Files
BYW-VoiceHub/app/components/Songs/AlbumDetailsModal.vue
T

667 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
@click.self="close"
>
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<div
class="relative w-full max-w-3xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
@click.stop
>
<!-- 头部 -->
<div class="flex items-start justify-between p-8 pb-4 gap-4">
<div class="flex items-start gap-4 min-w-0 flex-1">
<div class="w-20 h-20 rounded-2xl overflow-hidden flex-shrink-0 bg-bg-tertiary shadow-lg">
<img
v-if="albumInfo && albumInfo.cover"
:src="convertToHttps(albumInfo.cover)"
:alt="albumInfo && albumInfo.name ? albumInfo.name : locale.albumFallback"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
>
<div
v-else
class="w-full h-full bg-primary-hover-10 flex items-center justify-center text-primary"
>
<Icon name="disc" :size="32" />
</div>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-xl font-black text-text-primary tracking-tight truncate">
{{ albumInfo && albumInfo.name ? albumInfo.name : locale.albumFallback }}
</h3>
<p class="text-xs font-bold text-text-tertiary mt-1 truncate">
{{ albumInfo && albumInfo.artist ? albumInfo.artist : locale.unknownArtist }}
</p>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1.5 text-[11px] text-text-disabled">
<span v-if="albumInfo && albumInfo.size">
{{ callLocale('songCount', `${albumInfo.size} 首`, albumInfo.size) }}
</span>
<span v-if="albumInfo && albumInfo.publishTime">
{{ callLocale('publishTime', `发行时间: ${formatDate(albumInfo.publishTime)}`, formatDate(albumInfo.publishTime)) }}
</span>
<span v-if="albumInfo && albumInfo.company">
{{ callLocale('company', `唱片公司: ${albumInfo.company}`, albumInfo.company) }}
</span>
</div>
<!-- 专辑描述 -->
<p
v-if="albumInfo && albumInfo.description"
class="mt-2 text-[11px] text-text-tertiary leading-relaxed line-clamp-2 cursor-pointer hover:text-text-tertiary transition-colors"
:title="locale.viewFullIntro"
@click="showDescModal = true"
>
{{ albumInfo.description }}
</p>
</div>
</div>
<button
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex-shrink-0"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 搜索框 -->
<div class="px-8 pb-3">
<div class="relative">
<input
v-model="searchQuery"
type="text"
:placeholder="locale.searchPlaceholder"
class="w-full h-9 pl-9 pr-3 rounded-xl bg-bg-tertiary-60 border border-border-tertiary-50 text-text-primary text-sm placeholder-text-disabled focus:outline-none focus:border-primary-50 focus:bg-bg-tertiary transition-all"
>
<Icon name="search" :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" />
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex-1 flex items-center justify-center py-12">
<div class="flex flex-col items-center">
<AppSpinner :size="48" class="mb-4" />
<p class="text-sm font-bold text-text-tertiary uppercase tracking-widest">{{ locale.loading }}</p>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="flex-1 flex items-center justify-center py-12">
<div class="flex flex-col items-center text-error">
<div
class="w-16 h-16 rounded-3xl bg-error-10 flex items-center justify-center mb-4"
>
<Icon name="alert-circle" :size="32" />
</div>
<p class="text-sm font-bold uppercase tracking-widest">{{ error }}</p>
</div>
</div>
<!-- 歌曲列表 -->
<div v-else class="flex-1 overflow-y-auto px-8 pt-2 pb-4 custom-scrollbar">
<div
v-if="filteredSongs.length === 0"
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
>
<div
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
>
<Icon name="music" :size="32" class="opacity-20" />
</div>
<p class="text-sm font-bold uppercase tracking-widest">
{{ searchQuery ? locale.noMatches : locale.noSongs }}
</p>
</div>
<div v-else class="song-list space-y-1.5">
<div
v-for="(song, index) in filteredSongs"
:key="song.songmid"
class="group flex items-center p-2 sm:p-3 rounded-2xl transition-all"
:class="[
isCurrentSong(song)
? 'bg-primary-hover-10 border border-primary-50'
: 'bg-bg-tertiary-30 border border-border-secondary-50 hover:bg-bg-tertiary-50 hover:border-border-tertiary'
]"
>
<!-- 歌曲封面 -->
<div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl overflow-hidden flex-shrink-0 mr-3 bg-bg-tertiary">
<img
v-if="song.img"
:src="convertToHttps(song.img)"
:alt="song.name"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
loading="lazy"
>
<div
v-else
class="w-full h-full flex items-center justify-center text-text-disabled"
>
<Icon name="music" :size="16" />
</div>
</div>
<!-- 歌曲信息 -->
<div class="flex-1 min-w-0">
<h4
class="font-bold text-text-primary text-sm sm:text-base whitespace-normal sm:whitespace-nowrap sm:truncate line-clamp-2 sm:line-clamp-none group-hover:text-text-primary transition-colors"
>
{{ song.name }}
</h4>
<div
class="flex items-center gap-3 mt-0.5 text-[10px] text-text-tertiary font-bold uppercase tracking-wider"
>
<span class="truncate">{{ song.singer }}</span>
<span class="flex items-center shrink-0">
<Icon name="clock" :size="10" class="mr-1" />
{{ song.interval }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-2">
<!-- 预听/暂停按钮 -->
<button
class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary transition-all active:scale-95"
:title="isCurrentSong(song) && isPlaying ? locale.pause : locale.preview"
@click.stop="togglePlay(song)"
>
<Icon
:name="isCurrentSong(song) && isPlaying ? 'pause' : 'play'"
:size="16"
/>
</button>
<template v-if="song.status.played">
<!-- 已播放标签 -->
<div
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-success-10 text-success border border-success-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
{{ requestLocale.played }}
</div>
</template>
<template v-else-if="song.status.scheduled">
<!-- 已排期标签 -->
<div
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-warning-10 text-warning border border-warning-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
{{ requestLocale.scheduled }}
</div>
</template>
<template v-else-if="song.status.submitted">
<div class="flex flex-col sm:flex-row gap-2 items-center">
<!-- 已投稿标签 -->
<div
class="w-full text-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-bg-tertiary text-text-tertiary text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
{{ locale.submitted }}
</div>
<!-- 点赞按钮(非自己投稿) -->
<button
v-if="!isMySong(song)"
:disabled="song.status.voted || submitting"
:class="[
'w-full sm:w-auto px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest transition-all active:scale-95',
song.status.voted
? 'bg-error-10 text-error border border-error-20 cursor-not-allowed'
: 'bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-error'
]"
@click.stop="voteSong(song)"
>
<span class="flex items-center justify-center gap-1">
<Icon
name="heart"
:size="12"
:class="song.status.voted ? 'fill-current' : ''"
/>
<span>{{ song.status.voted ? requestLocale.liked : requestLocale.like }}</span>
</span>
</button>
</div>
</template>
<template v-else>
<!-- 投稿按钮(未投稿) -->
<button
:disabled="submitting"
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
@click.stop="selectSong(song)"
>
<span v-if="submitting && selectedSongId === song.songmid">{{ requestLocale.submitting }}</span>
<span v-else>{{ locale.submit }}</span>
</button>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
<!-- 专辑简介弹窗 -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showDescModal"
class="fixed inset-0 z-[200] flex items-center justify-center p-6"
@click.self="showDescModal = false"
>
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<div
class="relative w-full max-w-xl max-h-[70vh] bg-bg-secondary border border-border-secondary rounded-2xl p-6 overflow-y-auto custom-scrollbar"
@click.stop
>
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.albumIntro }}</h4>
<button
class="w-8 h-8 flex items-center justify-center rounded-lg bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all"
@click="showDescModal = false"
>
<Icon name="x" :size="16" />
</button>
</div>
<p class="text-sm text-text-tertiary leading-relaxed whitespace-pre-wrap">
{{ albumInfo?.description }}
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { convertToHttps } from '~/utils/url'
import { useLocale } from '~/utils/locale'
import { normalizeForMatch as normalizeStr } from '~/utils/song-name-normalize'
const props = defineProps({
show: Boolean,
albumId: {
type: [String, Number],
default: null
},
albumName: {
type: String,
default: null
},
platform: {
type: String,
default: 'netease' // netease 或 tencent
},
submittedSongs: {
type: Array,
default: () => []
},
currentUserId: {
type: Number,
default: null
}
})
const emit = defineEmits(['close', 'submit', 'play', 'vote'])
const { getCurrentSong, getPlayingStatus, pauseSong } = useAudioPlayer()
const { songs: songsLocale } = useLocale()
const fallbackRequestLocale = {
played: 'Played',
scheduled: 'Scheduled',
liked: 'Liked',
like: 'Like',
submitting: 'Submitting...'
}
const fallbackAlbumModalLocale = {
albumFallback: 'Album',
unknownArtist: 'Unknown artist',
songCount: (count) => `${count} songs`,
publishTime: (date) => `Published: ${date}`,
company: (company) => `Label: ${company}`,
viewFullIntro: 'View full intro',
searchPlaceholder: 'Search songs in this album...',
loading: 'Loading...',
noMatches: 'No matching songs found',
noSongs: 'No songs',
pause: 'Pause',
preview: 'Preview',
submitted: 'Submitted',
submit: 'Submit',
albumIntro: 'Album Intro',
loadFailed: 'Load failed. Please try again later',
fetchFailed: 'Failed to fetch album details',
unsupportedPlatform: 'Unsupported platform',
tencentUnsupported: 'QQ Music album details are not supported yet'
}
const requestLocale = computed(() => ({
...fallbackRequestLocale,
...(songsLocale.value?.requestForm || {})
}))
const locale = computed(() => ({
...fallbackAlbumModalLocale,
...(requestLocale.value?.albumModal || {})
}))
const { t: callLocale } = useLocaleText(locale)
const currentSong = getCurrentSong()
const isPlaying = getPlayingStatus()
const loading = ref(false)
const error = ref(null)
const albumInfo = ref(null)
const songs = ref([])
const submitting = ref(false)
const selectedSongId = ref(null)
const searchQuery = ref('')
const showDescModal = ref(false)
let albumAbortController = null
const songsWithStatus = computed(() => songs.value.map(s => ({ ...s, status: getSongStatus(s) })))
const filteredSongs = computed(() => {
if (!searchQuery.value.trim()) return songsWithStatus.value
const q = searchQuery.value.trim().toLowerCase()
return songsWithStatus.value.filter(s =>
(s.name?.toLowerCase() || '').includes(q) ||
(s.singer?.toLowerCase() || '').includes(q)
)
})
// 监听显示状态和专辑ID变化,加载专辑详情
watch(
[() => props.show, () => props.albumId],
async ([show, albumId]) => {
if (show && albumId) {
searchQuery.value = ''
await loadAlbumDetails()
} else {
if (albumAbortController) {
albumAbortController.abort()
albumAbortController = null
}
}
},
{ immediate: true }
)
const loadAlbumDetails = async () => {
if (!props.albumId) return
if (albumAbortController) {
albumAbortController.abort()
}
albumAbortController = new AbortController()
const signal = albumAbortController.signal
loading.value = true
error.value = null
albumInfo.value = null
songs.value = []
try {
if (props.platform === 'netease') {
// 使用网易云音乐 API 获取专辑详情
const response = await $fetch('/api/api-enhanced/netease/album', {
params: {
id: props.albumId
},
signal
})
if (response && response.album && Array.isArray(response.songs)) {
// 转换为统一格式
const album = response.album
const artistName = album.artist?.name || album.artists?.map(a => a?.name).filter(Boolean).join('/') || locale.value.unknownArtist
albumInfo.value = {
id: album.id,
name: album.name,
cover: album.picUrl,
artist: artistName,
publishTime: album.publishTime,
description: album.description,
company: album.company,
size: album.size
}
songs.value = response.songs.map((item, i) => {
const singerName = item.ar?.map(a => a?.name).filter(Boolean).join('/') || locale.value.unknownArtist
const durationMs = item.dt || 0
return {
songmid: item.id,
name: item.name,
singer: singerName,
albumName: album.name,
albumId: album.id,
img: item.al?.picUrl || album.picUrl,
interval: formatTime(Math.floor(durationMs / 1000)),
duration: durationMs / 1000,
trackNumber: item.no || i + 1,
source: 'netease'
}
})
} else {
throw new Error(locale.value.fetchFailed)
}
} else if (props.platform === 'tencent') {
// QQ音乐暂不支持,提示用户
error.value = locale.value.tencentUnsupported
} else {
error.value = locale.value.unsupportedPlatform
}
} catch (err) {
if (err?.name === 'AbortError') return
console.error('加载专辑详情失败:', err)
error.value = locale.value.loadFailed
} finally {
if (!signal.aborted) {
loading.value = false
}
}
}
const formatTime = (seconds) => {
if (isNaN(seconds) || seconds < 0) return '0:00'
const minutes = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return minutes + ':' + secs.toString().padStart(2, '0')
}
const formatDate = (timestamp) => {
if (!timestamp) return null
const parsedTimestamp = typeof timestamp === 'string' && /^\d+$/.test(timestamp) ? parseInt(timestamp, 10) : timestamp
const date = new Date(parsedTimestamp)
if (isNaN(date.getTime())) return null
return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0')
}
const isCurrentSong = (song) => {
if (!currentSong.value) return false
// 使用 songmid 匹配
return String(currentSong.value.musicId) === String(song.songmid) ||
String(currentSong.value.id) === String(song.songmid)
}
const submittedSongsMap = computed(() => {
const map = new Map()
props.submittedSongs.forEach((s) => {
if (s.musicId) {
map.set(String(s.musicId), s)
}
const key = normalizeStr(s.title) + '|' + normalizeStr(s.artist)
map.set(key, s)
})
return map
})
const getSongStatus = (song) => {
const songmidStr = String(song.songmid)
const key = normalizeStr(song.name) + '|' + normalizeStr(song.singer)
const submittedSong = submittedSongsMap.value.get(songmidStr) || submittedSongsMap.value.get(key)
if (submittedSong) {
return {
submitted: true,
voted: submittedSong.voted || false,
songId: submittedSong.id,
requesterId: submittedSong.requesterId,
played: submittedSong.played || false,
scheduled: submittedSong.scheduled || false
}
}
return {
submitted: false,
voted: false,
songId: null,
requesterId: null,
played: false,
scheduled: false
}
}
const isMySong = (song) => {
if (!props.currentUserId) return false
return (song.status || getSongStatus(song)).requesterId === props.currentUserId
}
const togglePlay = (song) => {
if (isCurrentSong(song) && isPlaying.value) {
pauseSong()
} else {
playSong(song)
}
}
const playSong = (song) => {
// 构建所有歌曲的播放列表
const playlist = songs.value.map(s => ({
id: s.songmid,
title: s.name,
song: s.name,
artist: s.singer,
singer: s.singer,
cover: s.img,
album: s.albumName,
albumId: s.albumId,
duration: s.duration * 1000,
musicPlatform: props.platform,
musicId: s.songmid,
sourceInfo: {
source: props.platform === 'tencent' ? 'vkeys' : 'netease-backup',
originalId: s.songmid,
fetchedAt: new Date(),
mid: s.songmid
}
}))
const currentIndex = playlist.findIndex(p => p.id === song.songmid)
emit('play', { song: playlist[currentIndex], playlist, playlistIndex: currentIndex })
}
const selectSong = (song) => {
if (submitting.value) return
submitting.value = true
selectedSongId.value = song.songmid
// 转换为投稿格式
const songData = {
id: song.songmid,
title: song.name,
song: song.name,
artist: song.singer,
singer: song.singer,
cover: song.img,
album: song.albumName,
albumId: song.albumId,
duration: song.duration * 1000,
musicPlatform: props.platform,
musicId: song.songmid,
sourceInfo: {
source: props.platform === 'tencent' ? 'vkeys' : 'netease-backup',
originalId: song.songmid,
fetchedAt: new Date(),
mid: song.songmid
}
}
emit('submit', songData)
}
const voteSong = (song) => {
if (submitting.value) return
const { songId, voted } = getSongStatus(song)
if (!songId || voted) return
emit('vote', { ...song, songId, voted })
}
const close = () => {
emit('close')
// 重置状态
submitting.value = false
selectedSongId.value = null
}
onBeforeUnmount(() => {
if (albumAbortController) {
albumAbortController.abort()
albumAbortController = null
}
})
// 暴露方法给父组件,用于在提交失败时重置状态
const resetSubmissionState = () => {
submitting.value = false
selectedSongId.value = null
}
defineExpose({
resetSubmissionState
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--overlay-10);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--overlay-20);
}
</style>