music / src /components /player /MoreActionsPanel.vue
ahutchen's picture
feat(search): 优化音乐源选择功能
71ebd26
<template>
<div class="more-actions-overlay" @click="$emit('close')">
<div class="more-actions-panel" @click.stop>
<!-- 歌曲信息 -->
<div v-if="type === 'song' && song" class="item-info">
<img
v-if="song.cover"
:src="song.cover"
:alt="song.name"
class="item-cover"
@error="handleSongImageError"
/>
<div v-else class="default-cover">
<i class="fas fa-music"></i>
</div>
<div class="item-details">
<div class="item-name">{{ song.name }}</div>
<div class="item-artist">{{ utils.formatArtist(song.artist) }}</div>
<div class="item-album" v-if="song.album">{{ song.album }}</div>
</div>
</div>
<!-- 歌单信息 -->
<div v-if="type === 'playlist' && playlist" class="item-info">
<img
v-if="playlist.cover"
:src="playlist.cover"
:alt="playlist.name"
class="item-cover"
@error="handlePlaylistImageError"
/>
<div v-else class="default-cover">
<i class="fas fa-music"></i>
</div>
<div class="item-details">
<div class="item-name">{{ playlist.name }}</div>
<div class="item-meta">{{ playlist.songs?.length || 0 }}首歌曲</div>
<div class="item-meta" v-if="playlist.updatedAt">{{ formatDate(playlist.updatedAt) }}</div>
</div>
</div>
<!-- 操作按钮列表 -->
<div class="actions-list">
<!-- 歌曲操作 -->
<template v-if="type === 'song'">
<button
class="action-item"
@click="handleAction('favorite')"
:class="{ active: isFavorite }"
>
<i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
<span>{{ isFavorite ? '取消收藏' : '添加到我的收藏' }}</span>
</button>
<button class="action-item" @click="handleAction('addToPlaylist')">
<i class="fas fa-plus"></i>
<span>添加到歌单</span>
</button>
<button class="action-item" @click="handleAction('download')">
<i class="fas fa-download"></i>
<span>下载到本地</span>
</button>
</template>
<!-- 歌单操作 -->
<template v-if="type === 'playlist'">
<button class="action-item" @click="handleAction('viewInfo')">
<i class="fas fa-info-circle"></i>
<span>查看信息</span>
</button>
<button class="action-item" @click="handleAction('editInfo')">
<i class="fas fa-edit"></i>
<span>编辑信息</span>
</button>
<button class="action-item" @click="handleAction('clearPlaylist')">
<i class="fas fa-trash-alt"></i>
<span>清空歌单</span>
</button>
<button class="action-item danger" @click="handleAction('deletePlaylist')">
<i class="fas fa-trash"></i>
<span>删除歌单</span>
</button>
</template>
<!-- 自定义操作 -->
<button
v-for="action in customActions"
:key="action.key"
class="action-item"
:class="action.class"
@click="handleAction(action.key)"
>
<i :class="action.icon"></i>
<span>{{ action.label }}</span>
</button>
</div>
<!-- 取消按钮 -->
<button class="cancel-btn" @click="$emit('close')">
取消
</button>
</div>
<!-- 播放列表选择对话框 -->
<PlaylistSelector
v-if="song"
:show="showPlaylistSelector"
:song="song"
@close="closePlaylistSelector"
@added="handleAddedToPlaylist"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useFavoritesStore } from '@/stores/favorites'
import { useToastStore } from '@/stores/toast'
import PlaylistSelector from '@/components/playlist/PlaylistSelector.vue'
import { utils, musicApi } from '@/services/musicApi'
const props = defineProps({
song: {
type: Object,
default: null
},
playlist: {
type: Object,
default: null
},
// 操作项类型:'song' | 'playlist'
type: {
type: String,
default: 'song',
validator: (value) => ['song', 'playlist'].includes(value)
},
// 自定义操作项
customActions: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['close', 'action'])
const favoritesStore = useFavoritesStore()
const toastStore = useToastStore()
const showPlaylistSelector = ref(false)
// 图片加载错误处理
const handleSongImageError = (event) => {
event.target.style.display = 'none'
}
const handlePlaylistImageError = (event) => {
event.target.style.display = 'none'
}
// 计算属性
const isFavorite = computed(() => {
return props.song ? favoritesStore.isFavorite(props.song) : false
})
const defaultCover = computed(() => {
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMDUpIiByeD0iMTIiLz4KPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzAsIDMwKSI+CjxwYXRoIGQ9Ik0xMCA1VjE2QzEwIDE3LjY1NyA4LjY1NyAxOSA3IDE5QzUuMzQzIDE5IDQgMTcuNjU3IDQgMTZDNCA0LjM0MyA1LjM0MyAxMyA3IDEzQzcuNTUyIDEzIDguMDY3IDEzLjE0NyA4LjUgMTMuNFY3LjVMMTUgM1YxMkMxNSAxMy42NTcgMTMuNjU3IDE1IDEyIDE1QzEwLjM0MyAxNSA5IDEzLjY1NyA5IDEyQzkgMTAuMzQzIDEwLjM0MyA5IDEyIDlDMTIuNTUyIDkgMTMuMDY3IDkuMTQ3IDEzLjUgOS40VjBMMTAgNVoiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC42KSIvPgo8L2c+Cjwvc3ZnPgo='
})
const defaultPlaylistCover = computed(() => {
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMDUpIiByeD0iMTIiLz4KPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzAsIDMwKSI+CjxwYXRoIGQ9Ik0xMCA1VjE2QzEwIDE3LjY1NyA4LjY1NyAxOSA3IDE5QzUuMzQzIDE5IDQgMTcuNjU3IDQgMTZDNCA0LjM0MyA1LjM0MyAxMyA3IDEzQzcuNTUyIDEzIDguMDY3IDEzLjE0NyA4LjUgMTMuNFY3LjVMMTUgM1YxMkMxNSAxMy42NTcgMTMuNjU3IDE1IDEyIDE1QzEwLjM0MyAxNSA5IDEzLjY1NyA5IDEyQzkgMTAuMzQzIDEwLjM0MyA5IDEyIDlDMTIuNTUyIDkgMTMuMDY3IDkuMTQ3IDEzLjUgOS40VjBMMTAgNVoiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC42KSIvPgo8L2c+Cjwvc3ZnPgo='
})
// 格式化日期
const formatDate = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
const now = new Date()
const diffTime = now - date
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
return '今天'
} else if (diffDays === 1) {
return '昨天'
} else if (diffDays < 7) {
return `${diffDays}天前`
} else {
return date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric'
})
}
}
// 方法
const handleAction = async (action) => {
switch (action) {
// 歌曲操作
case 'favorite':
if (!props.song) return
try {
if (isFavorite.value) {
await favoritesStore.removeFromFavorites(props.song)
toastStore.success('已从我喜欢的音乐中移除')
} else {
await favoritesStore.addToFavorites(props.song)
toastStore.success('已添加到我喜欢的音乐')
}
} catch (error) {
console.error('收藏操作失败:', error)
toastStore.error('操作失败,请重试')
}
break
case 'addToPlaylist':
if (!props.song) return
// 打开播放列表选择器
showPlaylistSelector.value = true
break
case 'download':
if (!props.song) return
try {
toastStore.info('正在获取下载链接...')
const audioUrl = await musicApi.getMusicUrl(props.song.source, props.song.id, '320')
if (audioUrl) {
const link = document.createElement('a')
link.href = audioUrl
link.download = `${utils.formatArtist(props.song.artist)} - ${props.song.name}.mp3`
link.target = '_blank'
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toastStore.success('开始下载')
} else {
toastStore.warning('该歌曲暂不支持下载')
}
} catch (error) {
console.error('下载失败:', error)
toastStore.error('下载失败,请重试')
}
break
// 歌单操作
case 'viewInfo':
case 'editInfo':
case 'clearPlaylist':
case 'deletePlaylist':
// 这些操作由父组件处理
emit('action', { action, playlist: props.playlist })
emit('close')
break
// 自定义操作
default:
emit('action', { action, song: props.song, playlist: props.playlist })
break
}
}
// 关闭播放列表选择器
const closePlaylistSelector = () => {
showPlaylistSelector.value = false
}
// 处理添加到歌单成功
const handleAddedToPlaylist = (data) => {
toastStore.success(data.message)
// 关闭当前面板
emit('close')
}
</script>
<style scoped>
.more-actions-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
z-index: 2000;
display: flex;
align-items: flex-end;
animation: fadeIn 0.3s ease-out;
}
.more-actions-panel {
width: 100%;
background: var(--bg-secondary);
border-radius: 16px 16px 0 0;
padding: 0 0 20px;
animation: slideUp 0.3s ease-out;
overflow: hidden;
}
.item-info {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 20px 20px;
border-bottom: 1px solid var(--border-light);
}
.item-cover {
width: 64px;
height: 64px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
}
.default-cover {
width: 64px;
height: 64px;
border-radius: 12px;
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
flex-shrink: 0;
}
.item-details {
flex: 1;
min-width: 0;
}
.item-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-artist {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-album {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta:last-child {
margin-bottom: 0;
font-size: 12px;
color: var(--text-tertiary);
}
.actions-list {
padding: 8px 0;
}
.action-item {
width: 100%;
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 16px;
text-align: left;
cursor: pointer;
transition: var(--transition-fast);
}
.action-item:hover {
background: var(--overlay-lighter);
}
.action-item:active {
background: rgba(255, 255, 255, 0.1);
}
.action-item i {
width: 20px;
text-align: center;
font-size: 16px;
flex-shrink: 0;
color: var(--text-secondary);
}
.action-item.active {
color: var(--accent-red);
}
.action-item.active i {
color: var(--accent-red);
}
.action-item.danger {
color: #ff4444;
}
.action-item.danger i {
color: #ff4444;
}
.cancel-btn {
width: calc(100% - 40px);
margin: 16px 20px 0;
padding: 16px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
font-size: 16px;
font-weight: 500;
border-radius: 12px;
cursor: pointer;
transition: var(--transition-fast);
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.cancel-btn:active {
background: rgba(255, 255, 255, 0.2);
transform: scale(0.98);
}
/* 响应式 */
@media (max-width: 375px) {
.item-info {
padding: 20px 16px 16px;
}
.item-cover {
width: 56px;
height: 56px;
border-radius: 10px;
}
.item-name {
font-size: 16px;
}
.item-artist {
font-size: 13px;
}
.item-album,
.item-meta {
font-size: 11px;
}
.action-item {
padding: 14px 16px;
font-size: 15px;
}
.action-item i {
width: 18px;
font-size: 15px;
}
.cancel-btn {
width: calc(100% - 32px);
margin: 16px 16px 0;
padding: 14px;
font-size: 15px;
}
}
@media (min-width: 768px) {
.more-actions-panel {
max-width: 480px;
margin: 0 auto 0;
border-radius: 16px;
}
.more-actions-overlay {
align-items: center;
padding: 20px;
}
.item-info {
padding: 28px 24px 24px;
}
.item-cover {
width: 72px;
height: 72px;
border-radius: 14px;
}
.action-item {
padding: 18px 24px;
}
.cancel-btn {
width: calc(100% - 48px);
margin: 20px 24px 0;
}
}
/* 动画 */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
/* 无障碍支持 */
.action-item:focus-visible {
outline: 2px solid var(--accent-red);
outline-offset: 2px;
}
.cancel-btn:focus-visible {
outline: 2px solid var(--accent-red);
outline-offset: 2px;
}
/* 触摸反馈 */
@media (hover: none) {
.action-item:active {
background: rgba(255, 255, 255, 0.1);
transform: scale(0.98);
}
}
</style>