| <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 |
| }, |
| |
| 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> |
| |