feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,666 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user