|
|
<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({ |
|
|
|
|
|
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) { |
|
|
|
|
|
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> |