music / src /components /common /ActionMenu.vue
ahutchen's picture
feat(search): 优化音乐源选择功能
71ebd26
<template>
<div class="action-menu-overlay" @click="$emit('close')">
<div class="action-menu" @click.stop>
<!-- 项目信息 -->
<div class="item-info">
<img
v-if="itemCover"
:src="itemCover"
:alt="itemName"
class="item-cover"
@error="handleImageError"
/>
<div v-else class="default-cover">
<i class="fas fa-music"></i>
</div>
<div class="item-details">
<div class="item-name">{{ itemName }}</div>
<div class="item-meta">{{ itemMeta }}</div>
<div class="item-meta secondary" v-if="itemSecondaryMeta">{{ itemSecondaryMeta }}</div>
</div>
</div>
<!-- 操作按钮列表 -->
<div class="actions-list">
<button
v-for="action in availableActions"
: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 && showPlaylistSelector"
: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' | 'playlist'
type: {
type: String,
required: true,
validator: (value) => ['song', 'playlist'].includes(value)
},
// 歌曲对象
song: {
type: Object,
default: null
},
// 歌单对象
playlist: {
type: Object,
default: null
},
// 自定义操作项
customActions: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['close', 'action'])
const favoritesStore = useFavoritesStore()
const toastStore = useToastStore()
const showPlaylistSelector = ref(false)
// 计算属性
const isFavorite = computed(() => {
return props.song ? favoritesStore.isFavorite(props.song) : false
})
const itemCover = computed(() => {
return props.song?.cover || props.playlist?.cover || null
})
const itemName = computed(() => {
return props.song?.name || props.playlist?.name || ''
})
const itemMeta = computed(() => {
if (props.song) {
return utils.formatArtist(props.song.artist)
}
if (props.playlist) {
return `${props.playlist.songs?.length || 0}首歌曲`
}
return ''
})
const itemSecondaryMeta = computed(() => {
if (props.song?.album) {
return props.song.album
}
if (props.playlist) {
// 优先显示描述,如果没有描述则显示更新时间
if (props.playlist.description) {
return props.playlist.description
}
if (props.playlist.updatedAt) {
return formatDate(props.playlist.updatedAt)
}
}
return null
})
// 可用操作项
const availableActions = computed(() => {
const actions = []
if (props.type === 'song' && props.song) {
// 歌曲操作
actions.push({
key: 'favorite',
icon: isFavorite.value ? 'fas fa-heart' : 'far fa-heart',
label: isFavorite.value ? '取消收藏' : '添加到我的收藏',
class: isFavorite.value ? 'active' : ''
})
actions.push({
key: 'addToPlaylist',
icon: 'fas fa-plus',
label: '添加到歌单',
class: ''
})
actions.push({
key: 'download',
icon: 'fas fa-download',
label: '下载到本地',
class: ''
})
} else if (props.type === 'playlist' && props.playlist) {
// 歌单操作(移除"查看信息"功能,因为 item-info 已经展示了信息)
actions.push({
key: 'editInfo',
icon: 'fas fa-edit',
label: '编辑信息',
class: ''
})
actions.push({
key: 'clearPlaylist',
icon: 'fas fa-trash-alt',
label: '清空歌单',
class: ''
})
// 只有非默认歌单才显示删除选项
if (!props.playlist?.isDefault) {
actions.push({
key: 'deletePlaylist',
icon: 'fas fa-trash',
label: '删除歌单',
class: 'danger'
})
}
}
// 添加自定义操作项
actions.push(...props.customActions)
return actions
})
// 方法
const handleImageError = (event) => {
event.target.style.display = 'none'
}
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
// 歌单操作和自定义操作
default:
emit('action', {
action,
song: props.song,
playlist: props.playlist
})
emit('close')
break
}
}
const closePlaylistSelector = () => {
showPlaylistSelector.value = false
}
const handleAddedToPlaylist = (data) => {
toastStore.success(data.message)
emit('close')
}
</script>
<style scoped>
.action-menu-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;
}
.action-menu {
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-meta {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta.secondary {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 0;
}
.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;
}
.default-cover {
width: 56px;
height: 56px;
border-radius: 10px;
font-size: 20px;
}
.item-name {
font-size: 16px;
}
.item-meta {
font-size: 13px;
}
.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) {
.action-menu {
max-width: 480px;
margin: 0 auto 0;
border-radius: 16px;
}
.action-menu-overlay {
align-items: center;
padding: 20px;
}
.item-info {
padding: 28px 24px 24px;
}
.item-cover {
width: 72px;
height: 72px;
border-radius: 14px;
}
.default-cover {
width: 72px;
height: 72px;
border-radius: 14px;
font-size: 28px;
}
.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>