feat(component): 新增歌单相关组件和功能
Browse files- 新增 ActionMenu 组件,用于显示歌曲或歌单的操作菜单
- 新增 DelayedConfirmDialog 组件,用于显示带倒计时的确认对话框
- 新增 PlaylistItem 组件,用于显示歌单列表项
- 新增 SongCover 组件,用于显示歌曲封面
- 新增 FavoriteItem 组件,用于显示收藏列表项
- 优化 App 组件,加载播放队列状态
- src/App.vue +3 -0
- src/components/common/ActionMenu.vue +552 -0
- src/components/common/DelayedConfirmDialog.vue +424 -0
- src/components/common/Modal.vue +1 -1
- src/components/common/PlaylistItem.vue +261 -0
- src/components/common/SongCover.vue +29 -8
- src/components/favorites/FavoriteItem.vue +46 -4
- src/components/player/MoreActionsPanel.vue +210 -43
- src/components/playlist/PlaylistSelector.vue +80 -151
- src/components/search/SearchResults.vue +3 -37
- src/components/search/SongItem.vue +37 -1
- src/composables/useSongCoverLoader.js +6 -2
- src/stores/playlist.js +64 -1
- src/utils/imageCache.js +2 -2
- src/views/CurrentPlaylistPage.vue +3 -10
- src/views/FullPlayerPage.vue +9 -3
- src/views/HistoryPage.vue +16 -42
- src/views/HomePage.vue +79 -0
- src/views/PlaylistDetailPage.vue +379 -291
- src/views/PlaylistsPage.vue +188 -155
- src/views/SettingsPage.vue +116 -83
src/App.vue
CHANGED
|
@@ -59,6 +59,7 @@
|
|
| 59 |
import { ref, computed, onMounted, watch } from 'vue'
|
| 60 |
import { useRoute } from 'vue-router'
|
| 61 |
import { usePlayerStore } from '@/stores/player'
|
|
|
|
| 62 |
import { useSearchStore } from '@/stores/search'
|
| 63 |
import { useFavoritesStore } from '@/stores/favorites'
|
| 64 |
import { useHistoryStore } from '@/stores/history'
|
|
@@ -74,6 +75,7 @@ const route = useRoute()
|
|
| 74 |
|
| 75 |
// Store
|
| 76 |
const playerStore = usePlayerStore()
|
|
|
|
| 77 |
const searchStore = useSearchStore()
|
| 78 |
const favoritesStore = useFavoritesStore()
|
| 79 |
const historyStore = useHistoryStore()
|
|
@@ -341,6 +343,7 @@ watch(() => playerStore.volume, (newVolume) => {
|
|
| 341 |
onMounted(() => {
|
| 342 |
// 加载所有存储状态
|
| 343 |
playerStore.loadPlayerState()
|
|
|
|
| 344 |
searchStore.loadSearchSettings()
|
| 345 |
searchStore.loadSearchHistory()
|
| 346 |
favoritesStore.loadFavorites()
|
|
|
|
| 59 |
import { ref, computed, onMounted, watch } from 'vue'
|
| 60 |
import { useRoute } from 'vue-router'
|
| 61 |
import { usePlayerStore } from '@/stores/player'
|
| 62 |
+
import { usePlayQueueStore } from '@/stores/playqueue'
|
| 63 |
import { useSearchStore } from '@/stores/search'
|
| 64 |
import { useFavoritesStore } from '@/stores/favorites'
|
| 65 |
import { useHistoryStore } from '@/stores/history'
|
|
|
|
| 75 |
|
| 76 |
// Store
|
| 77 |
const playerStore = usePlayerStore()
|
| 78 |
+
const playQueueStore = usePlayQueueStore()
|
| 79 |
const searchStore = useSearchStore()
|
| 80 |
const favoritesStore = useFavoritesStore()
|
| 81 |
const historyStore = useHistoryStore()
|
|
|
|
| 343 |
onMounted(() => {
|
| 344 |
// 加载所有存储状态
|
| 345 |
playerStore.loadPlayerState()
|
| 346 |
+
playQueueStore.loadQueue()
|
| 347 |
searchStore.loadSearchSettings()
|
| 348 |
searchStore.loadSearchHistory()
|
| 349 |
favoritesStore.loadFavorites()
|
src/components/common/ActionMenu.vue
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="action-menu-overlay" @click="$emit('close')">
|
| 3 |
+
<div class="action-menu" @click.stop>
|
| 4 |
+
<!-- 项目信息 -->
|
| 5 |
+
<div class="item-info">
|
| 6 |
+
<img
|
| 7 |
+
v-if="itemCover"
|
| 8 |
+
:src="itemCover"
|
| 9 |
+
:alt="itemName"
|
| 10 |
+
class="item-cover"
|
| 11 |
+
@error="handleImageError"
|
| 12 |
+
/>
|
| 13 |
+
<div v-else class="default-cover">
|
| 14 |
+
<i class="fas fa-music"></i>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<div class="item-details">
|
| 18 |
+
<div class="item-name">{{ itemName }}</div>
|
| 19 |
+
<div class="item-meta">{{ itemMeta }}</div>
|
| 20 |
+
<div class="item-meta secondary" v-if="itemSecondaryMeta">{{ itemSecondaryMeta }}</div>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<!-- 操作按钮列表 -->
|
| 25 |
+
<div class="actions-list">
|
| 26 |
+
<button
|
| 27 |
+
v-for="action in availableActions"
|
| 28 |
+
:key="action.key"
|
| 29 |
+
class="action-item"
|
| 30 |
+
:class="action.class"
|
| 31 |
+
@click="handleAction(action.key)"
|
| 32 |
+
>
|
| 33 |
+
<i :class="action.icon"></i>
|
| 34 |
+
<span>{{ action.label }}</span>
|
| 35 |
+
</button>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- 取消按钮 -->
|
| 39 |
+
<button class="cancel-btn" @click="$emit('close')">
|
| 40 |
+
取消
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<!-- 播放列表选择器(用于歌曲添加到歌单功能) -->
|
| 45 |
+
<PlaylistSelector
|
| 46 |
+
v-if="song && showPlaylistSelector"
|
| 47 |
+
:show="showPlaylistSelector"
|
| 48 |
+
:song="song"
|
| 49 |
+
@close="closePlaylistSelector"
|
| 50 |
+
@added="handleAddedToPlaylist"
|
| 51 |
+
/>
|
| 52 |
+
</div>
|
| 53 |
+
</template>
|
| 54 |
+
|
| 55 |
+
<script setup>
|
| 56 |
+
import { computed, ref } from 'vue'
|
| 57 |
+
import { useFavoritesStore } from '@/stores/favorites'
|
| 58 |
+
import { useToastStore } from '@/stores/toast'
|
| 59 |
+
import PlaylistSelector from '@/components/playlist/PlaylistSelector.vue'
|
| 60 |
+
import { utils } from '@/services/musicApi'
|
| 61 |
+
|
| 62 |
+
const props = defineProps({
|
| 63 |
+
// 项目类型:'song' | 'playlist'
|
| 64 |
+
type: {
|
| 65 |
+
type: String,
|
| 66 |
+
required: true,
|
| 67 |
+
validator: (value) => ['song', 'playlist'].includes(value)
|
| 68 |
+
},
|
| 69 |
+
// 歌曲对象
|
| 70 |
+
song: {
|
| 71 |
+
type: Object,
|
| 72 |
+
default: null
|
| 73 |
+
},
|
| 74 |
+
// 歌单对象
|
| 75 |
+
playlist: {
|
| 76 |
+
type: Object,
|
| 77 |
+
default: null
|
| 78 |
+
},
|
| 79 |
+
// 自定义操作项
|
| 80 |
+
customActions: {
|
| 81 |
+
type: Array,
|
| 82 |
+
default: () => []
|
| 83 |
+
}
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
const emit = defineEmits(['close', 'action'])
|
| 87 |
+
|
| 88 |
+
const favoritesStore = useFavoritesStore()
|
| 89 |
+
const toastStore = useToastStore()
|
| 90 |
+
const showPlaylistSelector = ref(false)
|
| 91 |
+
|
| 92 |
+
// 计算属性
|
| 93 |
+
const isFavorite = computed(() => {
|
| 94 |
+
return props.song ? favoritesStore.isFavorite(props.song) : false
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
const itemCover = computed(() => {
|
| 98 |
+
return props.song?.cover || props.playlist?.cover || null
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
const itemName = computed(() => {
|
| 102 |
+
return props.song?.name || props.playlist?.name || ''
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
const itemMeta = computed(() => {
|
| 106 |
+
if (props.song) {
|
| 107 |
+
return utils.formatArtist(props.song.artist)
|
| 108 |
+
}
|
| 109 |
+
if (props.playlist) {
|
| 110 |
+
return `${props.playlist.songs?.length || 0}首歌曲`
|
| 111 |
+
}
|
| 112 |
+
return ''
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
const itemSecondaryMeta = computed(() => {
|
| 116 |
+
if (props.song?.album) {
|
| 117 |
+
return props.song.album
|
| 118 |
+
}
|
| 119 |
+
if (props.playlist) {
|
| 120 |
+
// 优先显示描述,如果没有描述则显示更新时间
|
| 121 |
+
if (props.playlist.description) {
|
| 122 |
+
return props.playlist.description
|
| 123 |
+
}
|
| 124 |
+
if (props.playlist.updatedAt) {
|
| 125 |
+
return formatDate(props.playlist.updatedAt)
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
return null
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
// 可用操作项
|
| 132 |
+
const availableActions = computed(() => {
|
| 133 |
+
const actions = []
|
| 134 |
+
|
| 135 |
+
if (props.type === 'song' && props.song) {
|
| 136 |
+
// 歌曲操作
|
| 137 |
+
actions.push({
|
| 138 |
+
key: 'favorite',
|
| 139 |
+
icon: isFavorite.value ? 'fas fa-heart' : 'far fa-heart',
|
| 140 |
+
label: isFavorite.value ? '取消收藏' : '添加到我的收藏',
|
| 141 |
+
class: isFavorite.value ? 'active' : ''
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
actions.push({
|
| 145 |
+
key: 'addToPlaylist',
|
| 146 |
+
icon: 'fas fa-plus',
|
| 147 |
+
label: '添加到歌单',
|
| 148 |
+
class: ''
|
| 149 |
+
})
|
| 150 |
+
|
| 151 |
+
actions.push({
|
| 152 |
+
key: 'download',
|
| 153 |
+
icon: 'fas fa-download',
|
| 154 |
+
label: '下载到本地',
|
| 155 |
+
class: ''
|
| 156 |
+
})
|
| 157 |
+
} else if (props.type === 'playlist' && props.playlist) {
|
| 158 |
+
// 歌单操作(移除"查看信息"功能,因为 item-info 已经展示了信息)
|
| 159 |
+
actions.push({
|
| 160 |
+
key: 'editInfo',
|
| 161 |
+
icon: 'fas fa-edit',
|
| 162 |
+
label: '编辑信息',
|
| 163 |
+
class: ''
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
actions.push({
|
| 167 |
+
key: 'clearPlaylist',
|
| 168 |
+
icon: 'fas fa-trash-alt',
|
| 169 |
+
label: '清空歌单',
|
| 170 |
+
class: ''
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
// 只有非默认歌单才显示删除选项
|
| 174 |
+
if (!props.playlist?.isDefault) {
|
| 175 |
+
actions.push({
|
| 176 |
+
key: 'deletePlaylist',
|
| 177 |
+
icon: 'fas fa-trash',
|
| 178 |
+
label: '删除歌单',
|
| 179 |
+
class: 'danger'
|
| 180 |
+
})
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// 添加自定义操作项
|
| 185 |
+
actions.push(...props.customActions)
|
| 186 |
+
|
| 187 |
+
return actions
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
// 方法
|
| 191 |
+
const handleImageError = (event) => {
|
| 192 |
+
event.target.style.display = 'none'
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
const formatDate = (timestamp) => {
|
| 196 |
+
if (!timestamp) return ''
|
| 197 |
+
const date = new Date(timestamp)
|
| 198 |
+
const now = new Date()
|
| 199 |
+
const diffTime = now - date
|
| 200 |
+
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
| 201 |
+
|
| 202 |
+
if (diffDays === 0) {
|
| 203 |
+
return '今天'
|
| 204 |
+
} else if (diffDays === 1) {
|
| 205 |
+
return '昨天'
|
| 206 |
+
} else if (diffDays < 7) {
|
| 207 |
+
return `${diffDays}天前`
|
| 208 |
+
} else {
|
| 209 |
+
return date.toLocaleDateString('zh-CN', {
|
| 210 |
+
month: 'short',
|
| 211 |
+
day: 'numeric'
|
| 212 |
+
})
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
const handleAction = async (action) => {
|
| 217 |
+
switch (action) {
|
| 218 |
+
// 歌曲操作
|
| 219 |
+
case 'favorite':
|
| 220 |
+
if (!props.song) return
|
| 221 |
+
try {
|
| 222 |
+
if (isFavorite.value) {
|
| 223 |
+
await favoritesStore.removeFromFavorites(props.song)
|
| 224 |
+
toastStore.success('已从我喜欢的音乐中移除')
|
| 225 |
+
} else {
|
| 226 |
+
await favoritesStore.addToFavorites(props.song)
|
| 227 |
+
toastStore.success('已添加到我喜欢的音乐')
|
| 228 |
+
}
|
| 229 |
+
} catch (error) {
|
| 230 |
+
console.error('收藏操作失败:', error)
|
| 231 |
+
toastStore.error('操作失败,请重试')
|
| 232 |
+
}
|
| 233 |
+
break
|
| 234 |
+
|
| 235 |
+
case 'addToPlaylist':
|
| 236 |
+
if (!props.song) return
|
| 237 |
+
showPlaylistSelector.value = true
|
| 238 |
+
break
|
| 239 |
+
|
| 240 |
+
case 'download':
|
| 241 |
+
if (!props.song) return
|
| 242 |
+
try {
|
| 243 |
+
if (props.song.url) {
|
| 244 |
+
const link = document.createElement('a')
|
| 245 |
+
link.href = props.song.url
|
| 246 |
+
link.download = `${utils.formatArtist(props.song.artist)} - ${props.song.name}.mp3`
|
| 247 |
+
document.body.appendChild(link)
|
| 248 |
+
link.click()
|
| 249 |
+
document.body.removeChild(link)
|
| 250 |
+
toastStore.success('开始下载')
|
| 251 |
+
} else {
|
| 252 |
+
toastStore.warning('该歌曲暂不支持下载')
|
| 253 |
+
}
|
| 254 |
+
} catch (error) {
|
| 255 |
+
console.error('下载失败:', error)
|
| 256 |
+
toastStore.error('下载失败,请重试')
|
| 257 |
+
}
|
| 258 |
+
break
|
| 259 |
+
|
| 260 |
+
// 歌单操作和自定义操作
|
| 261 |
+
default:
|
| 262 |
+
emit('action', {
|
| 263 |
+
action,
|
| 264 |
+
song: props.song,
|
| 265 |
+
playlist: props.playlist
|
| 266 |
+
})
|
| 267 |
+
emit('close')
|
| 268 |
+
break
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
const closePlaylistSelector = () => {
|
| 273 |
+
showPlaylistSelector.value = false
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
const handleAddedToPlaylist = (data) => {
|
| 277 |
+
toastStore.success(data.message)
|
| 278 |
+
emit('close')
|
| 279 |
+
}
|
| 280 |
+
</script>
|
| 281 |
+
|
| 282 |
+
<style scoped>
|
| 283 |
+
.action-menu-overlay {
|
| 284 |
+
position: fixed;
|
| 285 |
+
top: 0;
|
| 286 |
+
left: 0;
|
| 287 |
+
right: 0;
|
| 288 |
+
bottom: 0;
|
| 289 |
+
background: rgba(0, 0, 0, 0.6);
|
| 290 |
+
backdrop-filter: blur(10px);
|
| 291 |
+
z-index: 2000;
|
| 292 |
+
display: flex;
|
| 293 |
+
align-items: flex-end;
|
| 294 |
+
animation: fadeIn 0.3s ease-out;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.action-menu {
|
| 298 |
+
width: 100%;
|
| 299 |
+
background: var(--bg-secondary);
|
| 300 |
+
border-radius: 16px 16px 0 0;
|
| 301 |
+
padding: 0 0 20px;
|
| 302 |
+
animation: slideUp 0.3s ease-out;
|
| 303 |
+
overflow: hidden;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.item-info {
|
| 307 |
+
display: flex;
|
| 308 |
+
align-items: center;
|
| 309 |
+
gap: 16px;
|
| 310 |
+
padding: 24px 20px 20px;
|
| 311 |
+
border-bottom: 1px solid var(--border-light);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.item-cover {
|
| 315 |
+
width: 64px;
|
| 316 |
+
height: 64px;
|
| 317 |
+
border-radius: 12px;
|
| 318 |
+
object-fit: cover;
|
| 319 |
+
flex-shrink: 0;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.default-cover {
|
| 323 |
+
width: 64px;
|
| 324 |
+
height: 64px;
|
| 325 |
+
border-radius: 12px;
|
| 326 |
+
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
| 327 |
+
display: flex;
|
| 328 |
+
align-items: center;
|
| 329 |
+
justify-content: center;
|
| 330 |
+
color: white;
|
| 331 |
+
font-size: 24px;
|
| 332 |
+
flex-shrink: 0;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.item-details {
|
| 336 |
+
flex: 1;
|
| 337 |
+
min-width: 0;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.item-name {
|
| 341 |
+
font-size: 18px;
|
| 342 |
+
font-weight: 600;
|
| 343 |
+
color: var(--text-primary);
|
| 344 |
+
margin-bottom: 6px;
|
| 345 |
+
white-space: nowrap;
|
| 346 |
+
overflow: hidden;
|
| 347 |
+
text-overflow: ellipsis;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.item-meta {
|
| 351 |
+
font-size: 14px;
|
| 352 |
+
color: var(--text-secondary);
|
| 353 |
+
margin-bottom: 4px;
|
| 354 |
+
white-space: nowrap;
|
| 355 |
+
overflow: hidden;
|
| 356 |
+
text-overflow: ellipsis;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.item-meta.secondary {
|
| 360 |
+
font-size: 12px;
|
| 361 |
+
color: var(--text-tertiary);
|
| 362 |
+
margin-bottom: 0;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.actions-list {
|
| 366 |
+
padding: 8px 0;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.action-item {
|
| 370 |
+
width: 100%;
|
| 371 |
+
display: flex;
|
| 372 |
+
align-items: center;
|
| 373 |
+
gap: 16px;
|
| 374 |
+
padding: 16px 20px;
|
| 375 |
+
border: none;
|
| 376 |
+
background: transparent;
|
| 377 |
+
color: var(--text-primary);
|
| 378 |
+
font-size: 16px;
|
| 379 |
+
text-align: left;
|
| 380 |
+
cursor: pointer;
|
| 381 |
+
transition: var(--transition-fast);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.action-item:hover {
|
| 385 |
+
background: var(--overlay-lighter);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.action-item:active {
|
| 389 |
+
background: rgba(255, 255, 255, 0.1);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.action-item i {
|
| 393 |
+
width: 20px;
|
| 394 |
+
text-align: center;
|
| 395 |
+
font-size: 16px;
|
| 396 |
+
flex-shrink: 0;
|
| 397 |
+
color: var(--text-secondary);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.action-item.active {
|
| 401 |
+
color: var(--accent-red);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.action-item.active i {
|
| 405 |
+
color: var(--accent-red);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.action-item.danger {
|
| 409 |
+
color: #ff4444;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.action-item.danger i {
|
| 413 |
+
color: #ff4444;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.cancel-btn {
|
| 417 |
+
width: calc(100% - 40px);
|
| 418 |
+
margin: 16px 20px 0;
|
| 419 |
+
padding: 16px;
|
| 420 |
+
border: none;
|
| 421 |
+
background: rgba(255, 255, 255, 0.1);
|
| 422 |
+
color: var(--text-primary);
|
| 423 |
+
font-size: 16px;
|
| 424 |
+
font-weight: 500;
|
| 425 |
+
border-radius: 12px;
|
| 426 |
+
cursor: pointer;
|
| 427 |
+
transition: var(--transition-fast);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.cancel-btn:hover {
|
| 431 |
+
background: rgba(255, 255, 255, 0.15);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.cancel-btn:active {
|
| 435 |
+
background: rgba(255, 255, 255, 0.2);
|
| 436 |
+
transform: scale(0.98);
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/* 响应式 */
|
| 440 |
+
@media (max-width: 375px) {
|
| 441 |
+
.item-info {
|
| 442 |
+
padding: 20px 16px 16px;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
.item-cover {
|
| 446 |
+
width: 56px;
|
| 447 |
+
height: 56px;
|
| 448 |
+
border-radius: 10px;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.default-cover {
|
| 452 |
+
width: 56px;
|
| 453 |
+
height: 56px;
|
| 454 |
+
border-radius: 10px;
|
| 455 |
+
font-size: 20px;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
.item-name {
|
| 459 |
+
font-size: 16px;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.item-meta {
|
| 463 |
+
font-size: 13px;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.action-item {
|
| 467 |
+
padding: 14px 16px;
|
| 468 |
+
font-size: 15px;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.action-item i {
|
| 472 |
+
width: 18px;
|
| 473 |
+
font-size: 15px;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.cancel-btn {
|
| 477 |
+
width: calc(100% - 32px);
|
| 478 |
+
margin: 16px 16px 0;
|
| 479 |
+
padding: 14px;
|
| 480 |
+
font-size: 15px;
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
@media (min-width: 768px) {
|
| 485 |
+
.action-menu {
|
| 486 |
+
max-width: 480px;
|
| 487 |
+
margin: 0 auto 0;
|
| 488 |
+
border-radius: 16px;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.action-menu-overlay {
|
| 492 |
+
align-items: center;
|
| 493 |
+
padding: 20px;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.item-info {
|
| 497 |
+
padding: 28px 24px 24px;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.item-cover {
|
| 501 |
+
width: 72px;
|
| 502 |
+
height: 72px;
|
| 503 |
+
border-radius: 14px;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.default-cover {
|
| 507 |
+
width: 72px;
|
| 508 |
+
height: 72px;
|
| 509 |
+
border-radius: 14px;
|
| 510 |
+
font-size: 28px;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.action-item {
|
| 514 |
+
padding: 18px 24px;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.cancel-btn {
|
| 518 |
+
width: calc(100% - 48px);
|
| 519 |
+
margin: 20px 24px 0;
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
/* 动画 */
|
| 524 |
+
@keyframes fadeIn {
|
| 525 |
+
from { opacity: 0; }
|
| 526 |
+
to { opacity: 1; }
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
@keyframes slideUp {
|
| 530 |
+
from { transform: translateY(100%); }
|
| 531 |
+
to { transform: translateY(0); }
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
/* 无障碍支持 */
|
| 535 |
+
.action-item:focus-visible {
|
| 536 |
+
outline: 2px solid var(--accent-red);
|
| 537 |
+
outline-offset: 2px;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
.cancel-btn:focus-visible {
|
| 541 |
+
outline: 2px solid var(--accent-red);
|
| 542 |
+
outline-offset: 2px;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
/* 触摸反馈 */
|
| 546 |
+
@media (hover: none) {
|
| 547 |
+
.action-item:active {
|
| 548 |
+
background: rgba(255, 255, 255, 0.1);
|
| 549 |
+
transform: scale(0.98);
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
</style>
|
src/components/common/DelayedConfirmDialog.vue
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="delayed-confirm-overlay" v-if="visible" @click="handleCancel">
|
| 3 |
+
<div class="delayed-confirm-dialog" @click.stop>
|
| 4 |
+
<div class="dialog-header">
|
| 5 |
+
<div class="warning-icon">
|
| 6 |
+
<i class="fas fa-exclamation-triangle"></i>
|
| 7 |
+
</div>
|
| 8 |
+
<h3 class="dialog-title">{{ title }}</h3>
|
| 9 |
+
</div>
|
| 10 |
+
|
| 11 |
+
<div class="dialog-content">
|
| 12 |
+
<p class="dialog-message">{{ message }}</p>
|
| 13 |
+
<div v-if="showCountdown" class="countdown-section">
|
| 14 |
+
<div class="countdown-circle">
|
| 15 |
+
<svg class="countdown-svg" viewBox="0 0 100 100">
|
| 16 |
+
<circle
|
| 17 |
+
class="countdown-bg"
|
| 18 |
+
cx="50"
|
| 19 |
+
cy="50"
|
| 20 |
+
r="45"
|
| 21 |
+
/>
|
| 22 |
+
<circle
|
| 23 |
+
class="countdown-progress"
|
| 24 |
+
cx="50"
|
| 25 |
+
cy="50"
|
| 26 |
+
r="45"
|
| 27 |
+
:style="{ strokeDasharray: circumference, strokeDashoffset: dashOffset }"
|
| 28 |
+
/>
|
| 29 |
+
</svg>
|
| 30 |
+
<span class="countdown-text">{{ remainingSeconds }}</span>
|
| 31 |
+
</div>
|
| 32 |
+
<p class="countdown-desc">{{ remainingSeconds }}秒后可以确认操作</p>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div class="dialog-actions">
|
| 37 |
+
<button
|
| 38 |
+
class="dialog-btn dialog-btn-cancel"
|
| 39 |
+
@click="handleCancel"
|
| 40 |
+
>
|
| 41 |
+
{{ cancelText }}
|
| 42 |
+
</button>
|
| 43 |
+
<button
|
| 44 |
+
class="dialog-btn dialog-btn-confirm"
|
| 45 |
+
@click="handleConfirm"
|
| 46 |
+
:disabled="isCountingDown"
|
| 47 |
+
:class="{
|
| 48 |
+
danger: type === 'danger',
|
| 49 |
+
disabled: isCountingDown
|
| 50 |
+
}"
|
| 51 |
+
>
|
| 52 |
+
<i v-if="isCountingDown" class="fas fa-spinner fa-spin"></i>
|
| 53 |
+
{{ isCountingDown ? `等待 ${remainingSeconds}s` : confirmText }}
|
| 54 |
+
</button>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</template>
|
| 59 |
+
|
| 60 |
+
<script setup>
|
| 61 |
+
import { ref, computed, onUnmounted } from 'vue'
|
| 62 |
+
|
| 63 |
+
const props = defineProps({
|
| 64 |
+
title: {
|
| 65 |
+
type: String,
|
| 66 |
+
default: '确认操作'
|
| 67 |
+
},
|
| 68 |
+
message: {
|
| 69 |
+
type: String,
|
| 70 |
+
required: true
|
| 71 |
+
},
|
| 72 |
+
confirmText: {
|
| 73 |
+
type: String,
|
| 74 |
+
default: '确认'
|
| 75 |
+
},
|
| 76 |
+
cancelText: {
|
| 77 |
+
type: String,
|
| 78 |
+
default: '取消'
|
| 79 |
+
},
|
| 80 |
+
type: {
|
| 81 |
+
type: String,
|
| 82 |
+
default: 'normal', // 'normal' | 'danger'
|
| 83 |
+
},
|
| 84 |
+
delaySeconds: {
|
| 85 |
+
type: Number,
|
| 86 |
+
default: 5 // 默认5秒延时
|
| 87 |
+
}
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
const emit = defineEmits(['confirm', 'cancel'])
|
| 91 |
+
|
| 92 |
+
const visible = ref(false)
|
| 93 |
+
const isCountingDown = ref(false)
|
| 94 |
+
const remainingSeconds = ref(0)
|
| 95 |
+
let countdownInterval = null
|
| 96 |
+
|
| 97 |
+
// SVG圆形进度条相关计算
|
| 98 |
+
const circumference = computed(() => 2 * Math.PI * 45)
|
| 99 |
+
const dashOffset = computed(() => {
|
| 100 |
+
const progress = (props.delaySeconds - remainingSeconds.value) / props.delaySeconds
|
| 101 |
+
return circumference.value * (1 - progress)
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
const showCountdown = computed(() => isCountingDown.value && remainingSeconds.value > 0)
|
| 105 |
+
|
| 106 |
+
const show = () => {
|
| 107 |
+
visible.value = true
|
| 108 |
+
startCountdown()
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const hide = () => {
|
| 112 |
+
visible.value = false
|
| 113 |
+
stopCountdown()
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const startCountdown = () => {
|
| 117 |
+
isCountingDown.value = true
|
| 118 |
+
remainingSeconds.value = props.delaySeconds
|
| 119 |
+
|
| 120 |
+
countdownInterval = setInterval(() => {
|
| 121 |
+
remainingSeconds.value--
|
| 122 |
+
|
| 123 |
+
if (remainingSeconds.value <= 0) {
|
| 124 |
+
isCountingDown.value = false
|
| 125 |
+
stopCountdown()
|
| 126 |
+
}
|
| 127 |
+
}, 1000)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const stopCountdown = () => {
|
| 131 |
+
if (countdownInterval) {
|
| 132 |
+
clearInterval(countdownInterval)
|
| 133 |
+
countdownInterval = null
|
| 134 |
+
}
|
| 135 |
+
isCountingDown.value = false
|
| 136 |
+
remainingSeconds.value = 0
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const handleConfirm = () => {
|
| 140 |
+
if (isCountingDown.value) return
|
| 141 |
+
|
| 142 |
+
emit('confirm')
|
| 143 |
+
hide()
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const handleCancel = () => {
|
| 147 |
+
emit('cancel')
|
| 148 |
+
hide()
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// 清理定时器
|
| 152 |
+
onUnmounted(() => {
|
| 153 |
+
stopCountdown()
|
| 154 |
+
})
|
| 155 |
+
|
| 156 |
+
defineExpose({
|
| 157 |
+
show,
|
| 158 |
+
hide
|
| 159 |
+
})
|
| 160 |
+
</script>
|
| 161 |
+
|
| 162 |
+
<style scoped>
|
| 163 |
+
.delayed-confirm-overlay {
|
| 164 |
+
position: fixed;
|
| 165 |
+
top: 0;
|
| 166 |
+
left: 0;
|
| 167 |
+
right: 0;
|
| 168 |
+
bottom: 0;
|
| 169 |
+
background: rgba(0, 0, 0, 0.7);
|
| 170 |
+
backdrop-filter: blur(12px);
|
| 171 |
+
z-index: 3000;
|
| 172 |
+
display: flex;
|
| 173 |
+
align-items: center;
|
| 174 |
+
justify-content: center;
|
| 175 |
+
animation: fadeIn 0.3s ease;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.delayed-confirm-dialog {
|
| 179 |
+
background: var(--bg-card);
|
| 180 |
+
border-radius: 20px;
|
| 181 |
+
padding: 0;
|
| 182 |
+
width: 90%;
|
| 183 |
+
max-width: 420px;
|
| 184 |
+
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
|
| 185 |
+
animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
| 186 |
+
overflow: hidden;
|
| 187 |
+
border: 1px solid var(--border-light);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.dialog-header {
|
| 191 |
+
display: flex;
|
| 192 |
+
flex-direction: column;
|
| 193 |
+
align-items: center;
|
| 194 |
+
padding: 30px 24px 20px;
|
| 195 |
+
background: var(--bg-gradient-1);
|
| 196 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.warning-icon {
|
| 200 |
+
width: 60px;
|
| 201 |
+
height: 60px;
|
| 202 |
+
background: linear-gradient(135deg, #ff6b6b, #ff5252);
|
| 203 |
+
border-radius: 50%;
|
| 204 |
+
display: flex;
|
| 205 |
+
align-items: center;
|
| 206 |
+
justify-content: center;
|
| 207 |
+
margin-bottom: 16px;
|
| 208 |
+
box-shadow: 0 8px 20px rgba(255, 107, 107, 0.3);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.warning-icon i {
|
| 212 |
+
font-size: 24px;
|
| 213 |
+
color: white;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.dialog-title {
|
| 217 |
+
font-size: 20px;
|
| 218 |
+
font-weight: 700;
|
| 219 |
+
color: var(--text-primary);
|
| 220 |
+
margin: 0;
|
| 221 |
+
text-align: center;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.dialog-content {
|
| 225 |
+
padding: 24px;
|
| 226 |
+
text-align: center;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.dialog-message {
|
| 230 |
+
font-size: 16px;
|
| 231 |
+
color: var(--text-secondary);
|
| 232 |
+
line-height: 1.5;
|
| 233 |
+
margin: 0 0 24px 0;
|
| 234 |
+
text-align: center;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.countdown-section {
|
| 238 |
+
display: flex;
|
| 239 |
+
flex-direction: column;
|
| 240 |
+
align-items: center;
|
| 241 |
+
gap: 16px;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.countdown-circle {
|
| 245 |
+
position: relative;
|
| 246 |
+
width: 80px;
|
| 247 |
+
height: 80px;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.countdown-svg {
|
| 251 |
+
width: 100%;
|
| 252 |
+
height: 100%;
|
| 253 |
+
transform: rotate(-90deg);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.countdown-bg {
|
| 257 |
+
fill: none;
|
| 258 |
+
stroke: var(--border-lighter);
|
| 259 |
+
stroke-width: 4;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.countdown-progress {
|
| 263 |
+
fill: none;
|
| 264 |
+
stroke: var(--accent-red);
|
| 265 |
+
stroke-width: 4;
|
| 266 |
+
stroke-linecap: round;
|
| 267 |
+
transition: stroke-dashoffset 1s linear;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.countdown-text {
|
| 271 |
+
position: absolute;
|
| 272 |
+
top: 50%;
|
| 273 |
+
left: 50%;
|
| 274 |
+
transform: translate(-50%, -50%);
|
| 275 |
+
font-size: 24px;
|
| 276 |
+
font-weight: 700;
|
| 277 |
+
color: var(--accent-red);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.countdown-desc {
|
| 281 |
+
font-size: 14px;
|
| 282 |
+
color: var(--text-tertiary);
|
| 283 |
+
margin: 0;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.dialog-actions {
|
| 287 |
+
display: flex;
|
| 288 |
+
gap: 12px;
|
| 289 |
+
padding: 20px 24px 30px;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.dialog-btn {
|
| 293 |
+
flex: 1;
|
| 294 |
+
padding: 14px 24px;
|
| 295 |
+
border: none;
|
| 296 |
+
border-radius: 12px;
|
| 297 |
+
font-size: 16px;
|
| 298 |
+
font-weight: 600;
|
| 299 |
+
cursor: pointer;
|
| 300 |
+
transition: all 0.2s ease;
|
| 301 |
+
min-height: 48px;
|
| 302 |
+
display: flex;
|
| 303 |
+
align-items: center;
|
| 304 |
+
justify-content: center;
|
| 305 |
+
gap: 8px;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.dialog-btn-cancel {
|
| 309 |
+
background: var(--bg-secondary);
|
| 310 |
+
color: var(--text-secondary);
|
| 311 |
+
border: 2px solid var(--border-light);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.dialog-btn-cancel:hover {
|
| 315 |
+
background: var(--bg-tertiary);
|
| 316 |
+
color: var(--text-primary);
|
| 317 |
+
border-color: var(--border-strong);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.dialog-btn-confirm {
|
| 321 |
+
background: var(--accent-red);
|
| 322 |
+
color: white;
|
| 323 |
+
border: 2px solid transparent;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.dialog-btn-confirm:hover:not(.disabled) {
|
| 327 |
+
background: var(--accent-red-hover);
|
| 328 |
+
transform: translateY(-1px);
|
| 329 |
+
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.dialog-btn-confirm.danger {
|
| 333 |
+
background: #ff4444;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.dialog-btn-confirm.danger:hover:not(.disabled) {
|
| 337 |
+
background: #ff2222;
|
| 338 |
+
box-shadow: 0 4px 12px rgba(255, 68, 68, 0.4);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.dialog-btn-confirm.disabled {
|
| 342 |
+
background: var(--bg-secondary);
|
| 343 |
+
color: var(--text-tertiary);
|
| 344 |
+
cursor: not-allowed;
|
| 345 |
+
transform: none;
|
| 346 |
+
box-shadow: none;
|
| 347 |
+
border-color: var(--border-light);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
@keyframes fadeIn {
|
| 351 |
+
from {
|
| 352 |
+
opacity: 0;
|
| 353 |
+
}
|
| 354 |
+
to {
|
| 355 |
+
opacity: 1;
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
@keyframes slideUp {
|
| 360 |
+
from {
|
| 361 |
+
opacity: 0;
|
| 362 |
+
transform: translateY(30px) scale(0.9);
|
| 363 |
+
}
|
| 364 |
+
to {
|
| 365 |
+
opacity: 1;
|
| 366 |
+
transform: translateY(0) scale(1);
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
/* 响应式 */
|
| 371 |
+
@media (max-width: 480px) {
|
| 372 |
+
.delayed-confirm-dialog {
|
| 373 |
+
width: 95%;
|
| 374 |
+
max-width: none;
|
| 375 |
+
border-radius: 16px;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.dialog-header {
|
| 379 |
+
padding: 24px 20px 16px;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.warning-icon {
|
| 383 |
+
width: 50px;
|
| 384 |
+
height: 50px;
|
| 385 |
+
margin-bottom: 12px;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.warning-icon i {
|
| 389 |
+
font-size: 20px;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.dialog-title {
|
| 393 |
+
font-size: 18px;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.dialog-content {
|
| 397 |
+
padding: 20px;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.dialog-message {
|
| 401 |
+
font-size: 15px;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.countdown-circle {
|
| 405 |
+
width: 70px;
|
| 406 |
+
height: 70px;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.countdown-text {
|
| 410 |
+
font-size: 20px;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.dialog-actions {
|
| 414 |
+
padding: 16px 20px 24px;
|
| 415 |
+
flex-direction: column;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.dialog-btn {
|
| 419 |
+
width: 100%;
|
| 420 |
+
padding: 12px 20px;
|
| 421 |
+
font-size: 15px;
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
</style>
|
src/components/common/Modal.vue
CHANGED
|
@@ -122,7 +122,7 @@ const close = () => {
|
|
| 122 |
|
| 123 |
// 生命周期
|
| 124 |
onMounted(() => {
|
| 125 |
-
|
| 126 |
})
|
| 127 |
|
| 128 |
onUnmounted(() => {
|
|
|
|
| 122 |
|
| 123 |
// 生命周期
|
| 124 |
onMounted(() => {
|
| 125 |
+
// 移除自动打开逻辑,改为由父组件控制
|
| 126 |
})
|
| 127 |
|
| 128 |
onUnmounted(() => {
|
src/components/common/PlaylistItem.vue
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<div class="playlist-item" :class="{
|
| 3 |
+
selected: isSelected
|
| 4 |
+
}">
|
| 5 |
+
<!-- 歌单信息 -->
|
| 6 |
+
<div class="playlist-info" @click="handleClick">
|
| 7 |
+
<div class="playlist-cover">
|
| 8 |
+
<img
|
| 9 |
+
v-if="playlist.cover"
|
| 10 |
+
:src="playlist.cover"
|
| 11 |
+
:alt="playlist.name"
|
| 12 |
+
/>
|
| 13 |
+
<div v-else class="default-cover">
|
| 14 |
+
<i class="fas fa-music"></i>
|
| 15 |
+
</div>
|
| 16 |
+
<div class="play-overlay">
|
| 17 |
+
<i class="fas fa-play"></i>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div class="playlist-details">
|
| 22 |
+
<h3 class="playlist-name">{{ playlist.name }}</h3>
|
| 23 |
+
<p class="playlist-meta">
|
| 24 |
+
<span class="song-count">{{ playlist.songs?.length || 0 }}首歌曲</span>
|
| 25 |
+
<span v-if="playlist.updatedAt" class="separator">•</span>
|
| 26 |
+
<span v-if="playlist.updatedAt" class="update-time">{{ formatDate(playlist.updatedAt) }}</span>
|
| 27 |
+
</p>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<!-- 操作按钮 -->
|
| 32 |
+
<div class="playlist-actions">
|
| 33 |
+
<button
|
| 34 |
+
class="action-btn more-btn"
|
| 35 |
+
@click="handleShowMoreActions"
|
| 36 |
+
title="更多操作"
|
| 37 |
+
>
|
| 38 |
+
<i class="fas fa-ellipsis-h"></i>
|
| 39 |
+
</button>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
</template>
|
| 43 |
+
|
| 44 |
+
<script setup>
|
| 45 |
+
import { ref, onMounted, watch } from 'vue'
|
| 46 |
+
|
| 47 |
+
const props = defineProps({
|
| 48 |
+
playlist: {
|
| 49 |
+
type: Object,
|
| 50 |
+
required: true
|
| 51 |
+
},
|
| 52 |
+
index: {
|
| 53 |
+
type: Number,
|
| 54 |
+
required: true
|
| 55 |
+
},
|
| 56 |
+
// 是否被选中
|
| 57 |
+
isSelected: {
|
| 58 |
+
type: Boolean,
|
| 59 |
+
default: false
|
| 60 |
+
}
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
const emit = defineEmits(['click', 'showMoreActions'])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
// 方法
|
| 68 |
+
const formatDate = (timestamp) => {
|
| 69 |
+
if (!timestamp) return ''
|
| 70 |
+
const date = new Date(timestamp)
|
| 71 |
+
const now = new Date()
|
| 72 |
+
const diffTime = now - date
|
| 73 |
+
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
| 74 |
+
|
| 75 |
+
if (diffDays === 0) {
|
| 76 |
+
return '今天'
|
| 77 |
+
} else if (diffDays === 1) {
|
| 78 |
+
return '昨天'
|
| 79 |
+
} else if (diffDays < 7) {
|
| 80 |
+
return `${diffDays}天前`
|
| 81 |
+
} else {
|
| 82 |
+
return date.toLocaleDateString('zh-CN', {
|
| 83 |
+
month: 'short',
|
| 84 |
+
day: 'numeric'
|
| 85 |
+
})
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const handleClick = () => {
|
| 90 |
+
emit('click', props.playlist, props.index)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const handleShowMoreActions = (event) => {
|
| 94 |
+
event.stopPropagation()
|
| 95 |
+
emit('showMoreActions', props.playlist, event)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// 生命周期
|
| 99 |
+
onMounted(() => {
|
| 100 |
+
// 如果需要懒加载可以在这里添加
|
| 101 |
+
})
|
| 102 |
+
</script>
|
| 103 |
+
|
| 104 |
+
<style scoped>
|
| 105 |
+
.playlist-item {
|
| 106 |
+
display: flex;
|
| 107 |
+
align-items: center;
|
| 108 |
+
padding: 12px 16px;
|
| 109 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 110 |
+
background: var(--bg-card);
|
| 111 |
+
transition: var(--transition-fast);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.playlist-item:hover {
|
| 115 |
+
background: var(--bg-gradient-1);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.playlist-item.selected {
|
| 119 |
+
background: var(--bg-gradient-1);
|
| 120 |
+
border-left: 4px solid var(--accent-red);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.playlist-info {
|
| 124 |
+
display: flex;
|
| 125 |
+
align-items: center;
|
| 126 |
+
flex: 1;
|
| 127 |
+
cursor: pointer;
|
| 128 |
+
gap: 12px;
|
| 129 |
+
min-width: 0;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.playlist-cover {
|
| 133 |
+
position: relative;
|
| 134 |
+
width: 50px;
|
| 135 |
+
height: 50px;
|
| 136 |
+
border-radius: var(--radius-small);
|
| 137 |
+
overflow: hidden;
|
| 138 |
+
flex-shrink: 0;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.playlist-cover img {
|
| 142 |
+
width: 100%;
|
| 143 |
+
height: 100%;
|
| 144 |
+
object-fit: cover;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.default-cover {
|
| 148 |
+
width: 100%;
|
| 149 |
+
height: 100%;
|
| 150 |
+
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
| 151 |
+
display: flex;
|
| 152 |
+
align-items: center;
|
| 153 |
+
justify-content: center;
|
| 154 |
+
color: white;
|
| 155 |
+
font-size: 20px;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.play-overlay {
|
| 159 |
+
position: absolute;
|
| 160 |
+
top: 0;
|
| 161 |
+
left: 0;
|
| 162 |
+
right: 0;
|
| 163 |
+
bottom: 0;
|
| 164 |
+
background: rgba(0, 0, 0, 0.5);
|
| 165 |
+
display: flex;
|
| 166 |
+
align-items: center;
|
| 167 |
+
justify-content: center;
|
| 168 |
+
opacity: 0;
|
| 169 |
+
transition: var(--transition-fast);
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.playlist-cover:hover .play-overlay {
|
| 173 |
+
opacity: 1;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.play-overlay i {
|
| 177 |
+
color: white;
|
| 178 |
+
font-size: 16px;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.playlist-details {
|
| 182 |
+
flex: 1;
|
| 183 |
+
min-width: 0;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.playlist-name {
|
| 187 |
+
font-size: 16px;
|
| 188 |
+
font-weight: 600;
|
| 189 |
+
color: var(--text-primary);
|
| 190 |
+
margin: 0 0 4px;
|
| 191 |
+
overflow: hidden;
|
| 192 |
+
text-overflow: ellipsis;
|
| 193 |
+
white-space: nowrap;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.playlist-meta {
|
| 197 |
+
font-size: 13px;
|
| 198 |
+
color: var(--text-secondary);
|
| 199 |
+
margin: 0;
|
| 200 |
+
overflow: hidden;
|
| 201 |
+
text-overflow: ellipsis;
|
| 202 |
+
white-space: nowrap;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.separator {
|
| 206 |
+
margin: 0 4px;
|
| 207 |
+
opacity: 0.6;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.playlist-actions {
|
| 211 |
+
display: flex;
|
| 212 |
+
gap: 8px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.playlist-actions .action-btn {
|
| 216 |
+
width: 36px;
|
| 217 |
+
height: 36px;
|
| 218 |
+
padding: 0;
|
| 219 |
+
border-radius: 50%;
|
| 220 |
+
background: rgba(255, 255, 255, 0.1);
|
| 221 |
+
color: var(--text-secondary);
|
| 222 |
+
display: flex;
|
| 223 |
+
align-items: center;
|
| 224 |
+
justify-content: center;
|
| 225 |
+
font-size: 14px;
|
| 226 |
+
border: none;
|
| 227 |
+
cursor: pointer;
|
| 228 |
+
transition: var(--transition-fast);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.playlist-actions .action-btn:hover {
|
| 232 |
+
background: rgba(255, 255, 255, 0.2);
|
| 233 |
+
color: var(--text-primary);
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/* 响应式 */
|
| 237 |
+
@media (max-width: 375px) {
|
| 238 |
+
.playlist-item {
|
| 239 |
+
padding: 12px;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.playlist-cover {
|
| 243 |
+
width: 45px;
|
| 244 |
+
height: 45px;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.playlist-name {
|
| 248 |
+
font-size: 15px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.playlist-meta {
|
| 252 |
+
font-size: 12px;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.playlist-actions .action-btn {
|
| 256 |
+
width: 32px;
|
| 257 |
+
height: 32px;
|
| 258 |
+
font-size: 12px;
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
</style>
|
src/components/common/SongCover.vue
CHANGED
|
@@ -1,11 +1,15 @@
|
|
| 1 |
<template>
|
| 2 |
<div class="song-cover">
|
| 3 |
<img
|
|
|
|
| 4 |
:src="coverUrl"
|
| 5 |
:alt="song.name"
|
| 6 |
class="cover-image"
|
| 7 |
@error="handleImageError"
|
| 8 |
/>
|
|
|
|
|
|
|
|
|
|
| 9 |
<slot></slot>
|
| 10 |
</div>
|
| 11 |
</template>
|
|
@@ -27,35 +31,41 @@ const props = defineProps({
|
|
| 27 |
|
| 28 |
const playerStore = usePlayerStore()
|
| 29 |
const coverUrl = ref('')
|
|
|
|
| 30 |
|
| 31 |
-
// 默认封面
|
| 32 |
const getDefaultCover = () => {
|
| 33 |
-
return
|
| 34 |
}
|
| 35 |
|
| 36 |
// 加载专辑封面
|
| 37 |
const loadCover = async () => {
|
| 38 |
if (!props.song) {
|
| 39 |
-
coverUrl.value =
|
|
|
|
| 40 |
return
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
| 43 |
try {
|
| 44 |
const coverUrlResult = await playerStore.getAlbumCover(props.song, props.size)
|
| 45 |
if (coverUrlResult) {
|
| 46 |
coverUrl.value = coverUrlResult
|
| 47 |
} else {
|
| 48 |
-
coverUrl.value =
|
| 49 |
}
|
| 50 |
} catch (error) {
|
| 51 |
console.error('加载封面失败:', error)
|
| 52 |
-
coverUrl.value =
|
| 53 |
}
|
| 54 |
}
|
| 55 |
|
| 56 |
// 图片加载错误处理
|
| 57 |
-
const handleImageError = () => {
|
| 58 |
-
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
// 监听歌曲变化
|
|
@@ -63,7 +73,7 @@ watch(() => props.song, (newSong) => {
|
|
| 63 |
if (newSong) {
|
| 64 |
loadCover()
|
| 65 |
} else {
|
| 66 |
-
coverUrl.value =
|
| 67 |
}
|
| 68 |
}, { immediate: true })
|
| 69 |
|
|
@@ -88,4 +98,15 @@ onMounted(() => {
|
|
| 88 |
object-fit: cover;
|
| 89 |
transition: var(--transition-normal);
|
| 90 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
</style>
|
|
|
|
| 1 |
<template>
|
| 2 |
<div class="song-cover">
|
| 3 |
<img
|
| 4 |
+
v-if="coverUrl && !imageError"
|
| 5 |
:src="coverUrl"
|
| 6 |
:alt="song.name"
|
| 7 |
class="cover-image"
|
| 8 |
@error="handleImageError"
|
| 9 |
/>
|
| 10 |
+
<div v-else class="default-cover">
|
| 11 |
+
<i class="fas fa-music"></i>
|
| 12 |
+
</div>
|
| 13 |
<slot></slot>
|
| 14 |
</div>
|
| 15 |
</template>
|
|
|
|
| 31 |
|
| 32 |
const playerStore = usePlayerStore()
|
| 33 |
const coverUrl = ref('')
|
| 34 |
+
const imageError = ref(false)
|
| 35 |
|
| 36 |
+
// 默认封面 - 返回 null,让组件使用 fas fa-music 图标
|
| 37 |
const getDefaultCover = () => {
|
| 38 |
+
return null
|
| 39 |
}
|
| 40 |
|
| 41 |
// 加载专辑封面
|
| 42 |
const loadCover = async () => {
|
| 43 |
if (!props.song) {
|
| 44 |
+
coverUrl.value = null
|
| 45 |
+
imageError.value = false
|
| 46 |
return
|
| 47 |
}
|
| 48 |
|
| 49 |
+
// 重置错误状态
|
| 50 |
+
imageError.value = false
|
| 51 |
+
|
| 52 |
try {
|
| 53 |
const coverUrlResult = await playerStore.getAlbumCover(props.song, props.size)
|
| 54 |
if (coverUrlResult) {
|
| 55 |
coverUrl.value = coverUrlResult
|
| 56 |
} else {
|
| 57 |
+
coverUrl.value = null
|
| 58 |
}
|
| 59 |
} catch (error) {
|
| 60 |
console.error('加载封面失败:', error)
|
| 61 |
+
coverUrl.value = null
|
| 62 |
}
|
| 63 |
}
|
| 64 |
|
| 65 |
// 图片加载错误处理
|
| 66 |
+
const handleImageError = (event) => {
|
| 67 |
+
imageError.value = true
|
| 68 |
+
// 不再需要 display: none,让 Vue 的条件渲染处理
|
| 69 |
}
|
| 70 |
|
| 71 |
// 监听歌曲变化
|
|
|
|
| 73 |
if (newSong) {
|
| 74 |
loadCover()
|
| 75 |
} else {
|
| 76 |
+
coverUrl.value = null
|
| 77 |
}
|
| 78 |
}, { immediate: true })
|
| 79 |
|
|
|
|
| 98 |
object-fit: cover;
|
| 99 |
transition: var(--transition-normal);
|
| 100 |
}
|
| 101 |
+
|
| 102 |
+
.default-cover {
|
| 103 |
+
width: 100%;
|
| 104 |
+
height: 100%;
|
| 105 |
+
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
| 106 |
+
display: flex;
|
| 107 |
+
align-items: center;
|
| 108 |
+
justify-content: center;
|
| 109 |
+
color: white;
|
| 110 |
+
font-size: 24px;
|
| 111 |
+
}
|
| 112 |
</style>
|
src/components/favorites/FavoriteItem.vue
CHANGED
|
@@ -10,11 +10,15 @@
|
|
| 10 |
<!-- 专辑封面 -->
|
| 11 |
<div class="album-cover">
|
| 12 |
<img
|
|
|
|
| 13 |
:src="actualCoverUrl"
|
| 14 |
:alt="song.album"
|
| 15 |
@error="handleImageError"
|
| 16 |
loading="lazy"
|
| 17 |
>
|
|
|
|
|
|
|
|
|
|
| 18 |
<div class="play-indicator" v-if="isCurrentSong">
|
| 19 |
<i :class="playIconClass"></i>
|
| 20 |
</div>
|
|
@@ -76,7 +80,7 @@
|
|
| 76 |
</template>
|
| 77 |
|
| 78 |
<script setup>
|
| 79 |
-
import { ref, computed, onMounted } from 'vue'
|
| 80 |
import { usePlayerStore } from '@/stores/player'
|
| 81 |
import { useHistoryStore } from '@/stores/history'
|
| 82 |
import FavoriteButton from './FavoriteButton.vue'
|
|
@@ -121,26 +125,52 @@ const playerStore = usePlayerStore()
|
|
| 121 |
const historyStore = useHistoryStore()
|
| 122 |
const showMenu = ref(false)
|
| 123 |
const showPlaylistSelector = ref(false)
|
|
|
|
| 124 |
|
| 125 |
// 异步获取封面URL
|
| 126 |
-
const actualCoverUrl = ref('
|
| 127 |
const loadCoverUrl = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
if (props.song.pic_id) {
|
| 129 |
try {
|
| 130 |
const { getCachedMusicPicUrlWithDelay } = await import('@/utils/musicPicCache.js')
|
| 131 |
const url = await getCachedMusicPicUrlWithDelay(props.song.source, props.song.pic_id, 300)
|
| 132 |
if (url) {
|
| 133 |
actualCoverUrl.value = url
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
} catch (error) {
|
| 136 |
console.error('加载封面失败:', error)
|
|
|
|
| 137 |
}
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
}
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
// 组件挂载时加载封面
|
| 142 |
onMounted(() => {
|
| 143 |
-
|
|
|
|
|
|
|
| 144 |
})
|
| 145 |
|
| 146 |
// 是否是当前播放歌曲
|
|
@@ -214,7 +244,8 @@ const formatPlayCount = (count) => {
|
|
| 214 |
|
| 215 |
// 处理图片加载错误
|
| 216 |
const handleImageError = (event) => {
|
| 217 |
-
|
|
|
|
| 218 |
}
|
| 219 |
|
| 220 |
// 播放歌曲
|
|
@@ -329,6 +360,17 @@ document.addEventListener('click', () => {
|
|
| 329 |
transition: var(--transition-fast);
|
| 330 |
}
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
.favorite-item:hover .album-cover img {
|
| 333 |
transform: scale(1.05);
|
| 334 |
}
|
|
|
|
| 10 |
<!-- 专辑封面 -->
|
| 11 |
<div class="album-cover">
|
| 12 |
<img
|
| 13 |
+
v-if="actualCoverUrl && actualCoverUrl !== '/default-album.png' && !imageError"
|
| 14 |
:src="actualCoverUrl"
|
| 15 |
:alt="song.album"
|
| 16 |
@error="handleImageError"
|
| 17 |
loading="lazy"
|
| 18 |
>
|
| 19 |
+
<div v-else class="default-cover-icon">
|
| 20 |
+
<i class="fas fa-music"></i>
|
| 21 |
+
</div>
|
| 22 |
<div class="play-indicator" v-if="isCurrentSong">
|
| 23 |
<i :class="playIconClass"></i>
|
| 24 |
</div>
|
|
|
|
| 80 |
</template>
|
| 81 |
|
| 82 |
<script setup>
|
| 83 |
+
import { ref, computed, onMounted, watch } from 'vue'
|
| 84 |
import { usePlayerStore } from '@/stores/player'
|
| 85 |
import { useHistoryStore } from '@/stores/history'
|
| 86 |
import FavoriteButton from './FavoriteButton.vue'
|
|
|
|
| 125 |
const historyStore = useHistoryStore()
|
| 126 |
const showMenu = ref(false)
|
| 127 |
const showPlaylistSelector = ref(false)
|
| 128 |
+
const imageError = ref(false)
|
| 129 |
|
| 130 |
// 异步获取封面URL
|
| 131 |
+
const actualCoverUrl = ref('')
|
| 132 |
const loadCoverUrl = async () => {
|
| 133 |
+
if (!props.song) {
|
| 134 |
+
actualCoverUrl.value = ''
|
| 135 |
+
imageError.value = false
|
| 136 |
+
return
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// 重置错误状态
|
| 140 |
+
imageError.value = false
|
| 141 |
+
|
| 142 |
if (props.song.pic_id) {
|
| 143 |
try {
|
| 144 |
const { getCachedMusicPicUrlWithDelay } = await import('@/utils/musicPicCache.js')
|
| 145 |
const url = await getCachedMusicPicUrlWithDelay(props.song.source, props.song.pic_id, 300)
|
| 146 |
if (url) {
|
| 147 |
actualCoverUrl.value = url
|
| 148 |
+
} else {
|
| 149 |
+
actualCoverUrl.value = ''
|
| 150 |
}
|
| 151 |
} catch (error) {
|
| 152 |
console.error('加载封面失败:', error)
|
| 153 |
+
actualCoverUrl.value = ''
|
| 154 |
}
|
| 155 |
+
} else {
|
| 156 |
+
actualCoverUrl.value = ''
|
| 157 |
}
|
| 158 |
}
|
| 159 |
|
| 160 |
+
// 监听歌曲变化
|
| 161 |
+
watch(() => props.song, (newSong) => {
|
| 162 |
+
if (newSong) {
|
| 163 |
+
loadCoverUrl()
|
| 164 |
+
} else {
|
| 165 |
+
actualCoverUrl.value = ''
|
| 166 |
+
}
|
| 167 |
+
}, { immediate: true })
|
| 168 |
+
|
| 169 |
// 组件挂载时加载封面
|
| 170 |
onMounted(() => {
|
| 171 |
+
if (props.song) {
|
| 172 |
+
loadCoverUrl()
|
| 173 |
+
}
|
| 174 |
})
|
| 175 |
|
| 176 |
// 是否是当前播放歌曲
|
|
|
|
| 244 |
|
| 245 |
// 处理图片加载错误
|
| 246 |
const handleImageError = (event) => {
|
| 247 |
+
imageError.value = true
|
| 248 |
+
// 不再需要设置 src,让 Vue 的条件渲染处理
|
| 249 |
}
|
| 250 |
|
| 251 |
// 播放歌曲
|
|
|
|
| 360 |
transition: var(--transition-fast);
|
| 361 |
}
|
| 362 |
|
| 363 |
+
.default-cover-icon {
|
| 364 |
+
width: 100%;
|
| 365 |
+
height: 100%;
|
| 366 |
+
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
| 367 |
+
display: flex;
|
| 368 |
+
align-items: center;
|
| 369 |
+
justify-content: center;
|
| 370 |
+
color: white;
|
| 371 |
+
font-size: 18px;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
.favorite-item:hover .album-cover img {
|
| 375 |
transform: scale(1.05);
|
| 376 |
}
|
src/components/player/MoreActionsPanel.vue
CHANGED
|
@@ -2,39 +2,102 @@
|
|
| 2 |
<div class="more-actions-overlay" @click="$emit('close')">
|
| 3 |
<div class="more-actions-panel" @click.stop>
|
| 4 |
<!-- 歌曲信息 -->
|
| 5 |
-
<div v-if="song" class="
|
| 6 |
<img
|
| 7 |
-
|
|
|
|
| 8 |
:alt="song.name"
|
| 9 |
-
class="
|
|
|
|
| 10 |
/>
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
<div class="
|
| 13 |
-
<div class="
|
| 14 |
-
<div class="
|
| 15 |
-
<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
</div>
|
| 17 |
</div>
|
| 18 |
|
| 19 |
<!-- 操作按钮列表 -->
|
| 20 |
<div class="actions-list">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
<button
|
|
|
|
|
|
|
| 22 |
class="action-item"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
>
|
| 26 |
-
<i :class="
|
| 27 |
-
<span>{{
|
| 28 |
-
</button>
|
| 29 |
-
|
| 30 |
-
<button class="action-item" @click="handleAction('addToPlaylist')">
|
| 31 |
-
<i class="fas fa-plus"></i>
|
| 32 |
-
<span>添加到歌单</span>
|
| 33 |
-
</button>
|
| 34 |
-
|
| 35 |
-
<button class="action-item" @click="handleAction('download')">
|
| 36 |
-
<i class="fas fa-download"></i>
|
| 37 |
-
<span>下载到本地</span>
|
| 38 |
</button>
|
| 39 |
</div>
|
| 40 |
|
|
@@ -46,6 +109,7 @@
|
|
| 46 |
|
| 47 |
<!-- 播放列表选择对话框 -->
|
| 48 |
<PlaylistSelector
|
|
|
|
| 49 |
:show="showPlaylistSelector"
|
| 50 |
:song="song"
|
| 51 |
@close="closePlaylistSelector"
|
|
@@ -57,70 +121,144 @@
|
|
| 57 |
<script setup>
|
| 58 |
import { computed, ref } from 'vue'
|
| 59 |
import { useFavoritesStore } from '@/stores/favorites'
|
|
|
|
| 60 |
import PlaylistSelector from '@/components/playlist/PlaylistSelector.vue'
|
|
|
|
| 61 |
|
| 62 |
const props = defineProps({
|
| 63 |
song: {
|
| 64 |
type: Object,
|
| 65 |
default: null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
})
|
| 68 |
|
| 69 |
const emit = defineEmits(['close', 'action'])
|
| 70 |
|
| 71 |
const favoritesStore = useFavoritesStore()
|
|
|
|
| 72 |
const showPlaylistSelector = ref(false)
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
// 计算属性
|
| 75 |
const isFavorite = computed(() => {
|
| 76 |
return props.song ? favoritesStore.isFavorite(props.song) : false
|
| 77 |
})
|
| 78 |
|
| 79 |
const defaultCover = computed(() => {
|
| 80 |
-
return 'data:image/svg+xml;base64,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
})
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
// 方法
|
| 84 |
const handleAction = async (action) => {
|
| 85 |
-
if (!props.song && action !== 'close') return
|
| 86 |
-
|
| 87 |
switch (action) {
|
|
|
|
| 88 |
case 'favorite':
|
|
|
|
| 89 |
try {
|
| 90 |
if (isFavorite.value) {
|
| 91 |
await favoritesStore.removeFromFavorites(props.song)
|
|
|
|
| 92 |
} else {
|
| 93 |
await favoritesStore.addToFavorites(props.song)
|
|
|
|
| 94 |
}
|
| 95 |
} catch (error) {
|
| 96 |
console.error('收藏操作失败:', error)
|
|
|
|
| 97 |
}
|
| 98 |
break
|
| 99 |
|
| 100 |
case 'addToPlaylist':
|
|
|
|
| 101 |
// 打开播放列表选择器
|
| 102 |
showPlaylistSelector.value = true
|
| 103 |
break
|
| 104 |
|
| 105 |
case 'download':
|
|
|
|
| 106 |
// 实现下载功能
|
| 107 |
try {
|
| 108 |
-
// 这里可以调用下载逻辑
|
| 109 |
-
const link = document.createElement('a')
|
| 110 |
if (props.song.url) {
|
|
|
|
| 111 |
link.href = props.song.url
|
| 112 |
-
link.download = `${props.song.artist} - ${props.song.name}.mp3`
|
| 113 |
document.body.appendChild(link)
|
| 114 |
link.click()
|
| 115 |
document.body.removeChild(link)
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
} catch (error) {
|
| 118 |
console.error('下载失败:', error)
|
|
|
|
| 119 |
}
|
| 120 |
break
|
| 121 |
-
}
|
| 122 |
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
}
|
| 125 |
|
| 126 |
// 关闭播放列表选择器
|
|
@@ -130,7 +268,7 @@ const closePlaylistSelector = () => {
|
|
| 130 |
|
| 131 |
// 处理添加到歌单成功
|
| 132 |
const handleAddedToPlaylist = (data) => {
|
| 133 |
-
|
| 134 |
// 关闭当前面板
|
| 135 |
emit('close')
|
| 136 |
}
|
|
@@ -160,7 +298,7 @@ const handleAddedToPlaylist = (data) => {
|
|
| 160 |
overflow: hidden;
|
| 161 |
}
|
| 162 |
|
| 163 |
-
.
|
| 164 |
display: flex;
|
| 165 |
align-items: center;
|
| 166 |
gap: 16px;
|
|
@@ -168,7 +306,7 @@ const handleAddedToPlaylist = (data) => {
|
|
| 168 |
border-bottom: 1px solid var(--border-light);
|
| 169 |
}
|
| 170 |
|
| 171 |
-
.
|
| 172 |
width: 64px;
|
| 173 |
height: 64px;
|
| 174 |
border-radius: 12px;
|
|
@@ -176,12 +314,25 @@ const handleAddedToPlaylist = (data) => {
|
|
| 176 |
flex-shrink: 0;
|
| 177 |
}
|
| 178 |
|
| 179 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
flex: 1;
|
| 181 |
min-width: 0;
|
| 182 |
}
|
| 183 |
|
| 184 |
-
.
|
| 185 |
font-size: 18px;
|
| 186 |
font-weight: 600;
|
| 187 |
color: var(--text-primary);
|
|
@@ -191,7 +342,7 @@ const handleAddedToPlaylist = (data) => {
|
|
| 191 |
text-overflow: ellipsis;
|
| 192 |
}
|
| 193 |
|
| 194 |
-
.
|
| 195 |
font-size: 14px;
|
| 196 |
color: var(--text-secondary);
|
| 197 |
margin-bottom: 4px;
|
|
@@ -200,7 +351,7 @@ const handleAddedToPlaylist = (data) => {
|
|
| 200 |
text-overflow: ellipsis;
|
| 201 |
}
|
| 202 |
|
| 203 |
-
.
|
| 204 |
font-size: 12px;
|
| 205 |
color: var(--text-tertiary);
|
| 206 |
white-space: nowrap;
|
|
@@ -208,6 +359,21 @@ const handleAddedToPlaylist = (data) => {
|
|
| 208 |
text-overflow: ellipsis;
|
| 209 |
}
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
.actions-list {
|
| 212 |
padding: 8px 0;
|
| 213 |
}
|
|
@@ -284,25 +450,26 @@ const handleAddedToPlaylist = (data) => {
|
|
| 284 |
|
| 285 |
/* 响应式 */
|
| 286 |
@media (max-width: 375px) {
|
| 287 |
-
.
|
| 288 |
padding: 20px 16px 16px;
|
| 289 |
}
|
| 290 |
|
| 291 |
-
.
|
| 292 |
width: 56px;
|
| 293 |
height: 56px;
|
| 294 |
border-radius: 10px;
|
| 295 |
}
|
| 296 |
|
| 297 |
-
.
|
| 298 |
font-size: 16px;
|
| 299 |
}
|
| 300 |
|
| 301 |
-
.
|
| 302 |
font-size: 13px;
|
| 303 |
}
|
| 304 |
|
| 305 |
-
.
|
|
|
|
| 306 |
font-size: 11px;
|
| 307 |
}
|
| 308 |
|
|
@@ -336,11 +503,11 @@ const handleAddedToPlaylist = (data) => {
|
|
| 336 |
padding: 20px;
|
| 337 |
}
|
| 338 |
|
| 339 |
-
.
|
| 340 |
padding: 28px 24px 24px;
|
| 341 |
}
|
| 342 |
|
| 343 |
-
.
|
| 344 |
width: 72px;
|
| 345 |
height: 72px;
|
| 346 |
border-radius: 14px;
|
|
|
|
| 2 |
<div class="more-actions-overlay" @click="$emit('close')">
|
| 3 |
<div class="more-actions-panel" @click.stop>
|
| 4 |
<!-- 歌曲信息 -->
|
| 5 |
+
<div v-if="type === 'song' && song" class="item-info">
|
| 6 |
<img
|
| 7 |
+
v-if="song.cover"
|
| 8 |
+
:src="song.cover"
|
| 9 |
:alt="song.name"
|
| 10 |
+
class="item-cover"
|
| 11 |
+
@error="handleSongImageError"
|
| 12 |
/>
|
| 13 |
+
<div v-else class="default-cover">
|
| 14 |
+
<i class="fas fa-music"></i>
|
| 15 |
+
</div>
|
| 16 |
|
| 17 |
+
<div class="item-details">
|
| 18 |
+
<div class="item-name">{{ song.name }}</div>
|
| 19 |
+
<div class="item-artist">{{ utils.formatArtist(song.artist) }}</div>
|
| 20 |
+
<div class="item-album" v-if="song.album">{{ song.album }}</div>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<!-- 歌单信息 -->
|
| 25 |
+
<div v-if="type === 'playlist' && playlist" class="item-info">
|
| 26 |
+
<img
|
| 27 |
+
v-if="playlist.cover"
|
| 28 |
+
:src="playlist.cover"
|
| 29 |
+
:alt="playlist.name"
|
| 30 |
+
class="item-cover"
|
| 31 |
+
@error="handlePlaylistImageError"
|
| 32 |
+
/>
|
| 33 |
+
<div v-else class="default-cover">
|
| 34 |
+
<i class="fas fa-music"></i>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<div class="item-details">
|
| 38 |
+
<div class="item-name">{{ playlist.name }}</div>
|
| 39 |
+
<div class="item-meta">{{ playlist.songs?.length || 0 }}首歌曲</div>
|
| 40 |
+
<div class="item-meta" v-if="playlist.updatedAt">{{ formatDate(playlist.updatedAt) }}</div>
|
| 41 |
</div>
|
| 42 |
</div>
|
| 43 |
|
| 44 |
<!-- 操作按钮列表 -->
|
| 45 |
<div class="actions-list">
|
| 46 |
+
<!-- 歌曲操作 -->
|
| 47 |
+
<template v-if="type === 'song'">
|
| 48 |
+
<button
|
| 49 |
+
class="action-item"
|
| 50 |
+
@click="handleAction('favorite')"
|
| 51 |
+
:class="{ active: isFavorite }"
|
| 52 |
+
>
|
| 53 |
+
<i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
|
| 54 |
+
<span>{{ isFavorite ? '取消收藏' : '添加到我的收藏' }}</span>
|
| 55 |
+
</button>
|
| 56 |
+
|
| 57 |
+
<button class="action-item" @click="handleAction('addToPlaylist')">
|
| 58 |
+
<i class="fas fa-plus"></i>
|
| 59 |
+
<span>添加到歌单</span>
|
| 60 |
+
</button>
|
| 61 |
+
|
| 62 |
+
<button class="action-item" @click="handleAction('download')">
|
| 63 |
+
<i class="fas fa-download"></i>
|
| 64 |
+
<span>下载到本地</span>
|
| 65 |
+
</button>
|
| 66 |
+
</template>
|
| 67 |
+
|
| 68 |
+
<!-- 歌单操作 -->
|
| 69 |
+
<template v-if="type === 'playlist'">
|
| 70 |
+
<button class="action-item" @click="handleAction('viewInfo')">
|
| 71 |
+
<i class="fas fa-info-circle"></i>
|
| 72 |
+
<span>查看信息</span>
|
| 73 |
+
</button>
|
| 74 |
+
|
| 75 |
+
<button class="action-item" @click="handleAction('editInfo')">
|
| 76 |
+
<i class="fas fa-edit"></i>
|
| 77 |
+
<span>编辑信息</span>
|
| 78 |
+
</button>
|
| 79 |
+
|
| 80 |
+
<button class="action-item" @click="handleAction('clearPlaylist')">
|
| 81 |
+
<i class="fas fa-trash-alt"></i>
|
| 82 |
+
<span>清空歌单</span>
|
| 83 |
+
</button>
|
| 84 |
+
|
| 85 |
+
<button class="action-item danger" @click="handleAction('deletePlaylist')">
|
| 86 |
+
<i class="fas fa-trash"></i>
|
| 87 |
+
<span>删除歌单</span>
|
| 88 |
+
</button>
|
| 89 |
+
</template>
|
| 90 |
+
|
| 91 |
+
<!-- 自定义操作 -->
|
| 92 |
<button
|
| 93 |
+
v-for="action in customActions"
|
| 94 |
+
:key="action.key"
|
| 95 |
class="action-item"
|
| 96 |
+
:class="action.class"
|
| 97 |
+
@click="handleAction(action.key)"
|
| 98 |
>
|
| 99 |
+
<i :class="action.icon"></i>
|
| 100 |
+
<span>{{ action.label }}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</button>
|
| 102 |
</div>
|
| 103 |
|
|
|
|
| 109 |
|
| 110 |
<!-- 播放列表选择对话框 -->
|
| 111 |
<PlaylistSelector
|
| 112 |
+
v-if="song"
|
| 113 |
:show="showPlaylistSelector"
|
| 114 |
:song="song"
|
| 115 |
@close="closePlaylistSelector"
|
|
|
|
| 121 |
<script setup>
|
| 122 |
import { computed, ref } from 'vue'
|
| 123 |
import { useFavoritesStore } from '@/stores/favorites'
|
| 124 |
+
import { useToastStore } from '@/stores/toast'
|
| 125 |
import PlaylistSelector from '@/components/playlist/PlaylistSelector.vue'
|
| 126 |
+
import { utils } from '@/services/musicApi'
|
| 127 |
|
| 128 |
const props = defineProps({
|
| 129 |
song: {
|
| 130 |
type: Object,
|
| 131 |
default: null
|
| 132 |
+
},
|
| 133 |
+
playlist: {
|
| 134 |
+
type: Object,
|
| 135 |
+
default: null
|
| 136 |
+
},
|
| 137 |
+
// 操作项类型:'song' | 'playlist'
|
| 138 |
+
type: {
|
| 139 |
+
type: String,
|
| 140 |
+
default: 'song',
|
| 141 |
+
validator: (value) => ['song', 'playlist'].includes(value)
|
| 142 |
+
},
|
| 143 |
+
// 自定义操作项
|
| 144 |
+
customActions: {
|
| 145 |
+
type: Array,
|
| 146 |
+
default: () => []
|
| 147 |
}
|
| 148 |
})
|
| 149 |
|
| 150 |
const emit = defineEmits(['close', 'action'])
|
| 151 |
|
| 152 |
const favoritesStore = useFavoritesStore()
|
| 153 |
+
const toastStore = useToastStore()
|
| 154 |
const showPlaylistSelector = ref(false)
|
| 155 |
|
| 156 |
+
// 图片加载错误处理
|
| 157 |
+
const handleSongImageError = (event) => {
|
| 158 |
+
event.target.style.display = 'none'
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const handlePlaylistImageError = (event) => {
|
| 162 |
+
event.target.style.display = 'none'
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
// 计算属性
|
| 166 |
const isFavorite = computed(() => {
|
| 167 |
return props.song ? favoritesStore.isFavorite(props.song) : false
|
| 168 |
})
|
| 169 |
|
| 170 |
const defaultCover = computed(() => {
|
| 171 |
+
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMDUpIiByeD0iMTIiLz4KPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzAsIDMwKSI+CjxwYXRoIGQ9Ik0xMCA1VjE2QzEwIDE3LjY1NyA4LjY1NyAxOSA3IDE5QzUuMzQzIDE5IDQgMTcuNjU3IDQgMTZDNCA0LjM0MyA1LjM0MyAxMyA3IDEzQzcuNTUyIDEzIDguMDY3IDEzLjE0NyA4LjUgMTMuNFY3LjVMMTUgM1YxMkMxNSAxMy42NTcgMTMuNjU3IDE1IDEyIDE1QzEwLjM0MyAxNSA5IDEzLjY1NyA5IDEyQzkgMTAuMzQzIDEwLjM0MyA5IDEyIDlDMTIuNTUyIDkgMTMuMDY3IDkuMTQ3IDEzLjUgOS40VjBMMTAgNVoiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC42KSIvPgo8L2c+Cjwvc3ZnPgo='
|
| 172 |
+
})
|
| 173 |
+
|
| 174 |
+
const defaultPlaylistCover = computed(() => {
|
| 175 |
+
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMDUpIiByeD0iMTIiLz4KPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzAsIDMwKSI+CjxwYXRoIGQ9Ik0xMCA1VjE2QzEwIDE3LjY1NyA4LjY1NyAxOSA3IDE5QzUuMzQzIDE5IDQgMTcuNjU3IDQgMTZDNCA0LjM0MyA1LjM0MyAxMyA3IDEzQzcuNTUyIDEzIDguMDY3IDEzLjE0NyA4LjUgMTMuNFY3LjVMMTUgM1YxMkMxNSAxMy42NTcgMTMuNjU3IDE1IDEyIDE1QzEwLjM0MyAxNSA5IDEzLjY1NyA5IDEyQzkgMTAuMzQzIDEwLjM0MyA5IDEyIDlDMTIuNTUyIDkgMTMuMDY3IDkuMTQ3IDEzLjUgOS40VjBMMTAgNVoiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC42KSIvPgo8L2c+Cjwvc3ZnPgo='
|
| 176 |
})
|
| 177 |
|
| 178 |
+
// 格式化日期
|
| 179 |
+
const formatDate = (timestamp) => {
|
| 180 |
+
if (!timestamp) return ''
|
| 181 |
+
const date = new Date(timestamp)
|
| 182 |
+
const now = new Date()
|
| 183 |
+
const diffTime = now - date
|
| 184 |
+
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
| 185 |
+
|
| 186 |
+
if (diffDays === 0) {
|
| 187 |
+
return '今天'
|
| 188 |
+
} else if (diffDays === 1) {
|
| 189 |
+
return '昨天'
|
| 190 |
+
} else if (diffDays < 7) {
|
| 191 |
+
return `${diffDays}天前`
|
| 192 |
+
} else {
|
| 193 |
+
return date.toLocaleDateString('zh-CN', {
|
| 194 |
+
month: 'short',
|
| 195 |
+
day: 'numeric'
|
| 196 |
+
})
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
// 方法
|
| 201 |
const handleAction = async (action) => {
|
|
|
|
|
|
|
| 202 |
switch (action) {
|
| 203 |
+
// 歌曲操作
|
| 204 |
case 'favorite':
|
| 205 |
+
if (!props.song) return
|
| 206 |
try {
|
| 207 |
if (isFavorite.value) {
|
| 208 |
await favoritesStore.removeFromFavorites(props.song)
|
| 209 |
+
toastStore.success('已从我喜欢的音乐中移除')
|
| 210 |
} else {
|
| 211 |
await favoritesStore.addToFavorites(props.song)
|
| 212 |
+
toastStore.success('已添加到我喜欢的音乐')
|
| 213 |
}
|
| 214 |
} catch (error) {
|
| 215 |
console.error('收藏操作失败:', error)
|
| 216 |
+
toastStore.error('操作失败,请重试')
|
| 217 |
}
|
| 218 |
break
|
| 219 |
|
| 220 |
case 'addToPlaylist':
|
| 221 |
+
if (!props.song) return
|
| 222 |
// 打开播放列表选择器
|
| 223 |
showPlaylistSelector.value = true
|
| 224 |
break
|
| 225 |
|
| 226 |
case 'download':
|
| 227 |
+
if (!props.song) return
|
| 228 |
// 实现下载功能
|
| 229 |
try {
|
|
|
|
|
|
|
| 230 |
if (props.song.url) {
|
| 231 |
+
const link = document.createElement('a')
|
| 232 |
link.href = props.song.url
|
| 233 |
+
link.download = `${utils.formatArtist(props.song.artist)} - ${props.song.name}.mp3`
|
| 234 |
document.body.appendChild(link)
|
| 235 |
link.click()
|
| 236 |
document.body.removeChild(link)
|
| 237 |
+
toastStore.success('开始下载')
|
| 238 |
+
} else {
|
| 239 |
+
toastStore.warning('该歌曲暂不支持下载')
|
| 240 |
}
|
| 241 |
} catch (error) {
|
| 242 |
console.error('下载失败:', error)
|
| 243 |
+
toastStore.error('下载失败,请重试')
|
| 244 |
}
|
| 245 |
break
|
|
|
|
| 246 |
|
| 247 |
+
// 歌单操作
|
| 248 |
+
case 'viewInfo':
|
| 249 |
+
case 'editInfo':
|
| 250 |
+
case 'clearPlaylist':
|
| 251 |
+
case 'deletePlaylist':
|
| 252 |
+
// 这些操作由父组件处理
|
| 253 |
+
emit('action', { action, playlist: props.playlist })
|
| 254 |
+
emit('close')
|
| 255 |
+
break
|
| 256 |
+
|
| 257 |
+
// 自定义操作
|
| 258 |
+
default:
|
| 259 |
+
emit('action', { action, song: props.song, playlist: props.playlist })
|
| 260 |
+
break
|
| 261 |
+
}
|
| 262 |
}
|
| 263 |
|
| 264 |
// 关闭播放列表选择器
|
|
|
|
| 268 |
|
| 269 |
// 处理添加到歌单成功
|
| 270 |
const handleAddedToPlaylist = (data) => {
|
| 271 |
+
toastStore.success(data.message)
|
| 272 |
// 关闭当前面板
|
| 273 |
emit('close')
|
| 274 |
}
|
|
|
|
| 298 |
overflow: hidden;
|
| 299 |
}
|
| 300 |
|
| 301 |
+
.item-info {
|
| 302 |
display: flex;
|
| 303 |
align-items: center;
|
| 304 |
gap: 16px;
|
|
|
|
| 306 |
border-bottom: 1px solid var(--border-light);
|
| 307 |
}
|
| 308 |
|
| 309 |
+
.item-cover {
|
| 310 |
width: 64px;
|
| 311 |
height: 64px;
|
| 312 |
border-radius: 12px;
|
|
|
|
| 314 |
flex-shrink: 0;
|
| 315 |
}
|
| 316 |
|
| 317 |
+
.default-cover {
|
| 318 |
+
width: 64px;
|
| 319 |
+
height: 64px;
|
| 320 |
+
border-radius: 12px;
|
| 321 |
+
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
| 322 |
+
display: flex;
|
| 323 |
+
align-items: center;
|
| 324 |
+
justify-content: center;
|
| 325 |
+
color: white;
|
| 326 |
+
font-size: 24px;
|
| 327 |
+
flex-shrink: 0;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.item-details {
|
| 331 |
flex: 1;
|
| 332 |
min-width: 0;
|
| 333 |
}
|
| 334 |
|
| 335 |
+
.item-name {
|
| 336 |
font-size: 18px;
|
| 337 |
font-weight: 600;
|
| 338 |
color: var(--text-primary);
|
|
|
|
| 342 |
text-overflow: ellipsis;
|
| 343 |
}
|
| 344 |
|
| 345 |
+
.item-artist {
|
| 346 |
font-size: 14px;
|
| 347 |
color: var(--text-secondary);
|
| 348 |
margin-bottom: 4px;
|
|
|
|
| 351 |
text-overflow: ellipsis;
|
| 352 |
}
|
| 353 |
|
| 354 |
+
.item-album {
|
| 355 |
font-size: 12px;
|
| 356 |
color: var(--text-tertiary);
|
| 357 |
white-space: nowrap;
|
|
|
|
| 359 |
text-overflow: ellipsis;
|
| 360 |
}
|
| 361 |
|
| 362 |
+
.item-meta {
|
| 363 |
+
font-size: 14px;
|
| 364 |
+
color: var(--text-secondary);
|
| 365 |
+
margin-bottom: 4px;
|
| 366 |
+
white-space: nowrap;
|
| 367 |
+
overflow: hidden;
|
| 368 |
+
text-overflow: ellipsis;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.item-meta:last-child {
|
| 372 |
+
margin-bottom: 0;
|
| 373 |
+
font-size: 12px;
|
| 374 |
+
color: var(--text-tertiary);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
.actions-list {
|
| 378 |
padding: 8px 0;
|
| 379 |
}
|
|
|
|
| 450 |
|
| 451 |
/* 响应式 */
|
| 452 |
@media (max-width: 375px) {
|
| 453 |
+
.item-info {
|
| 454 |
padding: 20px 16px 16px;
|
| 455 |
}
|
| 456 |
|
| 457 |
+
.item-cover {
|
| 458 |
width: 56px;
|
| 459 |
height: 56px;
|
| 460 |
border-radius: 10px;
|
| 461 |
}
|
| 462 |
|
| 463 |
+
.item-name {
|
| 464 |
font-size: 16px;
|
| 465 |
}
|
| 466 |
|
| 467 |
+
.item-artist {
|
| 468 |
font-size: 13px;
|
| 469 |
}
|
| 470 |
|
| 471 |
+
.item-album,
|
| 472 |
+
.item-meta {
|
| 473 |
font-size: 11px;
|
| 474 |
}
|
| 475 |
|
|
|
|
| 503 |
padding: 20px;
|
| 504 |
}
|
| 505 |
|
| 506 |
+
.item-info {
|
| 507 |
padding: 28px 24px 24px;
|
| 508 |
}
|
| 509 |
|
| 510 |
+
.item-cover {
|
| 511 |
width: 72px;
|
| 512 |
height: 72px;
|
| 513 |
border-radius: 14px;
|
src/components/playlist/PlaylistSelector.vue
CHANGED
|
@@ -14,7 +14,10 @@
|
|
| 14 |
v-for="playlist in playlists"
|
| 15 |
:key="playlist.id"
|
| 16 |
class="playlist-item"
|
| 17 |
-
:class="{
|
|
|
|
|
|
|
|
|
|
| 18 |
@click="selectPlaylist(playlist)"
|
| 19 |
>
|
| 20 |
<div class="playlist-cover">
|
|
@@ -31,49 +34,32 @@
|
|
| 31 |
<div class="playlist-info">
|
| 32 |
<h4 class="playlist-name">{{ playlist.name }}</h4>
|
| 33 |
<p class="playlist-count">{{ playlist.songs.length }}首歌曲</p>
|
| 34 |
-
<p v-if="playlist.isDefault" class="playlist-note">当前播放列表</p>
|
| 35 |
</div>
|
| 36 |
|
| 37 |
-
<div class="playlist-status"
|
| 38 |
-
<i class="fas fa-
|
|
|
|
| 39 |
</div>
|
| 40 |
</div>
|
| 41 |
</div>
|
| 42 |
-
|
| 43 |
-
<div class="create-new">
|
| 44 |
-
<button class="create-new-btn" @click="openCreatePlaylist">
|
| 45 |
-
<i class="fas fa-plus"></i>
|
| 46 |
-
<span>创建新播放列表</span>
|
| 47 |
-
</button>
|
| 48 |
-
</div>
|
| 49 |
</div>
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
<
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
ref="nameInput"
|
| 61 |
-
/>
|
| 62 |
-
</div>
|
| 63 |
-
|
| 64 |
-
<div class="form-actions">
|
| 65 |
-
<button class="btn btn-cancel" @click="cancelCreate">取消</button>
|
| 66 |
-
<button class="btn btn-create" @click="createAndAdd" :disabled="!newPlaylistName.trim()">
|
| 67 |
-
创建并添加
|
| 68 |
-
</button>
|
| 69 |
-
</div>
|
| 70 |
</div>
|
| 71 |
</div>
|
| 72 |
</div>
|
| 73 |
</template>
|
| 74 |
|
| 75 |
<script setup>
|
| 76 |
-
import { ref, computed
|
| 77 |
import { usePlaylistStore } from '@/stores/playlist'
|
| 78 |
import { useToastStore } from '@/stores/toast'
|
| 79 |
|
|
@@ -92,84 +78,60 @@ const emit = defineEmits(['close', 'added'])
|
|
| 92 |
|
| 93 |
const playlistStore = usePlaylistStore()
|
| 94 |
const toastStore = useToastStore()
|
| 95 |
-
const
|
| 96 |
-
const newPlaylistName = ref('')
|
| 97 |
-
const nameInput = ref(null)
|
| 98 |
|
| 99 |
-
//
|
| 100 |
const playlists = computed(() => {
|
| 101 |
-
return playlistStore.playlists
|
| 102 |
})
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
// 选择播放列表
|
| 105 |
-
const selectPlaylist =
|
| 106 |
-
if (playlist
|
| 107 |
-
//
|
| 108 |
-
return
|
| 109 |
}
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
emit('added', { playlist, song: props.song, message: result.message })
|
| 116 |
-
handleClose()
|
| 117 |
-
} else {
|
| 118 |
-
toastStore.error(result.message)
|
| 119 |
-
}
|
| 120 |
-
} catch (error) {
|
| 121 |
-
console.error('添加到播放列表失败:', error)
|
| 122 |
-
toastStore.error('添加失败,请重试')
|
| 123 |
}
|
| 124 |
}
|
| 125 |
|
| 126 |
-
//
|
| 127 |
-
const
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
nameInput.value.focus()
|
| 132 |
-
}
|
| 133 |
-
})
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
// 取消创建
|
| 137 |
-
const cancelCreate = () => {
|
| 138 |
-
showCreateForm.value = false
|
| 139 |
-
newPlaylistName.value = ''
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
// 创建播放列表并添加歌曲
|
| 143 |
-
const createAndAdd = async () => {
|
| 144 |
-
if (!newPlaylistName.value.trim()) return
|
| 145 |
|
| 146 |
try {
|
| 147 |
-
|
| 148 |
-
const newPlaylist = playlistStore.createPlaylist(newPlaylistName.value.trim())
|
| 149 |
-
|
| 150 |
-
// 添加歌曲到新播放列表
|
| 151 |
-
const result = playlistStore.addSongToPlaylist(newPlaylist.id, props.song)
|
| 152 |
|
| 153 |
if (result.success) {
|
| 154 |
-
emit('added', {
|
| 155 |
-
playlist:
|
| 156 |
-
song: props.song,
|
| 157 |
-
message:
|
| 158 |
})
|
| 159 |
handleClose()
|
| 160 |
} else {
|
| 161 |
toastStore.error(result.message)
|
| 162 |
}
|
| 163 |
} catch (error) {
|
| 164 |
-
console.error('
|
| 165 |
-
toastStore.error('
|
| 166 |
}
|
| 167 |
}
|
| 168 |
|
| 169 |
// 关闭对话框
|
| 170 |
const handleClose = () => {
|
| 171 |
-
|
| 172 |
-
newPlaylistName.value = ''
|
| 173 |
emit('close')
|
| 174 |
}
|
| 175 |
</script>
|
|
@@ -263,11 +225,25 @@ const handleClose = () => {
|
|
| 263 |
border-color: var(--accent-red);
|
| 264 |
}
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
.playlist-item.disabled {
|
| 267 |
-
opacity: 0.
|
| 268 |
cursor: not-allowed;
|
| 269 |
}
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
.playlist-cover {
|
| 272 |
width: 48px;
|
| 273 |
height: 48px;
|
|
@@ -311,92 +287,45 @@ const handleClose = () => {
|
|
| 311 |
.playlist-count {
|
| 312 |
font-size: 12px;
|
| 313 |
color: var(--text-secondary);
|
| 314 |
-
margin: 0 0 2px;
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
.playlist-note {
|
| 318 |
-
font-size: 11px;
|
| 319 |
-
color: var(--text-tertiary);
|
| 320 |
margin: 0;
|
| 321 |
}
|
| 322 |
|
| 323 |
.playlist-status {
|
| 324 |
color: var(--text-tertiary);
|
| 325 |
-
font-size:
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
.create-new {
|
| 329 |
-
border-top: 1px solid var(--border-lighter);
|
| 330 |
-
padding-top: 16px;
|
| 331 |
}
|
| 332 |
|
| 333 |
-
.
|
| 334 |
-
|
| 335 |
-
align-items: center;
|
| 336 |
-
gap: 8px;
|
| 337 |
-
width: 100%;
|
| 338 |
-
padding: 12px 16px;
|
| 339 |
-
border: 2px dashed var(--border-light);
|
| 340 |
-
background: transparent;
|
| 341 |
-
color: var(--text-secondary);
|
| 342 |
-
border-radius: 8px;
|
| 343 |
-
font-size: 14px;
|
| 344 |
-
cursor: pointer;
|
| 345 |
-
transition: var(--transition-fast);
|
| 346 |
}
|
| 347 |
|
| 348 |
-
.
|
| 349 |
-
border-color: var(--accent-red);
|
| 350 |
color: var(--accent-red);
|
| 351 |
-
background: var(--overlay-lighter);
|
| 352 |
}
|
| 353 |
|
| 354 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
padding: 20px 24px;
|
| 356 |
border-top: 1px solid var(--border-lighter);
|
| 357 |
-
background: rgba(255, 255, 255, 0.02);
|
| 358 |
-
}
|
| 359 |
-
|
| 360 |
-
.form-group {
|
| 361 |
-
margin-bottom: 16px;
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
.form-group label {
|
| 365 |
-
display: block;
|
| 366 |
-
font-size: 14px;
|
| 367 |
-
font-weight: 500;
|
| 368 |
-
color: var(--text-primary);
|
| 369 |
-
margin-bottom: 8px;
|
| 370 |
}
|
| 371 |
|
| 372 |
-
.
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
border: 2px solid var(--border-card);
|
| 376 |
-
border-radius: 8px;
|
| 377 |
-
background: var(--overlay-lighter);
|
| 378 |
-
color: var(--text-primary);
|
| 379 |
-
font-size: 14px;
|
| 380 |
-
transition: var(--transition-fast);
|
| 381 |
-
font-family: inherit;
|
| 382 |
-
box-sizing: border-box;
|
| 383 |
}
|
| 384 |
|
| 385 |
-
.
|
| 386 |
-
|
| 387 |
-
border-color: var(--accent-red);
|
| 388 |
-
background: rgba(255, 255, 255, 0.08);
|
| 389 |
}
|
| 390 |
|
| 391 |
-
.
|
|
|
|
| 392 |
color: var(--text-tertiary);
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
.form-actions {
|
| 396 |
-
display: flex;
|
| 397 |
-
align-items: center;
|
| 398 |
-
justify-content: flex-end;
|
| 399 |
-
gap: 12px;
|
| 400 |
}
|
| 401 |
|
| 402 |
.btn {
|
|
|
|
| 14 |
v-for="playlist in playlists"
|
| 15 |
:key="playlist.id"
|
| 16 |
class="playlist-item"
|
| 17 |
+
:class="{
|
| 18 |
+
'disabled': isAlreadyInPlaylist(playlist),
|
| 19 |
+
'selected': selectedPlaylist?.id === playlist.id
|
| 20 |
+
}"
|
| 21 |
@click="selectPlaylist(playlist)"
|
| 22 |
>
|
| 23 |
<div class="playlist-cover">
|
|
|
|
| 34 |
<div class="playlist-info">
|
| 35 |
<h4 class="playlist-name">{{ playlist.name }}</h4>
|
| 36 |
<p class="playlist-count">{{ playlist.songs.length }}首歌曲</p>
|
|
|
|
| 37 |
</div>
|
| 38 |
|
| 39 |
+
<div class="playlist-status">
|
| 40 |
+
<i v-if="isAlreadyInPlaylist(playlist)" class="fas fa-check" title="已添加"></i>
|
| 41 |
+
<i v-else-if="selectedPlaylist?.id === playlist.id" class="fas fa-plus-circle"></i>
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
</div>
|
| 46 |
|
| 47 |
+
<div class="dialog-footer">
|
| 48 |
+
<button class="btn btn-cancel" @click="handleClose">取消</button>
|
| 49 |
+
<button
|
| 50 |
+
class="btn btn-confirm"
|
| 51 |
+
@click="confirmAdd"
|
| 52 |
+
:disabled="!selectedPlaylist || isAlreadyInPlaylist(selectedPlaylist)"
|
| 53 |
+
>
|
| 54 |
+
确定
|
| 55 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
</div>
|
| 59 |
</template>
|
| 60 |
|
| 61 |
<script setup>
|
| 62 |
+
import { ref, computed } from 'vue'
|
| 63 |
import { usePlaylistStore } from '@/stores/playlist'
|
| 64 |
import { useToastStore } from '@/stores/toast'
|
| 65 |
|
|
|
|
| 78 |
|
| 79 |
const playlistStore = usePlaylistStore()
|
| 80 |
const toastStore = useToastStore()
|
| 81 |
+
const selectedPlaylist = ref(null)
|
|
|
|
|
|
|
| 82 |
|
| 83 |
+
// 获取所有播放列表
|
| 84 |
const playlists = computed(() => {
|
| 85 |
+
return playlistStore.playlists || []
|
| 86 |
})
|
| 87 |
|
| 88 |
+
// 检查歌曲是否已在某个歌单中
|
| 89 |
+
const isAlreadyInPlaylist = (playlist) => {
|
| 90 |
+
if (!props.song || !playlist.songs) return false
|
| 91 |
+
return playlist.songs.some(song => song.id === props.song.id)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
// 选择播放列表
|
| 95 |
+
const selectPlaylist = (playlist) => {
|
| 96 |
+
if (isAlreadyInPlaylist(playlist)) {
|
| 97 |
+
return // 已添加的歌单不能选择
|
|
|
|
| 98 |
}
|
| 99 |
+
|
| 100 |
+
if (selectedPlaylist.value?.id === playlist.id) {
|
| 101 |
+
selectedPlaylist.value = null // 取消选择
|
| 102 |
+
} else {
|
| 103 |
+
selectedPlaylist.value = playlist // 选择新的歌单
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
}
|
| 106 |
|
| 107 |
+
// 确认添加
|
| 108 |
+
const confirmAdd = async () => {
|
| 109 |
+
if (!selectedPlaylist.value || isAlreadyInPlaylist(selectedPlaylist.value)) {
|
| 110 |
+
return
|
| 111 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
try {
|
| 114 |
+
const result = playlistStore.addSongToPlaylist(selectedPlaylist.value.id, props.song)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
if (result.success) {
|
| 117 |
+
emit('added', {
|
| 118 |
+
playlist: selectedPlaylist.value,
|
| 119 |
+
song: props.song,
|
| 120 |
+
message: result.message
|
| 121 |
})
|
| 122 |
handleClose()
|
| 123 |
} else {
|
| 124 |
toastStore.error(result.message)
|
| 125 |
}
|
| 126 |
} catch (error) {
|
| 127 |
+
console.error('添加到播放列表失败:', error)
|
| 128 |
+
toastStore.error('添加失败,请重试')
|
| 129 |
}
|
| 130 |
}
|
| 131 |
|
| 132 |
// 关闭对话框
|
| 133 |
const handleClose = () => {
|
| 134 |
+
selectedPlaylist.value = null
|
|
|
|
| 135 |
emit('close')
|
| 136 |
}
|
| 137 |
</script>
|
|
|
|
| 225 |
border-color: var(--accent-red);
|
| 226 |
}
|
| 227 |
|
| 228 |
+
.playlist-item.selected {
|
| 229 |
+
background: var(--overlay-lighter);
|
| 230 |
+
border-color: var(--accent-red);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.playlist-item.selected .playlist-name {
|
| 234 |
+
color: var(--accent-red);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
.playlist-item.disabled {
|
| 238 |
+
opacity: 0.6;
|
| 239 |
cursor: not-allowed;
|
| 240 |
}
|
| 241 |
|
| 242 |
+
.playlist-item.disabled:hover {
|
| 243 |
+
background: transparent;
|
| 244 |
+
border-color: transparent;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
.playlist-cover {
|
| 248 |
width: 48px;
|
| 249 |
height: 48px;
|
|
|
|
| 287 |
.playlist-count {
|
| 288 |
font-size: 12px;
|
| 289 |
color: var(--text-secondary);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
margin: 0;
|
| 291 |
}
|
| 292 |
|
| 293 |
.playlist-status {
|
| 294 |
color: var(--text-tertiary);
|
| 295 |
+
font-size: 18px;
|
| 296 |
+
flex-shrink: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
}
|
| 298 |
|
| 299 |
+
.playlist-status .fa-check {
|
| 300 |
+
color: var(--accent-green, #28a745);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
}
|
| 302 |
|
| 303 |
+
.playlist-status .fa-plus-circle {
|
|
|
|
| 304 |
color: var(--accent-red);
|
|
|
|
| 305 |
}
|
| 306 |
|
| 307 |
+
.dialog-footer {
|
| 308 |
+
display: flex;
|
| 309 |
+
align-items: center;
|
| 310 |
+
justify-content: flex-end;
|
| 311 |
+
gap: 12px;
|
| 312 |
padding: 20px 24px;
|
| 313 |
border-top: 1px solid var(--border-lighter);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
}
|
| 315 |
|
| 316 |
+
.btn-confirm {
|
| 317 |
+
background: var(--accent-red);
|
| 318 |
+
color: white;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
}
|
| 320 |
|
| 321 |
+
.btn-confirm:hover:not(:disabled) {
|
| 322 |
+
background: var(--accent-red-hover);
|
|
|
|
|
|
|
| 323 |
}
|
| 324 |
|
| 325 |
+
.btn-confirm:disabled {
|
| 326 |
+
background: rgba(255, 255, 255, 0.1);
|
| 327 |
color: var(--text-tertiary);
|
| 328 |
+
cursor: not-allowed;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
}
|
| 330 |
|
| 331 |
.btn {
|
src/components/search/SearchResults.vue
CHANGED
|
@@ -85,7 +85,7 @@
|
|
| 85 |
</template>
|
| 86 |
|
| 87 |
<script setup>
|
| 88 |
-
import { ref, computed, onMounted, onUnmounted, watch
|
| 89 |
import { useSearchStore } from '@/stores/search'
|
| 90 |
import { usePlayerStore } from '@/stores/player'
|
| 91 |
import { usePlayQueueStore } from '@/stores/playqueue'
|
|
@@ -104,7 +104,7 @@ const props = defineProps({
|
|
| 104 |
}
|
| 105 |
})
|
| 106 |
|
| 107 |
-
const emit = defineEmits(['retry', 'search', 'play', 'loadMore'])
|
| 108 |
|
| 109 |
const searchStore = useSearchStore()
|
| 110 |
const playerStore = usePlayerStore()
|
|
@@ -158,41 +158,7 @@ const handlePlay = async (song, index) => {
|
|
| 158 |
|
| 159 |
// 更多操作处理
|
| 160 |
const handleShowMoreActions = (song, event) => {
|
| 161 |
-
|
| 162 |
-
const MoreActionsPanel = defineAsyncComponent(() => import('@/components/player/MoreActionsPanel.vue'))
|
| 163 |
-
|
| 164 |
-
// 创建容器元素
|
| 165 |
-
const container = document.createElement('div')
|
| 166 |
-
document.body.appendChild(container)
|
| 167 |
-
|
| 168 |
-
// 创建Vue应用实例
|
| 169 |
-
const app = createApp({
|
| 170 |
-
template: `
|
| 171 |
-
<MoreActionsPanel
|
| 172 |
-
:song="song"
|
| 173 |
-
@close="handleClose"
|
| 174 |
-
@action="handleAction"
|
| 175 |
-
/>
|
| 176 |
-
`,
|
| 177 |
-
components: { MoreActionsPanel },
|
| 178 |
-
setup() {
|
| 179 |
-
const handleClose = () => {
|
| 180 |
-
app.unmount()
|
| 181 |
-
document.body.removeChild(container)
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
const handleAction = (action) => {
|
| 185 |
-
console.log('执行操作:', action, song)
|
| 186 |
-
// 可以在这里处理具体的操作
|
| 187 |
-
handleClose()
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
return { song, handleClose, handleAction }
|
| 191 |
-
}
|
| 192 |
-
})
|
| 193 |
-
|
| 194 |
-
// 挂载应用
|
| 195 |
-
app.mount(container)
|
| 196 |
}
|
| 197 |
|
| 198 |
// 设置智能滚动加载 - 可视区域超过一半时加载下一页
|
|
|
|
| 85 |
</template>
|
| 86 |
|
| 87 |
<script setup>
|
| 88 |
+
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
| 89 |
import { useSearchStore } from '@/stores/search'
|
| 90 |
import { usePlayerStore } from '@/stores/player'
|
| 91 |
import { usePlayQueueStore } from '@/stores/playqueue'
|
|
|
|
| 104 |
}
|
| 105 |
})
|
| 106 |
|
| 107 |
+
const emit = defineEmits(['retry', 'search', 'play', 'loadMore', 'showMoreActions'])
|
| 108 |
|
| 109 |
const searchStore = useSearchStore()
|
| 110 |
const playerStore = usePlayerStore()
|
|
|
|
| 158 |
|
| 159 |
// 更多操作处理
|
| 160 |
const handleShowMoreActions = (song, event) => {
|
| 161 |
+
emit('showMoreActions', song, event)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
// 设置智能滚动加载 - 可视区域超过一半时加载下一页
|
src/components/search/SongItem.vue
CHANGED
|
@@ -23,7 +23,12 @@
|
|
| 23 |
:alt="song.name"
|
| 24 |
:data-song-data="JSON.stringify(song)"
|
| 25 |
@error="handleImageError"
|
|
|
|
| 26 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
<div class="play-overlay">
|
| 28 |
<i class="fas fa-play"></i>
|
| 29 |
</div>
|
|
@@ -81,7 +86,7 @@
|
|
| 81 |
</template>
|
| 82 |
|
| 83 |
<script setup>
|
| 84 |
-
import { computed, onMounted } from 'vue'
|
| 85 |
import { useFavoritesStore } from '@/stores/favorites'
|
| 86 |
import { useToastStore } from '@/stores/toast'
|
| 87 |
import { usePlayerStore } from '@/stores/player'
|
|
@@ -131,6 +136,9 @@ const toastStore = useToastStore()
|
|
| 131 |
const playerStore = usePlayerStore()
|
| 132 |
const { getDefaultCover, handleImageError, observeImage } = useSongCoverLoader()
|
| 133 |
|
|
|
|
|
|
|
|
|
|
| 134 |
// 计算属性
|
| 135 |
const isFavorite = computed(() => {
|
| 136 |
return favoritesStore.isFavorite(props.song)
|
|
@@ -150,6 +158,14 @@ const getSongCover = () => {
|
|
| 150 |
return getDefaultCover()
|
| 151 |
}
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
// 初始化懒加载观察
|
| 154 |
const initLazyImages = () => {
|
| 155 |
const imageElements = document.querySelectorAll('.favorite-item .song-cover img')
|
|
@@ -290,6 +306,26 @@ onMounted(() => {
|
|
| 290 |
object-fit: cover;
|
| 291 |
}
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
.play-overlay {
|
| 294 |
position: absolute;
|
| 295 |
top: 0;
|
|
|
|
| 23 |
:alt="song.name"
|
| 24 |
:data-song-data="JSON.stringify(song)"
|
| 25 |
@error="handleImageError"
|
| 26 |
+
@load="handleImageLoad"
|
| 27 |
/>
|
| 28 |
+
<!-- 音乐图标叠加层 -->
|
| 29 |
+
<div v-if="!imageLoaded" class="music-icon-overlay">
|
| 30 |
+
<i class="fas fa-music"></i>
|
| 31 |
+
</div>
|
| 32 |
<div class="play-overlay">
|
| 33 |
<i class="fas fa-play"></i>
|
| 34 |
</div>
|
|
|
|
| 86 |
</template>
|
| 87 |
|
| 88 |
<script setup>
|
| 89 |
+
import { computed, onMounted, ref } from 'vue'
|
| 90 |
import { useFavoritesStore } from '@/stores/favorites'
|
| 91 |
import { useToastStore } from '@/stores/toast'
|
| 92 |
import { usePlayerStore } from '@/stores/player'
|
|
|
|
| 136 |
const playerStore = usePlayerStore()
|
| 137 |
const { getDefaultCover, handleImageError, observeImage } = useSongCoverLoader()
|
| 138 |
|
| 139 |
+
// 图片加载状态
|
| 140 |
+
const imageLoaded = ref(false)
|
| 141 |
+
|
| 142 |
// 计算属性
|
| 143 |
const isFavorite = computed(() => {
|
| 144 |
return favoritesStore.isFavorite(props.song)
|
|
|
|
| 158 |
return getDefaultCover()
|
| 159 |
}
|
| 160 |
|
| 161 |
+
// 处理图片加载完成
|
| 162 |
+
const handleImageLoad = (event) => {
|
| 163 |
+
// 只有当图片src不是默认封面时才设置为已加载
|
| 164 |
+
if (event.target.src && event.target.src !== getDefaultCover()) {
|
| 165 |
+
imageLoaded.value = true
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
// 初始化懒加载观察
|
| 170 |
const initLazyImages = () => {
|
| 171 |
const imageElements = document.querySelectorAll('.favorite-item .song-cover img')
|
|
|
|
| 306 |
object-fit: cover;
|
| 307 |
}
|
| 308 |
|
| 309 |
+
/* 音乐图标叠加层 */
|
| 310 |
+
.music-icon-overlay {
|
| 311 |
+
position: absolute;
|
| 312 |
+
top: 0;
|
| 313 |
+
left: 0;
|
| 314 |
+
right: 0;
|
| 315 |
+
bottom: 0;
|
| 316 |
+
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
| 317 |
+
display: flex;
|
| 318 |
+
align-items: center;
|
| 319 |
+
justify-content: center;
|
| 320 |
+
border-radius: var(--radius-small);
|
| 321 |
+
transition: opacity 0.3s ease;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.music-icon-overlay i {
|
| 325 |
+
color: white;
|
| 326 |
+
font-size: 20px;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
.play-overlay {
|
| 330 |
position: absolute;
|
| 331 |
top: 0;
|
src/composables/useSongCoverLoader.js
CHANGED
|
@@ -40,16 +40,20 @@ export const useSongCoverLoader = () => {
|
|
| 40 |
|
| 41 |
/**
|
| 42 |
* 获取默认封面
|
|
|
|
| 43 |
*/
|
| 44 |
const getDefaultCover = () => {
|
| 45 |
-
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
/**
|
| 49 |
* 处理图片加载错误
|
| 50 |
*/
|
| 51 |
const handleImageError = (event) => {
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
/**
|
| 55 |
* 初始化懒加载观察器
|
|
|
|
| 40 |
|
| 41 |
/**
|
| 42 |
* 获取默认封面
|
| 43 |
+
* 返回一个1x1透明像素的data URL,避免显示浏览器默认的破损图片图标
|
| 44 |
*/
|
| 45 |
const getDefaultCover = () => {
|
| 46 |
+
// 返回1x1透明像素的data URL
|
| 47 |
+
return 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
| 48 |
}
|
| 49 |
|
| 50 |
/**
|
| 51 |
* 处理图片加载错误
|
| 52 |
*/
|
| 53 |
const handleImageError = (event) => {
|
| 54 |
+
// 不设置 src,也不隐藏图片,让父组件的错误处理机制接管
|
| 55 |
+
// 可以触发自定义事件让组件知道加载失败
|
| 56 |
+
event.target.dispatchEvent(new CustomEvent('cover-load-failed'))
|
| 57 |
}
|
| 58 |
/**
|
| 59 |
* 初始化懒加载观察器
|
src/stores/playlist.js
CHANGED
|
@@ -23,9 +23,17 @@ export const usePlaylistStore = defineStore('playlist', () => {
|
|
| 23 |
|
| 24 |
// 歌单管理方法
|
| 25 |
const createPlaylist = (name, description = '') => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
const playlist = {
|
| 27 |
id: `playlist_${Date.now()}`,
|
| 28 |
-
name:
|
| 29 |
description: description.trim(),
|
| 30 |
songs: [],
|
| 31 |
createdAt: Date.now(),
|
|
@@ -58,6 +66,15 @@ export const usePlaylistStore = defineStore('playlist', () => {
|
|
| 58 |
const playlist = playlists.value.find(p => p.id === playlistId)
|
| 59 |
if (!playlist) return false
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
Object.assign(playlist, {
|
| 62 |
...updates,
|
| 63 |
updatedAt: Date.now()
|
|
@@ -154,6 +171,38 @@ export const usePlaylistStore = defineStore('playlist', () => {
|
|
| 154 |
return true
|
| 155 |
}
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
const isSongInPlaylist = (playlistId, song) => {
|
| 158 |
const playlist = getPlaylist(playlistId)
|
| 159 |
if (!playlist) return false
|
|
@@ -218,6 +267,18 @@ export const usePlaylistStore = defineStore('playlist', () => {
|
|
| 218 |
}
|
| 219 |
}
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
// 清理方法
|
| 222 |
const clearAllPlaylists = () => {
|
| 223 |
playlists.value = []
|
|
@@ -242,10 +303,12 @@ export const usePlaylistStore = defineStore('playlist', () => {
|
|
| 242 |
deletePlaylist,
|
| 243 |
updatePlaylist,
|
| 244 |
getPlaylist,
|
|
|
|
| 245 |
|
| 246 |
// 歌曲管理
|
| 247 |
addSongToPlaylist,
|
| 248 |
removeSongFromPlaylist,
|
|
|
|
| 249 |
isSongInPlaylist,
|
| 250 |
addSongsToPlaylist,
|
| 251 |
|
|
|
|
| 23 |
|
| 24 |
// 歌单管理方法
|
| 25 |
const createPlaylist = (name, description = '') => {
|
| 26 |
+
const trimmedName = name.trim()
|
| 27 |
+
|
| 28 |
+
// 检查歌单名称是否重复
|
| 29 |
+
const exists = playlists.value.some(p => p.name === trimmedName)
|
| 30 |
+
if (exists) {
|
| 31 |
+
throw new Error('歌单名称已存在,请使用其他名称')
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
const playlist = {
|
| 35 |
id: `playlist_${Date.now()}`,
|
| 36 |
+
name: trimmedName,
|
| 37 |
description: description.trim(),
|
| 38 |
songs: [],
|
| 39 |
createdAt: Date.now(),
|
|
|
|
| 66 |
const playlist = playlists.value.find(p => p.id === playlistId)
|
| 67 |
if (!playlist) return false
|
| 68 |
|
| 69 |
+
// 如果更新名称,检查是否与其他歌单重复
|
| 70 |
+
if (updates.name) {
|
| 71 |
+
const trimmedName = updates.name.trim()
|
| 72 |
+
const exists = playlists.value.some(p => p.id !== playlistId && p.name === trimmedName)
|
| 73 |
+
if (exists) {
|
| 74 |
+
throw new Error('歌单名称已存在,请使用其他名称')
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
Object.assign(playlist, {
|
| 79 |
...updates,
|
| 80 |
updatedAt: Date.now()
|
|
|
|
| 171 |
return true
|
| 172 |
}
|
| 173 |
|
| 174 |
+
const removeSongFromPlaylistByIndex = async (playlistId, index) => {
|
| 175 |
+
const playlist = getPlaylist(playlistId)
|
| 176 |
+
if (!playlist || index < 0 || index >= playlist.songs.length) return false
|
| 177 |
+
|
| 178 |
+
const removedSong = playlist.songs[index]
|
| 179 |
+
playlist.songs.splice(index, 1)
|
| 180 |
+
playlist.updatedAt = Date.now()
|
| 181 |
+
|
| 182 |
+
// 如果删除的是第一首歌且还有其他歌曲,更新封面
|
| 183 |
+
if (index === 0 && playlist.songs.length > 0) {
|
| 184 |
+
const firstSong = playlist.songs[0]
|
| 185 |
+
if (firstSong.pic_id) {
|
| 186 |
+
// 使用缓存机制获取封面URL
|
| 187 |
+
try {
|
| 188 |
+
const { getCachedMusicPicUrl } = await import('@/utils/musicPicCache.js')
|
| 189 |
+
const coverUrl = await getCachedMusicPicUrl(firstSong.source, firstSong.pic_id, 300)
|
| 190 |
+
playlist.cover = coverUrl || null
|
| 191 |
+
} catch (error) {
|
| 192 |
+
console.error('更新歌单封面失败:', error)
|
| 193 |
+
playlist.cover = null
|
| 194 |
+
}
|
| 195 |
+
} else {
|
| 196 |
+
playlist.cover = null
|
| 197 |
+
}
|
| 198 |
+
} else if (playlist.songs.length === 0) {
|
| 199 |
+
playlist.cover = null
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
saveToStorage()
|
| 203 |
+
return true
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
const isSongInPlaylist = (playlistId, song) => {
|
| 207 |
const playlist = getPlaylist(playlistId)
|
| 208 |
if (!playlist) return false
|
|
|
|
| 267 |
}
|
| 268 |
}
|
| 269 |
|
| 270 |
+
const clearPlaylist = (playlistId) => {
|
| 271 |
+
const playlist = getPlaylist(playlistId)
|
| 272 |
+
if (!playlist) return false
|
| 273 |
+
|
| 274 |
+
playlist.songs = []
|
| 275 |
+
playlist.cover = null
|
| 276 |
+
playlist.updatedAt = Date.now()
|
| 277 |
+
|
| 278 |
+
saveToStorage()
|
| 279 |
+
return true
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
// 清理方法
|
| 283 |
const clearAllPlaylists = () => {
|
| 284 |
playlists.value = []
|
|
|
|
| 303 |
deletePlaylist,
|
| 304 |
updatePlaylist,
|
| 305 |
getPlaylist,
|
| 306 |
+
clearPlaylist,
|
| 307 |
|
| 308 |
// 歌曲管理
|
| 309 |
addSongToPlaylist,
|
| 310 |
removeSongFromPlaylist,
|
| 311 |
+
removeSongFromPlaylistByIndex,
|
| 312 |
isSongInPlaylist,
|
| 313 |
addSongsToPlaylist,
|
| 314 |
|
src/utils/imageCache.js
CHANGED
|
@@ -147,9 +147,9 @@ class ImageCacheManager {
|
|
| 147 |
})
|
| 148 |
}
|
| 149 |
|
| 150 |
-
// 获取默认图片
|
| 151 |
getDefaultImage() {
|
| 152 |
-
return
|
| 153 |
}
|
| 154 |
|
| 155 |
// 获取缓存的图片
|
|
|
|
| 147 |
})
|
| 148 |
}
|
| 149 |
|
| 150 |
+
// 获取默认图片 - 返回 null,让组件使用 fas fa-music 图标
|
| 151 |
getDefaultImage() {
|
| 152 |
+
return null
|
| 153 |
}
|
| 154 |
|
| 155 |
// 获取缓存的图片
|
src/views/CurrentPlaylistPage.vue
CHANGED
|
@@ -443,11 +443,7 @@ const startDrag = (index) => {
|
|
| 443 |
.play-controls {
|
| 444 |
display: flex;
|
| 445 |
gap: 12px;
|
| 446 |
-
|
| 447 |
-
margin: 0 16px 16px;
|
| 448 |
-
background: var(--bg-card);
|
| 449 |
-
border-radius: var(--radius-small);
|
| 450 |
-
border: 1px solid var(--border-light);
|
| 451 |
}
|
| 452 |
|
| 453 |
.control-btn {
|
|
@@ -456,7 +452,6 @@ const startDrag = (index) => {
|
|
| 456 |
gap: 6px;
|
| 457 |
padding: 10px 16px;
|
| 458 |
border: none;
|
| 459 |
-
border-radius: 20px;
|
| 460 |
font-size: 14px;
|
| 461 |
cursor: pointer;
|
| 462 |
transition: var(--transition-fast);
|
|
@@ -535,9 +530,7 @@ const startDrag = (index) => {
|
|
| 535 |
|
| 536 |
.songs-list {
|
| 537 |
background: var(--bg-card);
|
| 538 |
-
margin: 0
|
| 539 |
-
border-radius: var(--radius-small);
|
| 540 |
-
border: 1px solid var(--border-light);
|
| 541 |
overflow: hidden;
|
| 542 |
}
|
| 543 |
|
|
@@ -716,7 +709,7 @@ const startDrag = (index) => {
|
|
| 716 |
}
|
| 717 |
|
| 718 |
.songs-list {
|
| 719 |
-
margin: 0
|
| 720 |
}
|
| 721 |
}
|
| 722 |
</style>
|
|
|
|
| 443 |
.play-controls {
|
| 444 |
display: flex;
|
| 445 |
gap: 12px;
|
| 446 |
+
margin: 0 0 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
}
|
| 448 |
|
| 449 |
.control-btn {
|
|
|
|
| 452 |
gap: 6px;
|
| 453 |
padding: 10px 16px;
|
| 454 |
border: none;
|
|
|
|
| 455 |
font-size: 14px;
|
| 456 |
cursor: pointer;
|
| 457 |
transition: var(--transition-fast);
|
|
|
|
| 530 |
|
| 531 |
.songs-list {
|
| 532 |
background: var(--bg-card);
|
| 533 |
+
margin: 0;
|
|
|
|
|
|
|
| 534 |
overflow: hidden;
|
| 535 |
}
|
| 536 |
|
|
|
|
| 709 |
}
|
| 710 |
|
| 711 |
.songs-list {
|
| 712 |
+
margin: 0;
|
| 713 |
}
|
| 714 |
}
|
| 715 |
</style>
|
src/views/FullPlayerPage.vue
CHANGED
|
@@ -445,15 +445,21 @@ const handleTogglePlay = async () => {
|
|
| 445 |
}
|
| 446 |
|
| 447 |
const handlePrevious = () => {
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
| 449 |
}
|
| 450 |
|
| 451 |
const handleNext = () => {
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
| 453 |
}
|
| 454 |
|
| 455 |
const handleTogglePlayMode = () => {
|
| 456 |
-
|
| 457 |
}
|
| 458 |
|
| 459 |
const handleShowPlaylist = () => {
|
|
|
|
| 445 |
}
|
| 446 |
|
| 447 |
const handlePrevious = () => {
|
| 448 |
+
const result = playQueueStore.playPrevious()
|
| 449 |
+
if (result) {
|
| 450 |
+
playerStore.playSong(result)
|
| 451 |
+
}
|
| 452 |
}
|
| 453 |
|
| 454 |
const handleNext = () => {
|
| 455 |
+
const result = playQueueStore.playNext()
|
| 456 |
+
if (result) {
|
| 457 |
+
playerStore.playSong(result)
|
| 458 |
+
}
|
| 459 |
}
|
| 460 |
|
| 461 |
const handleTogglePlayMode = () => {
|
| 462 |
+
playQueueStore.togglePlayMode()
|
| 463 |
}
|
| 464 |
|
| 465 |
const handleShowPlaylist = () => {
|
src/views/HistoryPage.vue
CHANGED
|
@@ -7,10 +7,6 @@
|
|
| 7 |
播放历史
|
| 8 |
</h1>
|
| 9 |
<div class="header-actions">
|
| 10 |
-
<button v-if="!isEmpty" class="action-btn" @click="showExportOptions">
|
| 11 |
-
<i class="fas fa-download"></i>
|
| 12 |
-
<span>导出</span>
|
| 13 |
-
</button>
|
| 14 |
<button v-if="!isEmpty" class="action-btn danger" @click="confirmClearAll">
|
| 15 |
<i class="fas fa-trash"></i>
|
| 16 |
<span>清空</span>
|
|
@@ -28,10 +24,6 @@
|
|
| 28 |
<div class="stat-number">{{ uniqueSongs }}</div>
|
| 29 |
<div class="stat-label">不同歌曲</div>
|
| 30 |
</div>
|
| 31 |
-
<div class="stat-card">
|
| 32 |
-
<div class="stat-number">{{ formatDuration(playStats.totalDuration) }}</div>
|
| 33 |
-
<div class="stat-label">总时长</div>
|
| 34 |
-
</div>
|
| 35 |
</div>
|
| 36 |
|
| 37 |
<!-- 筛选和搜索 -->
|
|
@@ -282,7 +274,7 @@ const playQueueStore = usePlayQueueStore()
|
|
| 282 |
const toastStore = useToastStore()
|
| 283 |
|
| 284 |
// 响应式数据
|
| 285 |
-
const viewMode = ref('
|
| 286 |
const searchKeyword = ref('')
|
| 287 |
const selectedSong = ref(null)
|
| 288 |
const showMoreActions = ref(false)
|
|
@@ -344,8 +336,6 @@ const formatDuration = (seconds) => {
|
|
| 344 |
|
| 345 |
const formatPlayTime = (timestamp, showTime = false) => {
|
| 346 |
const date = new Date(timestamp)
|
| 347 |
-
const now = new Date()
|
| 348 |
-
const diff = now - date
|
| 349 |
|
| 350 |
if (showTime) {
|
| 351 |
return date.toLocaleTimeString('zh-CN', {
|
|
@@ -354,18 +344,11 @@ const formatPlayTime = (timestamp, showTime = false) => {
|
|
| 354 |
})
|
| 355 |
}
|
| 356 |
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
} else if (diff < 24 * 60 * 60 * 1000) {
|
| 363 |
-
const hours = Math.floor(diff / (60 * 60 * 1000))
|
| 364 |
-
return `${hours}小时前`
|
| 365 |
-
} else {
|
| 366 |
-
const days = Math.floor(diff / (24 * 60 * 60 * 1000))
|
| 367 |
-
return `${days}天前`
|
| 368 |
-
}
|
| 369 |
}
|
| 370 |
|
| 371 |
const formatDate = (dateString) => {
|
|
@@ -526,16 +509,7 @@ const handleMoreAction = async (action) => {
|
|
| 526 |
showMoreActions.value = false
|
| 527 |
}
|
| 528 |
|
| 529 |
-
// 导出和清空操作
|
| 530 |
-
const showExportOptions = () => {
|
| 531 |
-
const result = historyStore.exportHistory()
|
| 532 |
-
if (result.success) {
|
| 533 |
-
toastStore.success('导出成功')
|
| 534 |
-
} else {
|
| 535 |
-
toastStore.error('导出失败')
|
| 536 |
-
}
|
| 537 |
-
}
|
| 538 |
-
|
| 539 |
const confirmClearAll = () => {
|
| 540 |
confirmDialog.value = {
|
| 541 |
title: '清空播放历史',
|
|
@@ -599,7 +573,7 @@ onMounted(() => {
|
|
| 599 |
}
|
| 600 |
|
| 601 |
.page-title i {
|
| 602 |
-
color: var(--accent-
|
| 603 |
}
|
| 604 |
|
| 605 |
.header-actions {
|
|
@@ -613,7 +587,7 @@ onMounted(() => {
|
|
| 613 |
gap: 6px;
|
| 614 |
padding: 8px 16px;
|
| 615 |
border: none;
|
| 616 |
-
background: var(--accent-
|
| 617 |
color: white;
|
| 618 |
border-radius: 20px;
|
| 619 |
font-size: 14px;
|
|
@@ -623,7 +597,7 @@ onMounted(() => {
|
|
| 623 |
}
|
| 624 |
|
| 625 |
.action-btn:hover {
|
| 626 |
-
background: var(--accent-
|
| 627 |
transform: translateY(-1px);
|
| 628 |
}
|
| 629 |
|
|
@@ -637,7 +611,7 @@ onMounted(() => {
|
|
| 637 |
|
| 638 |
.stats-grid {
|
| 639 |
display: grid;
|
| 640 |
-
grid-template-columns: repeat(
|
| 641 |
gap: 12px;
|
| 642 |
padding: 16px;
|
| 643 |
background: var(--bg-card);
|
|
@@ -654,7 +628,7 @@ onMounted(() => {
|
|
| 654 |
.stat-number {
|
| 655 |
font-size: 20px;
|
| 656 |
font-weight: 700;
|
| 657 |
-
color: var(--accent-
|
| 658 |
margin-bottom: 4px;
|
| 659 |
}
|
| 660 |
|
|
@@ -696,7 +670,7 @@ onMounted(() => {
|
|
| 696 |
}
|
| 697 |
|
| 698 |
.filter-tab.active {
|
| 699 |
-
background: var(--accent-
|
| 700 |
color: white;
|
| 701 |
}
|
| 702 |
|
|
@@ -780,7 +754,7 @@ onMounted(() => {
|
|
| 780 |
gap: 8px;
|
| 781 |
padding: 12px 24px;
|
| 782 |
border: none;
|
| 783 |
-
background: var(--accent-
|
| 784 |
color: white;
|
| 785 |
border-radius: 25px;
|
| 786 |
font-size: 14px;
|
|
@@ -790,7 +764,7 @@ onMounted(() => {
|
|
| 790 |
}
|
| 791 |
|
| 792 |
.discover-btn:hover {
|
| 793 |
-
background: var(--accent-
|
| 794 |
transform: scale(1.05);
|
| 795 |
}
|
| 796 |
|
|
@@ -818,7 +792,7 @@ onMounted(() => {
|
|
| 818 |
width: 32px;
|
| 819 |
height: 32px;
|
| 820 |
border-radius: 50%;
|
| 821 |
-
background: var(--accent-
|
| 822 |
color: white;
|
| 823 |
display: flex;
|
| 824 |
align-items: center;
|
|
|
|
| 7 |
播放历史
|
| 8 |
</h1>
|
| 9 |
<div class="header-actions">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
<button v-if="!isEmpty" class="action-btn danger" @click="confirmClearAll">
|
| 11 |
<i class="fas fa-trash"></i>
|
| 12 |
<span>清空</span>
|
|
|
|
| 24 |
<div class="stat-number">{{ uniqueSongs }}</div>
|
| 25 |
<div class="stat-label">不同歌曲</div>
|
| 26 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
|
| 29 |
<!-- 筛选和搜索 -->
|
|
|
|
| 274 |
const toastStore = useToastStore()
|
| 275 |
|
| 276 |
// 响应式数据
|
| 277 |
+
const viewMode = ref('grouped') // 'recent', 'grouped', 'top' - 默认显示按日期
|
| 278 |
const searchKeyword = ref('')
|
| 279 |
const selectedSong = ref(null)
|
| 280 |
const showMoreActions = ref(false)
|
|
|
|
| 336 |
|
| 337 |
const formatPlayTime = (timestamp, showTime = false) => {
|
| 338 |
const date = new Date(timestamp)
|
|
|
|
|
|
|
| 339 |
|
| 340 |
if (showTime) {
|
| 341 |
return date.toLocaleTimeString('zh-CN', {
|
|
|
|
| 344 |
})
|
| 345 |
}
|
| 346 |
|
| 347 |
+
// 移除"多少分钟前"显示,直接显示时间
|
| 348 |
+
return date.toLocaleTimeString('zh-CN', {
|
| 349 |
+
hour: '2-digit',
|
| 350 |
+
minute: '2-digit'
|
| 351 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
}
|
| 353 |
|
| 354 |
const formatDate = (dateString) => {
|
|
|
|
| 509 |
showMoreActions.value = false
|
| 510 |
}
|
| 511 |
|
| 512 |
+
// 导出和清空操作 - 移除导出功能
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
const confirmClearAll = () => {
|
| 514 |
confirmDialog.value = {
|
| 515 |
title: '清空播放历史',
|
|
|
|
| 573 |
}
|
| 574 |
|
| 575 |
.page-title i {
|
| 576 |
+
color: var(--accent-red);
|
| 577 |
}
|
| 578 |
|
| 579 |
.header-actions {
|
|
|
|
| 587 |
gap: 6px;
|
| 588 |
padding: 8px 16px;
|
| 589 |
border: none;
|
| 590 |
+
background: var(--accent-red);
|
| 591 |
color: white;
|
| 592 |
border-radius: 20px;
|
| 593 |
font-size: 14px;
|
|
|
|
| 597 |
}
|
| 598 |
|
| 599 |
.action-btn:hover {
|
| 600 |
+
background: var(--accent-red-hover);
|
| 601 |
transform: translateY(-1px);
|
| 602 |
}
|
| 603 |
|
|
|
|
| 611 |
|
| 612 |
.stats-grid {
|
| 613 |
display: grid;
|
| 614 |
+
grid-template-columns: repeat(2, 1fr);
|
| 615 |
gap: 12px;
|
| 616 |
padding: 16px;
|
| 617 |
background: var(--bg-card);
|
|
|
|
| 628 |
.stat-number {
|
| 629 |
font-size: 20px;
|
| 630 |
font-weight: 700;
|
| 631 |
+
color: var(--accent-red);
|
| 632 |
margin-bottom: 4px;
|
| 633 |
}
|
| 634 |
|
|
|
|
| 670 |
}
|
| 671 |
|
| 672 |
.filter-tab.active {
|
| 673 |
+
background: var(--accent-red);
|
| 674 |
color: white;
|
| 675 |
}
|
| 676 |
|
|
|
|
| 754 |
gap: 8px;
|
| 755 |
padding: 12px 24px;
|
| 756 |
border: none;
|
| 757 |
+
background: var(--accent-red);
|
| 758 |
color: white;
|
| 759 |
border-radius: 25px;
|
| 760 |
font-size: 14px;
|
|
|
|
| 764 |
}
|
| 765 |
|
| 766 |
.discover-btn:hover {
|
| 767 |
+
background: var(--accent-red-hover);
|
| 768 |
transform: scale(1.05);
|
| 769 |
}
|
| 770 |
|
|
|
|
| 792 |
width: 32px;
|
| 793 |
height: 32px;
|
| 794 |
border-radius: 50%;
|
| 795 |
+
background: var(--accent-red);
|
| 796 |
color: white;
|
| 797 |
display: flex;
|
| 798 |
align-items: center;
|
src/views/HomePage.vue
CHANGED
|
@@ -14,8 +14,17 @@
|
|
| 14 |
@search="handleSearch"
|
| 15 |
@play="handlePlay"
|
| 16 |
@loadMore="handleLoadMore"
|
|
|
|
| 17 |
/>
|
| 18 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</div>
|
| 20 |
</template>
|
| 21 |
|
|
@@ -26,9 +35,11 @@ import { usePlayerStore } from '@/stores/player'
|
|
| 26 |
import { usePlayQueueStore } from '@/stores/playqueue'
|
| 27 |
import { useHistoryStore } from '@/stores/history'
|
| 28 |
import { useToastStore } from '@/stores/toast'
|
|
|
|
| 29 |
import { musicApi, utils } from '@/services/musicApi'
|
| 30 |
import SearchBox from '@/components/search/SearchBox.vue'
|
| 31 |
import SearchResults from '@/components/search/SearchResults.vue'
|
|
|
|
| 32 |
|
| 33 |
const searchStore = useSearchStore()
|
| 34 |
const playerStore = usePlayerStore()
|
|
@@ -40,6 +51,8 @@ const toastStore = useToastStore()
|
|
| 40 |
const searchError = ref('')
|
| 41 |
const hasSearched = ref(false)
|
| 42 |
const lastSearchKeyword = ref('')
|
|
|
|
|
|
|
| 43 |
|
| 44 |
// 计算属性
|
| 45 |
const searchResults = computed(() => searchStore.searchResults)
|
|
@@ -104,6 +117,72 @@ const handlePlay = async (song, index) => {
|
|
| 104 |
}
|
| 105 |
}
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
const getErrorMessage = (error) => {
|
| 108 |
if (error.message?.includes('Failed to fetch')) {
|
| 109 |
return '网络连接失败,请检查网络后重试'
|
|
|
|
| 14 |
@search="handleSearch"
|
| 15 |
@play="handlePlay"
|
| 16 |
@loadMore="handleLoadMore"
|
| 17 |
+
@showMoreActions="handleShowMoreActions"
|
| 18 |
/>
|
| 19 |
</div>
|
| 20 |
+
|
| 21 |
+
<!-- 更多操作菜单 -->
|
| 22 |
+
<MoreActionsPanel
|
| 23 |
+
v-if="showMoreActions && selectedSong"
|
| 24 |
+
:song="selectedSong"
|
| 25 |
+
@action="handleMoreAction"
|
| 26 |
+
@close="showMoreActions = false"
|
| 27 |
+
/>
|
| 28 |
</div>
|
| 29 |
</template>
|
| 30 |
|
|
|
|
| 35 |
import { usePlayQueueStore } from '@/stores/playqueue'
|
| 36 |
import { useHistoryStore } from '@/stores/history'
|
| 37 |
import { useToastStore } from '@/stores/toast'
|
| 38 |
+
import { useFavoritesStore } from '@/stores/favorites'
|
| 39 |
import { musicApi, utils } from '@/services/musicApi'
|
| 40 |
import SearchBox from '@/components/search/SearchBox.vue'
|
| 41 |
import SearchResults from '@/components/search/SearchResults.vue'
|
| 42 |
+
import MoreActionsPanel from '@/components/player/MoreActionsPanel.vue'
|
| 43 |
|
| 44 |
const searchStore = useSearchStore()
|
| 45 |
const playerStore = usePlayerStore()
|
|
|
|
| 51 |
const searchError = ref('')
|
| 52 |
const hasSearched = ref(false)
|
| 53 |
const lastSearchKeyword = ref('')
|
| 54 |
+
const showMoreActions = ref(false)
|
| 55 |
+
const selectedSong = ref(null)
|
| 56 |
|
| 57 |
// 计算属性
|
| 58 |
const searchResults = computed(() => searchStore.searchResults)
|
|
|
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
| 120 |
+
const handleShowMoreActions = (song, event) => {
|
| 121 |
+
selectedSong.value = song
|
| 122 |
+
showMoreActions.value = true
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const handleMoreAction = async (action) => {
|
| 126 |
+
if (!selectedSong.value) return
|
| 127 |
+
|
| 128 |
+
const song = selectedSong.value
|
| 129 |
+
|
| 130 |
+
switch (action) {
|
| 131 |
+
case 'favorite':
|
| 132 |
+
try {
|
| 133 |
+
const favoritesStore = useFavoritesStore()
|
| 134 |
+
if (favoritesStore.isFavorite(song)) {
|
| 135 |
+
await favoritesStore.removeFromFavorites(song)
|
| 136 |
+
toastStore.success('已从我喜欢的音乐中移除')
|
| 137 |
+
} else {
|
| 138 |
+
await favoritesStore.addToFavorites(song)
|
| 139 |
+
toastStore.success('已添加到我喜欢的音乐')
|
| 140 |
+
}
|
| 141 |
+
} catch (error) {
|
| 142 |
+
console.error('收藏操作失败:', error)
|
| 143 |
+
toastStore.error('操作失败,请重试')
|
| 144 |
+
}
|
| 145 |
+
break
|
| 146 |
+
|
| 147 |
+
case 'addToPlaylist':
|
| 148 |
+
// 添加到播放列表
|
| 149 |
+
try {
|
| 150 |
+
const result = playQueueStore.addToQueue(song, 'last')
|
| 151 |
+
if (result.success) {
|
| 152 |
+
toastStore.success(`"${song.name}" 已添加到播放列表`)
|
| 153 |
+
} else {
|
| 154 |
+
toastStore.error(result.message || '添加失败')
|
| 155 |
+
}
|
| 156 |
+
} catch (error) {
|
| 157 |
+
console.error('添加到播放列表失败:', error)
|
| 158 |
+
toastStore.error('添加到播放列表失败')
|
| 159 |
+
}
|
| 160 |
+
break
|
| 161 |
+
|
| 162 |
+
case 'download':
|
| 163 |
+
// 实现下载功能
|
| 164 |
+
try {
|
| 165 |
+
if (song.url) {
|
| 166 |
+
const link = document.createElement('a')
|
| 167 |
+
link.href = song.url
|
| 168 |
+
link.download = `${utils.formatArtist(song.artist)} - ${song.name}.mp3`
|
| 169 |
+
document.body.appendChild(link)
|
| 170 |
+
link.click()
|
| 171 |
+
document.body.removeChild(link)
|
| 172 |
+
toastStore.success('开始下载')
|
| 173 |
+
} else {
|
| 174 |
+
toastStore.warning('该歌曲暂不支持下载')
|
| 175 |
+
}
|
| 176 |
+
} catch (error) {
|
| 177 |
+
console.error('下载失败:', error)
|
| 178 |
+
toastStore.error('下载失败,请重试')
|
| 179 |
+
}
|
| 180 |
+
break
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
showMoreActions.value = false
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
const getErrorMessage = (error) => {
|
| 187 |
if (error.message?.includes('Failed to fetch')) {
|
| 188 |
return '网络连接失败,请检查网络后重试'
|
src/views/PlaylistDetailPage.vue
CHANGED
|
@@ -1,36 +1,14 @@
|
|
| 1 |
<template>
|
| 2 |
<div class="playlist-detail-page">
|
| 3 |
<!-- 头部信息 -->
|
| 4 |
-
<div class="
|
| 5 |
-
<
|
| 6 |
-
<i class="fas fa-arrow-left"></i>
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
<div class="playlist-cover">
|
| 10 |
-
<img
|
| 11 |
-
v-if="playlist?.cover"
|
| 12 |
-
:src="playlist.cover"
|
| 13 |
-
:alt="playlist?.name"
|
| 14 |
-
@error="handleImageError"
|
| 15 |
-
/>
|
| 16 |
-
<div v-else class="default-cover">
|
| 17 |
-
<i class="fas fa-music"></i>
|
| 18 |
-
</div>
|
| 19 |
-
</div>
|
| 20 |
-
|
| 21 |
-
<div class="playlist-info">
|
| 22 |
-
<h1 class="playlist-name">{{ playlist?.name || '播放列表' }}</h1>
|
| 23 |
-
<p class="playlist-description" v-if="playlist?.description">
|
| 24 |
-
{{ playlist.description }}
|
| 25 |
-
</p>
|
| 26 |
-
<div class="playlist-stats">
|
| 27 |
-
<span class="song-count">{{ playlist?.songs?.length || 0 }}首歌曲</span>
|
| 28 |
-
<span class="created-time">{{ formatCreateTime(playlist?.createdAt) }}</span>
|
| 29 |
-
</div>
|
| 30 |
</div>
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
<button class="action-btn more-btn" @click="showMoreActions">
|
| 34 |
<i class="fas fa-ellipsis-v"></i>
|
| 35 |
</button>
|
| 36 |
</div>
|
|
@@ -67,64 +45,19 @@
|
|
| 67 |
</div>
|
| 68 |
|
| 69 |
<div v-else class="songs-list">
|
| 70 |
-
<
|
| 71 |
v-for="(song, index) in playlist.songs"
|
| 72 |
:key="`${song.id}-${index}`"
|
| 73 |
-
|
| 74 |
-
:
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
<!-- 歌曲序号/播放图标 -->
|
| 85 |
-
<div class="song-index" @click="playSong(song, index)">
|
| 86 |
-
<span v-if="!isCurrentSong(song) || !playerStore.isPlaying">
|
| 87 |
-
{{ String(index + 1).padStart(2, '0') }}
|
| 88 |
-
</span>
|
| 89 |
-
<i v-else class="fas fa-volume-up playing-icon"></i>
|
| 90 |
-
</div>
|
| 91 |
-
|
| 92 |
-
<!-- 歌曲信息 -->
|
| 93 |
-
<div class="song-info" @click="playSong(song, index)">
|
| 94 |
-
<div class="song-name">{{ song.name }}</div>
|
| 95 |
-
<div class="song-artist">
|
| 96 |
-
{{ formatArtist(song.artist) }} · {{ song.album }}
|
| 97 |
-
</div>
|
| 98 |
-
</div>
|
| 99 |
-
|
| 100 |
-
<!-- 歌曲操作 -->
|
| 101 |
-
<div class="song-actions">
|
| 102 |
-
<button
|
| 103 |
-
v-if="!editMode"
|
| 104 |
-
class="action-btn favorite-btn"
|
| 105 |
-
:class="{ active: favoritesStore.isFavorite(song) }"
|
| 106 |
-
@click.stop="toggleFavorite(song)"
|
| 107 |
-
>
|
| 108 |
-
<i :class="favoritesStore.isFavorite(song) ? 'fas fa-heart' : 'far fa-heart'"></i>
|
| 109 |
-
</button>
|
| 110 |
-
|
| 111 |
-
<button
|
| 112 |
-
v-if="!editMode"
|
| 113 |
-
class="action-btn more-btn"
|
| 114 |
-
@click.stop="showSongActions(song, index)"
|
| 115 |
-
>
|
| 116 |
-
<i class="fas fa-ellipsis-v"></i>
|
| 117 |
-
</button>
|
| 118 |
-
|
| 119 |
-
<button
|
| 120 |
-
v-if="editMode"
|
| 121 |
-
class="action-btn remove-btn"
|
| 122 |
-
@click.stop="removeSong(index)"
|
| 123 |
-
>
|
| 124 |
-
<i class="fas fa-times"></i>
|
| 125 |
-
</button>
|
| 126 |
-
</div>
|
| 127 |
-
</div>
|
| 128 |
</div>
|
| 129 |
</div>
|
| 130 |
|
|
@@ -173,6 +106,93 @@
|
|
| 173 |
type="danger"
|
| 174 |
@confirm="confirmAction"
|
| 175 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
</div>
|
| 177 |
</template>
|
| 178 |
|
|
@@ -186,6 +206,10 @@ import { usePlayQueueStore } from '@/stores/playqueue'
|
|
| 186 |
import { useToastStore } from '@/stores/toast'
|
| 187 |
import { utils } from '@/services/musicApi'
|
| 188 |
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
const router = useRouter()
|
| 191 |
const route = useRoute()
|
|
@@ -200,19 +224,22 @@ const playlist = ref(null)
|
|
| 200 |
const editMode = ref(false)
|
| 201 |
const showActions = ref(false)
|
| 202 |
const showSongMenu = ref(false)
|
|
|
|
|
|
|
|
|
|
| 203 |
const selectedSong = ref(null)
|
| 204 |
const selectedIndex = ref(-1)
|
| 205 |
const confirmDialogRef = ref(null)
|
|
|
|
|
|
|
| 206 |
const confirmTitle = ref('')
|
| 207 |
const confirmMessage = ref('')
|
| 208 |
const confirmButtonText = ref('确认')
|
| 209 |
const pendingAction = ref(null)
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
playerStore.currentSong?.source === song.source
|
| 215 |
-
}
|
| 216 |
|
| 217 |
// 方法
|
| 218 |
const loadPlaylist = () => {
|
|
@@ -228,6 +255,10 @@ const loadPlaylist = () => {
|
|
| 228 |
}
|
| 229 |
}
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
const goBack = () => {
|
| 232 |
router.push('/playlists')
|
| 233 |
}
|
|
@@ -236,10 +267,6 @@ const handleImageError = (event) => {
|
|
| 236 |
event.target.style.display = 'none'
|
| 237 |
}
|
| 238 |
|
| 239 |
-
const formatArtist = (artist) => {
|
| 240 |
-
return utils.formatArtist ? utils.formatArtist(artist) : artist
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
const formatCreateTime = (timestamp) => {
|
| 244 |
if (!timestamp) return ''
|
| 245 |
const date = new Date(timestamp)
|
|
@@ -277,15 +304,6 @@ const playSong = async (song, index) => {
|
|
| 277 |
}
|
| 278 |
}
|
| 279 |
|
| 280 |
-
const toggleFavorite = async (song) => {
|
| 281 |
-
try {
|
| 282 |
-
await favoritesStore.toggleFavorite(song)
|
| 283 |
-
} catch (error) {
|
| 284 |
-
console.error('收藏操作失败:', error)
|
| 285 |
-
toastStore.error('操作失败,请重试')
|
| 286 |
-
}
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
// 编辑模式
|
| 290 |
const toggleEditMode = () => {
|
| 291 |
editMode.value = !editMode.value
|
|
@@ -299,12 +317,17 @@ const removeSong = (index) => {
|
|
| 299 |
confirmTitle.value = '移除歌曲'
|
| 300 |
confirmMessage.value = `确定要从播放列表中移除"${playlist.value.songs[index].name}"吗?`
|
| 301 |
confirmButtonText.value = '移除'
|
| 302 |
-
pendingAction.value = () => {
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
}
|
| 309 |
}
|
| 310 |
confirmDialogRef.value?.show()
|
|
@@ -312,13 +335,33 @@ const removeSong = (index) => {
|
|
| 312 |
|
| 313 |
// 操作菜单
|
| 314 |
const showMoreActions = () => {
|
| 315 |
-
|
| 316 |
}
|
| 317 |
|
| 318 |
const hideActions = () => {
|
| 319 |
showActions.value = false
|
| 320 |
}
|
| 321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
const showSongActions = (song, index) => {
|
| 323 |
selectedSong.value = song
|
| 324 |
selectedIndex.value = index
|
|
@@ -338,9 +381,21 @@ const playNext = (song) => {
|
|
| 338 |
}
|
| 339 |
|
| 340 |
const addToOtherPlaylist = () => {
|
| 341 |
-
// TODO: 实现添加到其他播放列表的功能
|
| 342 |
hideSongActions()
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
}
|
| 345 |
|
| 346 |
const removeSongFromMenu = () => {
|
|
@@ -349,9 +404,40 @@ const removeSongFromMenu = () => {
|
|
| 349 |
}
|
| 350 |
|
| 351 |
const editPlaylistInfo = () => {
|
| 352 |
-
// TODO: 实现编辑播放列表信息的功能
|
| 353 |
hideActions()
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
}
|
| 356 |
|
| 357 |
const clearPlaylist = () => {
|
|
@@ -413,53 +499,95 @@ watch(() => route.params.id, () => {
|
|
| 413 |
padding-bottom: calc(var(--mini-player-height) + var(--tabbar-height) + 20px);
|
| 414 |
}
|
| 415 |
|
| 416 |
-
|
|
|
|
| 417 |
display: flex;
|
| 418 |
-
align-items:
|
| 419 |
-
|
| 420 |
padding: 16px;
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
}
|
| 426 |
|
| 427 |
-
.back-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
border: none;
|
| 431 |
background: rgba(255, 255, 255, 0.1);
|
| 432 |
color: var(--text-secondary);
|
| 433 |
border-radius: 50%;
|
| 434 |
-
font-size:
|
| 435 |
cursor: pointer;
|
| 436 |
-
display: flex;
|
| 437 |
-
align-items: center;
|
| 438 |
-
justify-content: center;
|
| 439 |
transition: var(--transition-fast);
|
| 440 |
-
flex-shrink: 0;
|
| 441 |
}
|
| 442 |
|
| 443 |
-
.
|
| 444 |
-
background:
|
| 445 |
-
color:
|
|
|
|
| 446 |
}
|
| 447 |
|
| 448 |
-
.
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
overflow: hidden;
|
| 453 |
flex-shrink: 0;
|
| 454 |
}
|
| 455 |
|
| 456 |
-
.playlist-cover img {
|
| 457 |
width: 100%;
|
| 458 |
height: 100%;
|
| 459 |
object-fit: cover;
|
| 460 |
}
|
| 461 |
|
| 462 |
-
.default-cover {
|
| 463 |
width: 100%;
|
| 464 |
height: 100%;
|
| 465 |
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
|
@@ -467,29 +595,42 @@ watch(() => route.params.id, () => {
|
|
| 467 |
align-items: center;
|
| 468 |
justify-content: center;
|
| 469 |
color: white;
|
| 470 |
-
font-size:
|
| 471 |
}
|
| 472 |
|
| 473 |
-
.playlist-info {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
flex: 1;
|
| 475 |
-
|
|
|
|
|
|
|
| 476 |
}
|
| 477 |
|
| 478 |
-
.playlist-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
color: var(--text-primary);
|
| 482 |
-
margin: 0 0 8px;
|
| 483 |
-
overflow: hidden;
|
| 484 |
-
text-overflow: ellipsis;
|
| 485 |
-
white-space: nowrap;
|
| 486 |
}
|
| 487 |
|
| 488 |
.playlist-description {
|
| 489 |
font-size: 14px;
|
| 490 |
color: var(--text-secondary);
|
| 491 |
-
margin: 0 0
|
| 492 |
-
line-height: 1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
}
|
| 494 |
|
| 495 |
.playlist-stats {
|
|
@@ -499,38 +640,105 @@ watch(() => route.params.id, () => {
|
|
| 499 |
color: var(--text-tertiary);
|
| 500 |
}
|
| 501 |
|
| 502 |
-
.playlist-
|
| 503 |
-
|
|
|
|
|
|
|
| 504 |
}
|
| 505 |
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
border: none;
|
|
|
|
|
|
|
|
|
|
| 510 |
background: rgba(255, 255, 255, 0.1);
|
| 511 |
color: var(--text-secondary);
|
| 512 |
-
border-radius: 50%;
|
| 513 |
-
font-size: 16px;
|
| 514 |
-
cursor: pointer;
|
| 515 |
-
display: flex;
|
| 516 |
-
align-items: center;
|
| 517 |
-
justify-content: center;
|
| 518 |
-
transition: var(--transition-fast);
|
| 519 |
}
|
| 520 |
|
| 521 |
-
.
|
| 522 |
background: rgba(255, 255, 255, 0.2);
|
| 523 |
color: var(--text-primary);
|
| 524 |
}
|
| 525 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
.play-controls {
|
| 527 |
display: flex;
|
| 528 |
gap: 12px;
|
| 529 |
-
|
| 530 |
-
background: var(--bg-card);
|
| 531 |
-
margin: 0 16px 16px;
|
| 532 |
-
border-radius: var(--radius-small);
|
| 533 |
-
border: 1px solid var(--border-light);
|
| 534 |
}
|
| 535 |
|
| 536 |
.play-all-btn,
|
|
@@ -541,7 +749,6 @@ watch(() => route.params.id, () => {
|
|
| 541 |
gap: 6px;
|
| 542 |
padding: 10px 16px;
|
| 543 |
border: none;
|
| 544 |
-
border-radius: 20px;
|
| 545 |
font-size: 14px;
|
| 546 |
cursor: pointer;
|
| 547 |
transition: var(--transition-fast);
|
|
@@ -606,127 +813,8 @@ watch(() => route.params.id, () => {
|
|
| 606 |
|
| 607 |
.songs-list {
|
| 608 |
background: var(--bg-card);
|
| 609 |
-
margin: 0
|
| 610 |
-
border-radius: var(--radius-small);
|
| 611 |
-
border: 1px solid var(--border-light);
|
| 612 |
-
overflow: hidden;
|
| 613 |
-
}
|
| 614 |
-
|
| 615 |
-
.song-item {
|
| 616 |
-
display: flex;
|
| 617 |
-
align-items: center;
|
| 618 |
-
padding: 12px 16px;
|
| 619 |
-
border-bottom: 1px solid var(--border-lighter);
|
| 620 |
-
transition: var(--transition-fast);
|
| 621 |
-
position: relative;
|
| 622 |
-
}
|
| 623 |
-
|
| 624 |
-
.song-item:last-child {
|
| 625 |
-
border-bottom: none;
|
| 626 |
-
}
|
| 627 |
-
|
| 628 |
-
.song-item:hover {
|
| 629 |
-
background: var(--overlay-lighter);
|
| 630 |
-
}
|
| 631 |
-
|
| 632 |
-
.song-item.active {
|
| 633 |
-
background: var(--bg-gradient-3);
|
| 634 |
-
color: var(--accent-red);
|
| 635 |
-
}
|
| 636 |
-
|
| 637 |
-
.song-item.edit-mode {
|
| 638 |
-
padding-left: 50px;
|
| 639 |
-
}
|
| 640 |
-
|
| 641 |
-
.drag-handle {
|
| 642 |
-
position: absolute;
|
| 643 |
-
left: 16px;
|
| 644 |
-
color: var(--text-tertiary);
|
| 645 |
-
cursor: grab;
|
| 646 |
-
}
|
| 647 |
-
|
| 648 |
-
.drag-handle:active {
|
| 649 |
-
cursor: grabbing;
|
| 650 |
-
}
|
| 651 |
-
|
| 652 |
-
.song-index {
|
| 653 |
-
width: 32px;
|
| 654 |
-
height: 32px;
|
| 655 |
-
border-radius: 50%;
|
| 656 |
-
background: rgba(255, 255, 255, 0.1);
|
| 657 |
-
display: flex;
|
| 658 |
-
align-items: center;
|
| 659 |
-
justify-content: center;
|
| 660 |
-
font-size: 12px;
|
| 661 |
-
font-weight: 600;
|
| 662 |
-
color: var(--text-secondary);
|
| 663 |
-
margin-right: 12px;
|
| 664 |
-
flex-shrink: 0;
|
| 665 |
-
cursor: pointer;
|
| 666 |
-
transition: var(--transition-fast);
|
| 667 |
-
}
|
| 668 |
-
|
| 669 |
-
.song-index:hover {
|
| 670 |
-
background: rgba(255, 255, 255, 0.2);
|
| 671 |
-
}
|
| 672 |
-
|
| 673 |
-
.song-item.active .song-index {
|
| 674 |
-
background: var(--accent-red);
|
| 675 |
-
color: white;
|
| 676 |
-
}
|
| 677 |
-
|
| 678 |
-
.playing-icon {
|
| 679 |
-
color: var(--accent-red);
|
| 680 |
-
animation: pulse 1.5s infinite;
|
| 681 |
-
}
|
| 682 |
-
|
| 683 |
-
.song-info {
|
| 684 |
-
flex: 1;
|
| 685 |
-
min-width: 0;
|
| 686 |
-
margin-right: 12px;
|
| 687 |
-
cursor: pointer;
|
| 688 |
-
}
|
| 689 |
-
|
| 690 |
-
.song-name {
|
| 691 |
-
font-size: 15px;
|
| 692 |
-
font-weight: 500;
|
| 693 |
-
color: var(--text-primary);
|
| 694 |
-
margin-bottom: 4px;
|
| 695 |
overflow: hidden;
|
| 696 |
-
text-overflow: ellipsis;
|
| 697 |
-
white-space: nowrap;
|
| 698 |
-
}
|
| 699 |
-
|
| 700 |
-
.song-item.active .song-name {
|
| 701 |
-
color: var(--accent-red);
|
| 702 |
-
}
|
| 703 |
-
|
| 704 |
-
.song-artist {
|
| 705 |
-
font-size: 13px;
|
| 706 |
-
color: var(--text-secondary);
|
| 707 |
-
overflow: hidden;
|
| 708 |
-
text-overflow: ellipsis;
|
| 709 |
-
white-space: nowrap;
|
| 710 |
-
}
|
| 711 |
-
|
| 712 |
-
.song-actions {
|
| 713 |
-
display: flex;
|
| 714 |
-
gap: 4px;
|
| 715 |
-
flex-shrink: 0;
|
| 716 |
-
}
|
| 717 |
-
|
| 718 |
-
.favorite-btn.active {
|
| 719 |
-
color: var(--accent-red);
|
| 720 |
-
}
|
| 721 |
-
|
| 722 |
-
.remove-btn {
|
| 723 |
-
background: rgba(255, 68, 68, 0.1);
|
| 724 |
-
color: #ff4444;
|
| 725 |
-
}
|
| 726 |
-
|
| 727 |
-
.remove-btn:hover {
|
| 728 |
-
background: rgba(255, 68, 68, 0.2);
|
| 729 |
-
color: #ff2222;
|
| 730 |
}
|
| 731 |
|
| 732 |
.actions-overlay {
|
|
|
|
| 1 |
<template>
|
| 2 |
<div class="playlist-detail-page">
|
| 3 |
<!-- 头部信息 -->
|
| 4 |
+
<div class="page-header">
|
| 5 |
+
<div class="page-title">
|
| 6 |
+
<i class="fas fa-arrow-left back-icon" @click="goBack"></i>
|
| 7 |
+
<i class="fas fa-music"></i>
|
| 8 |
+
{{ playlist?.name || '播放列表' }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
</div>
|
| 10 |
+
<div class="header-actions">
|
| 11 |
+
<button class="action-btn" @click="showMoreActions">
|
|
|
|
| 12 |
<i class="fas fa-ellipsis-v"></i>
|
| 13 |
</button>
|
| 14 |
</div>
|
|
|
|
| 45 |
</div>
|
| 46 |
|
| 47 |
<div v-else class="songs-list">
|
| 48 |
+
<SongItem
|
| 49 |
v-for="(song, index) in playlist.songs"
|
| 50 |
:key="`${song.id}-${index}`"
|
| 51 |
+
:song="song"
|
| 52 |
+
:index="index"
|
| 53 |
+
:showBatchActions="editMode"
|
| 54 |
+
:isSelected="false"
|
| 55 |
+
:showRemove="editMode"
|
| 56 |
+
:showActions="!editMode"
|
| 57 |
+
@play="playSong"
|
| 58 |
+
@remove="removeSong"
|
| 59 |
+
@showMoreActions="showSongActions"
|
| 60 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
|
|
|
|
| 106 |
type="danger"
|
| 107 |
@confirm="confirmAction"
|
| 108 |
/>
|
| 109 |
+
|
| 110 |
+
<!-- 歌单选择器 -->
|
| 111 |
+
<PlaylistSelector
|
| 112 |
+
v-if="selectedSong"
|
| 113 |
+
:show="showPlaylistSelector"
|
| 114 |
+
:song="selectedSong"
|
| 115 |
+
@close="closePlaylistSelector"
|
| 116 |
+
@added="handlePlaylistSelectAdded"
|
| 117 |
+
/>
|
| 118 |
+
|
| 119 |
+
<!-- 编辑歌单Modal -->
|
| 120 |
+
<Modal
|
| 121 |
+
:title="editForm.name ? '编辑歌单信息' : '新建歌单'"
|
| 122 |
+
size="small"
|
| 123 |
+
:closable="true"
|
| 124 |
+
ref="editModalRef"
|
| 125 |
+
>
|
| 126 |
+
<div class="edit-form">
|
| 127 |
+
<div class="form-group">
|
| 128 |
+
<label>歌单名称</label>
|
| 129 |
+
<input
|
| 130 |
+
v-model="editForm.name"
|
| 131 |
+
type="text"
|
| 132 |
+
placeholder="请输入歌单名称"
|
| 133 |
+
maxlength="50"
|
| 134 |
+
/>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="form-group">
|
| 138 |
+
<label>描述 (可选)</label>
|
| 139 |
+
<textarea
|
| 140 |
+
v-model="editForm.description"
|
| 141 |
+
placeholder="请输入歌单描述"
|
| 142 |
+
maxlength="200"
|
| 143 |
+
rows="3"
|
| 144 |
+
></textarea>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<template #footer>
|
| 149 |
+
<button class="btn btn-cancel" @click="cancelEdit">取消</button>
|
| 150 |
+
<button class="btn btn-primary" @click="savePlaylistInfo" :disabled="!editForm.name.trim()">
|
| 151 |
+
保存
|
| 152 |
+
</button>
|
| 153 |
+
</template>
|
| 154 |
+
</Modal>
|
| 155 |
+
|
| 156 |
+
<!-- 歌单信息Modal -->
|
| 157 |
+
<Modal
|
| 158 |
+
:title="playlist?.name || '歌单信息'"
|
| 159 |
+
size="medium"
|
| 160 |
+
:closable="true"
|
| 161 |
+
ref="infoModalRef"
|
| 162 |
+
>
|
| 163 |
+
<div class="playlist-info-content">
|
| 164 |
+
<div class="playlist-cover-large">
|
| 165 |
+
<img
|
| 166 |
+
v-if="playlist?.cover"
|
| 167 |
+
:src="playlist.cover"
|
| 168 |
+
:alt="playlist?.name"
|
| 169 |
+
@error="handleImageError"
|
| 170 |
+
/>
|
| 171 |
+
<div v-else class="default-cover-large">
|
| 172 |
+
<i class="fas fa-music"></i>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div class="playlist-meta">
|
| 177 |
+
<p class="playlist-description" v-if="playlist?.description">
|
| 178 |
+
{{ playlist.description }}
|
| 179 |
+
</p>
|
| 180 |
+
<div class="playlist-stats">
|
| 181 |
+
<span class="song-count">{{ playlist?.songs?.length || 0 }}首歌曲</span>
|
| 182 |
+
<span class="created-time">{{ formatCreateTime(playlist?.createdAt) }}</span>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</Modal>
|
| 187 |
+
|
| 188 |
+
<!-- 统一操作菜单 -->
|
| 189 |
+
<ActionMenu
|
| 190 |
+
v-if="showMoreActionsPanel"
|
| 191 |
+
type="playlist"
|
| 192 |
+
:playlist="playlist"
|
| 193 |
+
@close="closeMoreActionsPanel"
|
| 194 |
+
@action="handleMoreAction"
|
| 195 |
+
/>
|
| 196 |
</div>
|
| 197 |
</template>
|
| 198 |
|
|
|
|
| 206 |
import { useToastStore } from '@/stores/toast'
|
| 207 |
import { utils } from '@/services/musicApi'
|
| 208 |
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
| 209 |
+
import PlaylistSelector from '@/components/playlist/PlaylistSelector.vue'
|
| 210 |
+
import Modal from '@/components/common/Modal.vue'
|
| 211 |
+
import ActionMenu from '@/components/common/ActionMenu.vue'
|
| 212 |
+
import SongItem from '@/components/search/SongItem.vue'
|
| 213 |
|
| 214 |
const router = useRouter()
|
| 215 |
const route = useRoute()
|
|
|
|
| 224 |
const editMode = ref(false)
|
| 225 |
const showActions = ref(false)
|
| 226 |
const showSongMenu = ref(false)
|
| 227 |
+
const showPlaylistSelector = ref(false)
|
| 228 |
+
const showEditModal = ref(false)
|
| 229 |
+
const showMoreActionsPanel = ref(false)
|
| 230 |
const selectedSong = ref(null)
|
| 231 |
const selectedIndex = ref(-1)
|
| 232 |
const confirmDialogRef = ref(null)
|
| 233 |
+
const editModalRef = ref(null)
|
| 234 |
+
const infoModalRef = ref(null)
|
| 235 |
const confirmTitle = ref('')
|
| 236 |
const confirmMessage = ref('')
|
| 237 |
const confirmButtonText = ref('确认')
|
| 238 |
const pendingAction = ref(null)
|
| 239 |
+
const editForm = ref({
|
| 240 |
+
name: '',
|
| 241 |
+
description: ''
|
| 242 |
+
})
|
|
|
|
|
|
|
| 243 |
|
| 244 |
// 方法
|
| 245 |
const loadPlaylist = () => {
|
|
|
|
| 255 |
}
|
| 256 |
}
|
| 257 |
|
| 258 |
+
const showInfoModal = () => {
|
| 259 |
+
infoModalRef.value?.open()
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
const goBack = () => {
|
| 263 |
router.push('/playlists')
|
| 264 |
}
|
|
|
|
| 267 |
event.target.style.display = 'none'
|
| 268 |
}
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
const formatCreateTime = (timestamp) => {
|
| 271 |
if (!timestamp) return ''
|
| 272 |
const date = new Date(timestamp)
|
|
|
|
| 304 |
}
|
| 305 |
}
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
// 编辑模式
|
| 308 |
const toggleEditMode = () => {
|
| 309 |
editMode.value = !editMode.value
|
|
|
|
| 317 |
confirmTitle.value = '移除歌曲'
|
| 318 |
confirmMessage.value = `确定要从播放列表中移除"${playlist.value.songs[index].name}"吗?`
|
| 319 |
confirmButtonText.value = '移除'
|
| 320 |
+
pendingAction.value = async () => {
|
| 321 |
+
try {
|
| 322 |
+
const success = await playlistStore.removeSongFromPlaylistByIndex(playlist.value.id, index)
|
| 323 |
+
if (success) {
|
| 324 |
+
toastStore.success('歌曲已移除')
|
| 325 |
+
} else {
|
| 326 |
+
toastStore.error('移除失败')
|
| 327 |
+
}
|
| 328 |
+
} catch (error) {
|
| 329 |
+
console.error('移除歌曲失败:', error)
|
| 330 |
+
toastStore.error('移除失败,请重试')
|
| 331 |
}
|
| 332 |
}
|
| 333 |
confirmDialogRef.value?.show()
|
|
|
|
| 335 |
|
| 336 |
// 操作菜单
|
| 337 |
const showMoreActions = () => {
|
| 338 |
+
showMoreActionsPanel.value = true
|
| 339 |
}
|
| 340 |
|
| 341 |
const hideActions = () => {
|
| 342 |
showActions.value = false
|
| 343 |
}
|
| 344 |
|
| 345 |
+
const closeMoreActionsPanel = () => {
|
| 346 |
+
showMoreActionsPanel.value = false
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
const handleMoreAction = ({ action, playlist: actionPlaylist }) => {
|
| 350 |
+
switch (action) {
|
| 351 |
+
case 'editInfo':
|
| 352 |
+
editPlaylistInfo()
|
| 353 |
+
break
|
| 354 |
+
|
| 355 |
+
case 'clearPlaylist':
|
| 356 |
+
clearPlaylist()
|
| 357 |
+
break
|
| 358 |
+
|
| 359 |
+
case 'deletePlaylist':
|
| 360 |
+
deletePlaylist()
|
| 361 |
+
break
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
const showSongActions = (song, index) => {
|
| 366 |
selectedSong.value = song
|
| 367 |
selectedIndex.value = index
|
|
|
|
| 381 |
}
|
| 382 |
|
| 383 |
const addToOtherPlaylist = () => {
|
|
|
|
| 384 |
hideSongActions()
|
| 385 |
+
if (selectedSong.value) {
|
| 386 |
+
showPlaylistSelector.value = true
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
const handlePlaylistSelectAdded = (data) => {
|
| 391 |
+
if (data && data.message) {
|
| 392 |
+
toastStore.success(data.message)
|
| 393 |
+
}
|
| 394 |
+
showPlaylistSelector.value = false
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
const closePlaylistSelector = () => {
|
| 398 |
+
showPlaylistSelector.value = false
|
| 399 |
}
|
| 400 |
|
| 401 |
const removeSongFromMenu = () => {
|
|
|
|
| 404 |
}
|
| 405 |
|
| 406 |
const editPlaylistInfo = () => {
|
|
|
|
| 407 |
hideActions()
|
| 408 |
+
if (playlist.value) {
|
| 409 |
+
editForm.value = {
|
| 410 |
+
name: playlist.value.name,
|
| 411 |
+
description: playlist.value.description || ''
|
| 412 |
+
}
|
| 413 |
+
editModalRef.value?.open()
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
const savePlaylistInfo = () => {
|
| 418 |
+
if (!editForm.value.name.trim()) {
|
| 419 |
+
toastStore.error('歌单名称不能为空')
|
| 420 |
+
return
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
try {
|
| 424 |
+
const success = playlistStore.updatePlaylist(playlist.value.id, {
|
| 425 |
+
name: editForm.value.name.trim(),
|
| 426 |
+
description: editForm.value.description.trim() || undefined
|
| 427 |
+
})
|
| 428 |
+
|
| 429 |
+
if (success) {
|
| 430 |
+
playlist.value.name = editForm.value.name.trim()
|
| 431 |
+
playlist.value.description = editForm.value.description.trim() || undefined
|
| 432 |
+
editModalRef.value?.close()
|
| 433 |
+
toastStore.success('歌单信息已更新')
|
| 434 |
+
} else {
|
| 435 |
+
toastStore.error('更新失败')
|
| 436 |
+
}
|
| 437 |
+
} catch (error) {
|
| 438 |
+
console.error('更新歌单信息失败:', error)
|
| 439 |
+
toastStore.error('更新失败,请重试')
|
| 440 |
+
}
|
| 441 |
}
|
| 442 |
|
| 443 |
const clearPlaylist = () => {
|
|
|
|
| 499 |
padding-bottom: calc(var(--mini-player-height) + var(--tabbar-height) + 20px);
|
| 500 |
}
|
| 501 |
|
| 502 |
+
/* 使用标准的page-header样式 */
|
| 503 |
+
.page-header {
|
| 504 |
display: flex;
|
| 505 |
+
align-items: center;
|
| 506 |
+
justify-content: space-between;
|
| 507 |
padding: 16px;
|
| 508 |
+
border-bottom: 1px solid var(--border-lighter);
|
| 509 |
+
background: var(--bg-primary);
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.page-title {
|
| 513 |
+
display: flex;
|
| 514 |
+
align-items: center;
|
| 515 |
+
gap: 12px;
|
| 516 |
+
font-size: 20px;
|
| 517 |
+
font-weight: 700;
|
| 518 |
+
color: var(--text-primary);
|
| 519 |
+
margin: 0;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
.page-title i {
|
| 523 |
+
color: var(--accent-red);
|
| 524 |
}
|
| 525 |
|
| 526 |
+
.back-icon {
|
| 527 |
+
cursor: pointer;
|
| 528 |
+
color: var(--text-secondary);
|
| 529 |
+
transition: var(--transition-fast);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.back-icon:hover {
|
| 533 |
+
color: var(--text-primary);
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.header-actions {
|
| 537 |
+
display: flex;
|
| 538 |
+
gap: 8px;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.action-btn {
|
| 542 |
+
display: flex;
|
| 543 |
+
align-items: center;
|
| 544 |
+
justify-content: center;
|
| 545 |
+
width: 36px;
|
| 546 |
+
height: 36px;
|
| 547 |
border: none;
|
| 548 |
background: rgba(255, 255, 255, 0.1);
|
| 549 |
color: var(--text-secondary);
|
| 550 |
border-radius: 50%;
|
| 551 |
+
font-size: 14px;
|
| 552 |
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
| 553 |
transition: var(--transition-fast);
|
|
|
|
| 554 |
}
|
| 555 |
|
| 556 |
+
.action-btn:hover {
|
| 557 |
+
background: var(--accent-red);
|
| 558 |
+
color: white;
|
| 559 |
+
transform: scale(1.1);
|
| 560 |
}
|
| 561 |
|
| 562 |
+
.action-btn.active {
|
| 563 |
+
background: var(--accent-red);
|
| 564 |
+
color: white;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
/* 歌单信息内容样式 */
|
| 568 |
+
.playlist-info-content {
|
| 569 |
+
display: flex;
|
| 570 |
+
flex-direction: column;
|
| 571 |
+
align-items: center;
|
| 572 |
+
gap: 20px;
|
| 573 |
+
padding: 20px 0;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.playlist-cover-large {
|
| 577 |
+
width: 120px;
|
| 578 |
+
height: 120px;
|
| 579 |
+
border-radius: 12px;
|
| 580 |
overflow: hidden;
|
| 581 |
flex-shrink: 0;
|
| 582 |
}
|
| 583 |
|
| 584 |
+
.playlist-cover-large img {
|
| 585 |
width: 100%;
|
| 586 |
height: 100%;
|
| 587 |
object-fit: cover;
|
| 588 |
}
|
| 589 |
|
| 590 |
+
.default-cover-large {
|
| 591 |
width: 100%;
|
| 592 |
height: 100%;
|
| 593 |
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
|
|
|
| 595 |
align-items: center;
|
| 596 |
justify-content: center;
|
| 597 |
color: white;
|
| 598 |
+
font-size: 48px;
|
| 599 |
}
|
| 600 |
|
| 601 |
+
.playlist-info-content .playlist-cover-large {
|
| 602 |
+
width: 200px;
|
| 603 |
+
height: 200px;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.playlist-info-content .default-cover-large {
|
| 607 |
+
font-size: 64px;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.playlist-meta {
|
| 611 |
flex: 1;
|
| 612 |
+
display: flex;
|
| 613 |
+
flex-direction: column;
|
| 614 |
+
justify-content: center;
|
| 615 |
}
|
| 616 |
|
| 617 |
+
.playlist-info-content .playlist-meta {
|
| 618 |
+
text-align: center;
|
| 619 |
+
width: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
}
|
| 621 |
|
| 622 |
.playlist-description {
|
| 623 |
font-size: 14px;
|
| 624 |
color: var(--text-secondary);
|
| 625 |
+
margin: 0 0 12px;
|
| 626 |
+
line-height: 1.5;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.playlist-info-content .playlist-description {
|
| 630 |
+
font-size: 16px;
|
| 631 |
+
line-height: 1.6;
|
| 632 |
+
margin-bottom: 16px;
|
| 633 |
+
color: var(--text-secondary);
|
| 634 |
}
|
| 635 |
|
| 636 |
.playlist-stats {
|
|
|
|
| 640 |
color: var(--text-tertiary);
|
| 641 |
}
|
| 642 |
|
| 643 |
+
.playlist-info-content .playlist-stats {
|
| 644 |
+
justify-content: center;
|
| 645 |
+
font-size: 14px;
|
| 646 |
+
gap: 24px;
|
| 647 |
}
|
| 648 |
|
| 649 |
+
/* 编辑表单样式 */
|
| 650 |
+
.edit-form {
|
| 651 |
+
padding: 8px 0;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.edit-form .form-group {
|
| 655 |
+
margin-bottom: 20px;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.edit-form .form-group:last-child {
|
| 659 |
+
margin-bottom: 0;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
.edit-form .form-group label {
|
| 663 |
+
display: block;
|
| 664 |
+
font-size: 14px;
|
| 665 |
+
font-weight: 500;
|
| 666 |
+
color: var(--text-primary);
|
| 667 |
+
margin-bottom: 8px;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.edit-form .form-group input,
|
| 671 |
+
.edit-form .form-group textarea {
|
| 672 |
+
width: 100%;
|
| 673 |
+
padding: 12px 16px;
|
| 674 |
+
border: 2px solid var(--border-card);
|
| 675 |
+
border-radius: 8px;
|
| 676 |
+
background: var(--overlay-lighter);
|
| 677 |
+
color: var(--text-primary);
|
| 678 |
+
font-size: 14px;
|
| 679 |
+
transition: var(--transition-fast);
|
| 680 |
+
font-family: inherit;
|
| 681 |
+
box-sizing: border-box;
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.edit-form .form-group input:focus,
|
| 685 |
+
.edit-form .form-group textarea:focus {
|
| 686 |
+
outline: none;
|
| 687 |
+
border-color: var(--accent-red);
|
| 688 |
+
background: rgba(255, 255, 255, 0.08);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.edit-form .form-group input::placeholder,
|
| 692 |
+
.edit-form .form-group textarea::placeholder {
|
| 693 |
+
color: var(--text-tertiary);
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.edit-form .form-group textarea {
|
| 697 |
+
resize: vertical;
|
| 698 |
+
min-height: 80px;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
/* Modal按钮样式 */
|
| 702 |
+
.btn {
|
| 703 |
+
padding: 10px 20px;
|
| 704 |
+
border-radius: 8px;
|
| 705 |
+
font-size: 14px;
|
| 706 |
+
font-weight: 500;
|
| 707 |
+
cursor: pointer;
|
| 708 |
+
transition: var(--transition-fast);
|
| 709 |
border: none;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
.btn-cancel {
|
| 713 |
background: rgba(255, 255, 255, 0.1);
|
| 714 |
color: var(--text-secondary);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
}
|
| 716 |
|
| 717 |
+
.btn-cancel:hover {
|
| 718 |
background: rgba(255, 255, 255, 0.2);
|
| 719 |
color: var(--text-primary);
|
| 720 |
}
|
| 721 |
|
| 722 |
+
.btn-primary {
|
| 723 |
+
background: var(--accent-red);
|
| 724 |
+
color: white;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
.btn-primary:hover:not(:disabled) {
|
| 728 |
+
background: var(--accent-red-hover);
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.btn-primary:disabled {
|
| 732 |
+
background: rgba(255, 255, 255, 0.1);
|
| 733 |
+
color: var(--text-tertiary);
|
| 734 |
+
cursor: not-allowed;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
/* 播放控制区域样式 */
|
| 738 |
.play-controls {
|
| 739 |
display: flex;
|
| 740 |
gap: 12px;
|
| 741 |
+
margin: 0 0 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
}
|
| 743 |
|
| 744 |
.play-all-btn,
|
|
|
|
| 749 |
gap: 6px;
|
| 750 |
padding: 10px 16px;
|
| 751 |
border: none;
|
|
|
|
| 752 |
font-size: 14px;
|
| 753 |
cursor: pointer;
|
| 754 |
transition: var(--transition-fast);
|
|
|
|
| 813 |
|
| 814 |
.songs-list {
|
| 815 |
background: var(--bg-card);
|
| 816 |
+
margin: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
}
|
| 819 |
|
| 820 |
.actions-overlay {
|
src/views/PlaylistsPage.vue
CHANGED
|
@@ -15,35 +15,15 @@
|
|
| 15 |
|
| 16 |
<!-- 我的歌单 -->
|
| 17 |
<div class="playlists-content">
|
| 18 |
-
<div v-if="customPlaylists.length > 0" class="playlists-
|
| 19 |
-
<
|
| 20 |
-
v-for="playlist in customPlaylists"
|
| 21 |
:key="playlist.id"
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
v-if="playlist.cover"
|
| 28 |
-
:src="playlist.cover"
|
| 29 |
-
:alt="playlist.name"
|
| 30 |
-
@error="handleImageError"
|
| 31 |
-
/>
|
| 32 |
-
<div v-else class="default-cover">
|
| 33 |
-
<i class="fas fa-music"></i>
|
| 34 |
-
</div>
|
| 35 |
-
<div class="play-overlay">
|
| 36 |
-
<button class="play-btn" @click.stop="playPlaylist(playlist)">
|
| 37 |
-
<i class="fas fa-play"></i>
|
| 38 |
-
</button>
|
| 39 |
-
</div>
|
| 40 |
-
</div>
|
| 41 |
-
<div class="playlist-info">
|
| 42 |
-
<h3 class="playlist-name">{{ playlist.name }}</h3>
|
| 43 |
-
<p class="playlist-count">{{ playlist.songs.length }}首歌曲</p>
|
| 44 |
-
<p class="playlist-updated">{{ formatDate(playlist.updatedAt) }}</p>
|
| 45 |
-
</div>
|
| 46 |
-
</div>
|
| 47 |
</div>
|
| 48 |
|
| 49 |
<!-- 空状态 -->
|
|
@@ -62,7 +42,7 @@
|
|
| 62 |
<div v-if="showCreatePlaylist" class="create-playlist-overlay" @click="closeCreatePlaylist">
|
| 63 |
<div class="create-playlist-dialog" @click.stop>
|
| 64 |
<div class="dialog-header">
|
| 65 |
-
<h3
|
| 66 |
<button class="close-btn" @click="closeCreatePlaylist">
|
| 67 |
<i class="fas fa-times"></i>
|
| 68 |
</button>
|
|
@@ -77,7 +57,11 @@
|
|
| 77 |
placeholder="请输入歌单名称"
|
| 78 |
maxlength="50"
|
| 79 |
ref="playlistNameInput"
|
|
|
|
| 80 |
/>
|
|
|
|
|
|
|
|
|
|
| 81 |
</div>
|
| 82 |
|
| 83 |
<div class="form-group">
|
|
@@ -93,10 +77,29 @@
|
|
| 93 |
|
| 94 |
<div class="dialog-footer">
|
| 95 |
<button class="btn btn-cancel" @click="closeCreatePlaylist">取消</button>
|
| 96 |
-
<button class="btn btn-create" @click="createNewPlaylist" :disabled="!newPlaylistName.trim()"
|
| 97 |
</div>
|
| 98 |
</div>
|
| 99 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</div>
|
| 101 |
</template>
|
| 102 |
|
|
@@ -108,6 +111,9 @@ import { usePlaylistStore } from '@/stores/playlist'
|
|
| 108 |
import { usePlayQueueStore } from '@/stores/playqueue'
|
| 109 |
import { useToastStore } from '@/stores/toast'
|
| 110 |
import { utils } from '@/services/musicApi'
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
const router = useRouter()
|
| 113 |
const playerStore = usePlayerStore()
|
|
@@ -120,6 +126,15 @@ const showCreatePlaylist = ref(false)
|
|
| 120 |
const newPlaylistName = ref('')
|
| 121 |
const newPlaylistDescription = ref('')
|
| 122 |
const playlistNameInput = ref(null)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
// 计算属性
|
| 125 |
const customPlaylists = computed(() => playlistStore.playlists || [])
|
|
@@ -145,6 +160,86 @@ const handleImageError = (event) => {
|
|
| 145 |
event.target.style.display = 'none'
|
| 146 |
}
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
// 新建歌单相关方法
|
| 149 |
const openCreatePlaylistDialog = () => {
|
| 150 |
showCreatePlaylist.value = true
|
|
@@ -159,24 +254,50 @@ const closeCreatePlaylist = () => {
|
|
| 159 |
showCreatePlaylist.value = false
|
| 160 |
newPlaylistName.value = ''
|
| 161 |
newPlaylistDescription.value = ''
|
|
|
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
const createNewPlaylist = () => {
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
try {
|
| 168 |
-
|
| 169 |
-
newPlaylistName.value.trim(),
|
| 170 |
-
newPlaylistDescription.value.trim()
|
| 171 |
-
)
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
} catch (error) {
|
| 178 |
-
|
| 179 |
-
|
| 180 |
}
|
| 181 |
}
|
| 182 |
|
|
@@ -266,113 +387,9 @@ onMounted(async () => {
|
|
| 266 |
min-height: 0;
|
| 267 |
}
|
| 268 |
|
| 269 |
-
/*
|
| 270 |
-
.playlists-
|
| 271 |
-
|
| 272 |
-
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
| 273 |
-
gap: 16px;
|
| 274 |
-
padding: 16px;
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
.playlist-card {
|
| 278 |
-
background: var(--bg-card);
|
| 279 |
-
border-radius: var(--radius-small);
|
| 280 |
-
overflow: hidden;
|
| 281 |
-
border: 1px solid var(--border-light);
|
| 282 |
-
cursor: pointer;
|
| 283 |
-
transition: var(--transition-fast);
|
| 284 |
-
}
|
| 285 |
-
|
| 286 |
-
.playlist-card:hover {
|
| 287 |
-
transform: translateY(-2px);
|
| 288 |
-
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
| 289 |
-
border-color: var(--accent-red);
|
| 290 |
-
}
|
| 291 |
-
|
| 292 |
-
.playlist-cover {
|
| 293 |
-
position: relative;
|
| 294 |
-
width: 100%;
|
| 295 |
-
height: 160px;
|
| 296 |
-
background: var(--bg-secondary);
|
| 297 |
-
overflow: hidden;
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
.playlist-cover img {
|
| 301 |
-
width: 100%;
|
| 302 |
-
height: 100%;
|
| 303 |
-
object-fit: cover;
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
.default-cover {
|
| 307 |
-
display: flex;
|
| 308 |
-
align-items: center;
|
| 309 |
-
justify-content: center;
|
| 310 |
-
width: 100%;
|
| 311 |
-
height: 100%;
|
| 312 |
-
background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
|
| 313 |
-
color: white;
|
| 314 |
-
font-size: 48px;
|
| 315 |
-
}
|
| 316 |
-
|
| 317 |
-
.play-overlay {
|
| 318 |
-
position: absolute;
|
| 319 |
-
top: 0;
|
| 320 |
-
left: 0;
|
| 321 |
-
right: 0;
|
| 322 |
-
bottom: 0;
|
| 323 |
-
background: rgba(0, 0, 0, 0.5);
|
| 324 |
-
display: flex;
|
| 325 |
-
align-items: center;
|
| 326 |
-
justify-content: center;
|
| 327 |
-
opacity: 0;
|
| 328 |
-
transition: var(--transition-fast);
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
.playlist-card:hover .play-overlay {
|
| 332 |
-
opacity: 1;
|
| 333 |
-
}
|
| 334 |
-
|
| 335 |
-
.play-btn {
|
| 336 |
-
width: 50px;
|
| 337 |
-
height: 50px;
|
| 338 |
-
border: none;
|
| 339 |
-
border-radius: 50%;
|
| 340 |
-
background: var(--accent-red);
|
| 341 |
-
color: white;
|
| 342 |
-
font-size: 18px;
|
| 343 |
-
cursor: pointer;
|
| 344 |
-
transition: var(--transition-fast);
|
| 345 |
-
}
|
| 346 |
-
|
| 347 |
-
.play-btn:hover {
|
| 348 |
-
background: var(--accent-red-hover);
|
| 349 |
-
transform: scale(1.1);
|
| 350 |
-
}
|
| 351 |
-
|
| 352 |
-
.playlist-info {
|
| 353 |
-
padding: 12px;
|
| 354 |
-
}
|
| 355 |
-
|
| 356 |
-
.playlist-name {
|
| 357 |
-
font-size: 14px;
|
| 358 |
-
font-weight: 600;
|
| 359 |
-
color: var(--text-primary);
|
| 360 |
-
margin: 0 0 4px;
|
| 361 |
-
overflow: hidden;
|
| 362 |
-
text-overflow: ellipsis;
|
| 363 |
-
white-space: nowrap;
|
| 364 |
-
}
|
| 365 |
-
|
| 366 |
-
.playlist-count {
|
| 367 |
-
font-size: 12px;
|
| 368 |
-
color: var(--text-secondary);
|
| 369 |
-
margin: 0 0 4px;
|
| 370 |
-
}
|
| 371 |
-
|
| 372 |
-
.playlist-updated {
|
| 373 |
-
font-size: 11px;
|
| 374 |
-
color: var(--text-tertiary);
|
| 375 |
-
margin: 0;
|
| 376 |
}
|
| 377 |
|
| 378 |
.empty-state {
|
|
@@ -528,6 +545,25 @@ onMounted(async () => {
|
|
| 528 |
color: var(--text-tertiary);
|
| 529 |
}
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
.dialog-footer {
|
| 532 |
display: flex;
|
| 533 |
align-items: center;
|
|
@@ -599,10 +635,8 @@ onMounted(async () => {
|
|
| 599 |
font-size: 20px;
|
| 600 |
}
|
| 601 |
|
| 602 |
-
.playlists-
|
| 603 |
-
padding: 12px;
|
| 604 |
-
gap: 12px;
|
| 605 |
-
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
| 606 |
}
|
| 607 |
|
| 608 |
.empty-state {
|
|
@@ -620,9 +654,8 @@ onMounted(async () => {
|
|
| 620 |
margin: 0 auto;
|
| 621 |
}
|
| 622 |
|
| 623 |
-
.playlists-
|
| 624 |
-
padding: 24px;
|
| 625 |
-
gap: 20px;
|
| 626 |
}
|
| 627 |
}
|
| 628 |
</style>
|
|
|
|
| 15 |
|
| 16 |
<!-- 我的歌单 -->
|
| 17 |
<div class="playlists-content">
|
| 18 |
+
<div v-if="customPlaylists.length > 0" class="playlists-list">
|
| 19 |
+
<PlaylistItem
|
| 20 |
+
v-for="(playlist, index) in customPlaylists"
|
| 21 |
:key="playlist.id"
|
| 22 |
+
:playlist="playlist"
|
| 23 |
+
:index="index"
|
| 24 |
+
@click="openPlaylist"
|
| 25 |
+
@showMoreActions="showMoreActionsPanel"
|
| 26 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
|
| 29 |
<!-- 空状态 -->
|
|
|
|
| 42 |
<div v-if="showCreatePlaylist" class="create-playlist-overlay" @click="closeCreatePlaylist">
|
| 43 |
<div class="create-playlist-dialog" @click.stop>
|
| 44 |
<div class="dialog-header">
|
| 45 |
+
<h3>{{ editingPlaylist ? '编辑歌单' : '新建歌单' }}</h3>
|
| 46 |
<button class="close-btn" @click="closeCreatePlaylist">
|
| 47 |
<i class="fas fa-times"></i>
|
| 48 |
</button>
|
|
|
|
| 57 |
placeholder="请输入歌单名称"
|
| 58 |
maxlength="50"
|
| 59 |
ref="playlistNameInput"
|
| 60 |
+
@input="createPlaylistError = ''"
|
| 61 |
/>
|
| 62 |
+
<div v-if="createPlaylistError" class="error-message">
|
| 63 |
+
{{ createPlaylistError }}
|
| 64 |
+
</div>
|
| 65 |
</div>
|
| 66 |
|
| 67 |
<div class="form-group">
|
|
|
|
| 77 |
|
| 78 |
<div class="dialog-footer">
|
| 79 |
<button class="btn btn-cancel" @click="closeCreatePlaylist">取消</button>
|
| 80 |
+
<button class="btn btn-create" @click="createNewPlaylist" :disabled="!newPlaylistName.trim()">{{ editingPlaylist ? '保存' : '创建' }}</button>
|
| 81 |
</div>
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
+
|
| 85 |
+
<!-- 统一操作菜单 -->
|
| 86 |
+
<ActionMenu
|
| 87 |
+
v-if="showMoreActions && selectedPlaylist"
|
| 88 |
+
type="playlist"
|
| 89 |
+
:playlist="selectedPlaylist"
|
| 90 |
+
@close="closeMoreActions"
|
| 91 |
+
@action="handlePlaylistAction"
|
| 92 |
+
/>
|
| 93 |
+
|
| 94 |
+
<!-- 确认对话框 -->
|
| 95 |
+
<ConfirmDialog
|
| 96 |
+
ref="confirmDialogRef"
|
| 97 |
+
:title="confirmTitle"
|
| 98 |
+
:message="confirmMessage"
|
| 99 |
+
:confirm-text="confirmButtonText"
|
| 100 |
+
type="danger"
|
| 101 |
+
@confirm="confirmAction"
|
| 102 |
+
/>
|
| 103 |
</div>
|
| 104 |
</template>
|
| 105 |
|
|
|
|
| 111 |
import { usePlayQueueStore } from '@/stores/playqueue'
|
| 112 |
import { useToastStore } from '@/stores/toast'
|
| 113 |
import { utils } from '@/services/musicApi'
|
| 114 |
+
import PlaylistItem from '@/components/common/PlaylistItem.vue'
|
| 115 |
+
import ActionMenu from '@/components/common/ActionMenu.vue'
|
| 116 |
+
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
| 117 |
|
| 118 |
const router = useRouter()
|
| 119 |
const playerStore = usePlayerStore()
|
|
|
|
| 126 |
const newPlaylistName = ref('')
|
| 127 |
const newPlaylistDescription = ref('')
|
| 128 |
const playlistNameInput = ref(null)
|
| 129 |
+
const createPlaylistError = ref('') // 添加错误信息状态
|
| 130 |
+
const showMoreActions = ref(false)
|
| 131 |
+
const selectedPlaylist = ref(null)
|
| 132 |
+
const editingPlaylist = ref(null)
|
| 133 |
+
const confirmDialogRef = ref(null)
|
| 134 |
+
const confirmTitle = ref('')
|
| 135 |
+
const confirmMessage = ref('')
|
| 136 |
+
const confirmButtonText = ref('确认')
|
| 137 |
+
const pendingAction = ref(null)
|
| 138 |
|
| 139 |
// 计算属性
|
| 140 |
const customPlaylists = computed(() => playlistStore.playlists || [])
|
|
|
|
| 160 |
event.target.style.display = 'none'
|
| 161 |
}
|
| 162 |
|
| 163 |
+
const showMoreActionsPanel = (playlist, event) => {
|
| 164 |
+
// 处理歌单更多操作,这里可以添加上下文菜单
|
| 165 |
+
selectedPlaylist.value = playlist
|
| 166 |
+
showMoreActions.value = true
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const handlePlaylistAction = ({ action, playlist }) => {
|
| 170 |
+
switch (action) {
|
| 171 |
+
case 'editInfo':
|
| 172 |
+
// 实现编辑歌单信息功能
|
| 173 |
+
if (playlist && !playlist.isDefault) {
|
| 174 |
+
// 打开编辑对话框并填入当前信息
|
| 175 |
+
newPlaylistName.value = playlist.name
|
| 176 |
+
newPlaylistDescription.value = playlist.description || ''
|
| 177 |
+
editingPlaylist.value = playlist
|
| 178 |
+
showCreatePlaylist.value = true
|
| 179 |
+
nextTick(() => {
|
| 180 |
+
if (playlistNameInput.value) {
|
| 181 |
+
playlistNameInput.value.focus()
|
| 182 |
+
}
|
| 183 |
+
})
|
| 184 |
+
} else {
|
| 185 |
+
toastStore.warning('默认歌单无法编辑')
|
| 186 |
+
}
|
| 187 |
+
break
|
| 188 |
+
|
| 189 |
+
case 'clearPlaylist':
|
| 190 |
+
// 实现清空歌单功能
|
| 191 |
+
if (playlist && !playlist.isDefault) {
|
| 192 |
+
confirmTitle.value = '清空歌单'
|
| 193 |
+
confirmMessage.value = `确定要清空歌单"${playlist.name}"中的所有歌曲吗?此操作不可撤销。`
|
| 194 |
+
confirmButtonText.value = '清空'
|
| 195 |
+
pendingAction.value = () => {
|
| 196 |
+
const success = playlistStore.clearPlaylist(playlist.id)
|
| 197 |
+
if (success) {
|
| 198 |
+
toastStore.success('歌单已清空')
|
| 199 |
+
} else {
|
| 200 |
+
toastStore.error('清空失败')
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
confirmDialogRef.value?.show()
|
| 204 |
+
} else {
|
| 205 |
+
toastStore.warning('默认歌单无法清空')
|
| 206 |
+
}
|
| 207 |
+
break
|
| 208 |
+
|
| 209 |
+
case 'deletePlaylist':
|
| 210 |
+
// 实现删除歌单功能
|
| 211 |
+
if (playlist && !playlist.isDefault) {
|
| 212 |
+
confirmTitle.value = '删除歌单'
|
| 213 |
+
confirmMessage.value = `确定要删除歌单"${playlist.name}"吗?此操作不可撤销。`
|
| 214 |
+
confirmButtonText.value = '删除'
|
| 215 |
+
pendingAction.value = () => {
|
| 216 |
+
const success = playlistStore.deletePlaylist(playlist.id)
|
| 217 |
+
if (success) {
|
| 218 |
+
toastStore.success('歌单已删除')
|
| 219 |
+
} else {
|
| 220 |
+
toastStore.error('删除失败')
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
confirmDialogRef.value?.show()
|
| 224 |
+
} else {
|
| 225 |
+
toastStore.warning('默认歌单无法删除')
|
| 226 |
+
}
|
| 227 |
+
break
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const closeMoreActions = () => {
|
| 232 |
+
showMoreActions.value = false
|
| 233 |
+
selectedPlaylist.value = null
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
const confirmAction = () => {
|
| 237 |
+
if (pendingAction.value) {
|
| 238 |
+
pendingAction.value()
|
| 239 |
+
pendingAction.value = null
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
// 新建歌单相关方法
|
| 244 |
const openCreatePlaylistDialog = () => {
|
| 245 |
showCreatePlaylist.value = true
|
|
|
|
| 254 |
showCreatePlaylist.value = false
|
| 255 |
newPlaylistName.value = ''
|
| 256 |
newPlaylistDescription.value = ''
|
| 257 |
+
createPlaylistError.value = '' // 清除错误信息
|
| 258 |
+
editingPlaylist.value = null // 重置编辑状态
|
| 259 |
}
|
| 260 |
|
| 261 |
const createNewPlaylist = () => {
|
| 262 |
+
const name = newPlaylistName.value.trim()
|
| 263 |
+
const description = newPlaylistDescription.value.trim()
|
| 264 |
+
|
| 265 |
+
// 清除之前的错误信息
|
| 266 |
+
createPlaylistError.value = ''
|
| 267 |
+
|
| 268 |
+
if (!name) {
|
| 269 |
+
createPlaylistError.value = '歌单名称不能为空'
|
| 270 |
+
return
|
| 271 |
+
}
|
| 272 |
|
| 273 |
try {
|
| 274 |
+
let success = false
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
+
if (editingPlaylist.value) {
|
| 277 |
+
// 编辑模式
|
| 278 |
+
success = playlistStore.updatePlaylist(editingPlaylist.value.id, {
|
| 279 |
+
name,
|
| 280 |
+
description: description || undefined
|
| 281 |
+
})
|
| 282 |
+
if (success) {
|
| 283 |
+
toastStore.success('歌单信息已更新')
|
| 284 |
+
closeCreatePlaylist()
|
| 285 |
+
} else {
|
| 286 |
+
createPlaylistError.value = '更新失败,请重试'
|
| 287 |
+
}
|
| 288 |
+
} else {
|
| 289 |
+
// 新建模式
|
| 290 |
+
const newPlaylist = playlistStore.createPlaylist(name, description)
|
| 291 |
+
if (newPlaylist) {
|
| 292 |
+
toastStore.success(`歌单 "${newPlaylist.name}" 创建成功!`)
|
| 293 |
+
closeCreatePlaylist()
|
| 294 |
+
} else {
|
| 295 |
+
createPlaylistError.value = '创建失败,请重试'
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
} catch (error) {
|
| 299 |
+
// 在输入框下方显示具体的错误信息
|
| 300 |
+
createPlaylistError.value = error.message || '操作失败,请重试'
|
| 301 |
}
|
| 302 |
}
|
| 303 |
|
|
|
|
| 387 |
min-height: 0;
|
| 388 |
}
|
| 389 |
|
| 390 |
+
/* 歌单列表样式 */
|
| 391 |
+
.playlists-list {
|
| 392 |
+
padding-bottom: 20px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
}
|
| 394 |
|
| 395 |
.empty-state {
|
|
|
|
| 545 |
color: var(--text-tertiary);
|
| 546 |
}
|
| 547 |
|
| 548 |
+
.error-message {
|
| 549 |
+
color: #ff4444;
|
| 550 |
+
font-size: 12px;
|
| 551 |
+
margin-top: 6px;
|
| 552 |
+
padding-left: 4px;
|
| 553 |
+
animation: slideDown 0.3s ease-out;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
@keyframes slideDown {
|
| 557 |
+
from {
|
| 558 |
+
opacity: 0;
|
| 559 |
+
transform: translateY(-4px);
|
| 560 |
+
}
|
| 561 |
+
to {
|
| 562 |
+
opacity: 1;
|
| 563 |
+
transform: translateY(0);
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
.dialog-footer {
|
| 568 |
display: flex;
|
| 569 |
align-items: center;
|
|
|
|
| 635 |
font-size: 20px;
|
| 636 |
}
|
| 637 |
|
| 638 |
+
.playlists-list {
|
| 639 |
+
padding-bottom: 12px;
|
|
|
|
|
|
|
| 640 |
}
|
| 641 |
|
| 642 |
.empty-state {
|
|
|
|
| 654 |
margin: 0 auto;
|
| 655 |
}
|
| 656 |
|
| 657 |
+
.playlists-list {
|
| 658 |
+
padding-bottom: 24px;
|
|
|
|
| 659 |
}
|
| 660 |
}
|
| 661 |
</style>
|
src/views/SettingsPage.vue
CHANGED
|
@@ -429,6 +429,19 @@
|
|
| 429 |
@cancel="confirmConfig.onCancel"
|
| 430 |
/>
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
<!-- 移动端选择器弹窗 -->
|
| 433 |
<div v-if="showSelector" class="mobile-selector-overlay" @click="closeSelector">
|
| 434 |
<div class="mobile-selector-content" @click.stop>
|
|
@@ -466,6 +479,7 @@ import { useSearchStore } from '@/stores/search'
|
|
| 466 |
import { useToastStore } from '@/stores/toast'
|
| 467 |
import { MUSIC_SOURCES, QUALITY_OPTIONS } from '@/services/musicApi'
|
| 468 |
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
|
|
| 469 |
|
| 470 |
const router = useRouter()
|
| 471 |
const settingsStore = useSettingsStore()
|
|
@@ -485,6 +499,7 @@ const selectorCurrentValue = ref('')
|
|
| 485 |
|
| 486 |
// 确认对话框相关
|
| 487 |
const confirmDialog = ref(null)
|
|
|
|
| 488 |
const confirmConfig = ref({
|
| 489 |
title: '确认操作',
|
| 490 |
message: '',
|
|
@@ -495,6 +510,17 @@ const confirmConfig = ref({
|
|
| 495 |
onCancel: () => {}
|
| 496 |
})
|
| 497 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
// 计算属性
|
| 499 |
const settings = computed(() => settingsStore.settings)
|
| 500 |
const appVersion = computed(() => '1.0.0')
|
|
@@ -603,99 +629,106 @@ const clearPlayHistory = async () => {
|
|
| 603 |
}
|
| 604 |
|
| 605 |
const clearFavorites = async () => {
|
| 606 |
-
|
| 607 |
title: '清除收藏数据',
|
| 608 |
-
message: '
|
| 609 |
type: 'danger',
|
| 610 |
-
confirmText: '
|
|
|
|
|
|
|
| 611 |
onConfirm: async () => {
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
toastStore.error('清除失败')
|
| 625 |
-
}
|
| 626 |
-
}
|
| 627 |
-
})
|
| 628 |
-
}
|
| 629 |
-
})
|
| 630 |
}
|
| 631 |
|
| 632 |
const clearAllData = async () => {
|
| 633 |
-
|
| 634 |
title: '清除全部数据',
|
| 635 |
-
message: '
|
| 636 |
type: 'danger',
|
| 637 |
-
confirmText: '
|
|
|
|
|
|
|
| 638 |
onConfirm: async () => {
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
await Promise.all([
|
| 677 |
-
searchStore.clearHistory(),
|
| 678 |
-
historyStore.clearHistory(),
|
| 679 |
-
favoritesStore.clearFavorites()
|
| 680 |
-
])
|
| 681 |
-
|
| 682 |
-
// 清除播放列表和播放列表
|
| 683 |
-
const { usePlaylistStore } = await import('@/stores/playlist')
|
| 684 |
-
const { usePlayQueueStore } = await import('@/stores/playqueue')
|
| 685 |
-
const playlistStore = usePlaylistStore()
|
| 686 |
-
const playQueueStore = usePlayQueueStore()
|
| 687 |
-
playlistStore.clearAllPlaylists()
|
| 688 |
-
playQueueStore.clearQueue()
|
| 689 |
-
|
| 690 |
-
toastStore.success('所有数据已清除')
|
| 691 |
-
} catch (error) {
|
| 692 |
-
console.error('清除数据失败:', error)
|
| 693 |
-
toastStore.error('清除失败')
|
| 694 |
}
|
| 695 |
-
}
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
}
|
| 700 |
|
| 701 |
// 响应式处理
|
|
|
|
| 429 |
@cancel="confirmConfig.onCancel"
|
| 430 |
/>
|
| 431 |
|
| 432 |
+
<!-- 延时确认对话框(用于危险操作) -->
|
| 433 |
+
<DelayedConfirmDialog
|
| 434 |
+
ref="delayedConfirmDialog"
|
| 435 |
+
:title="delayedConfirmConfig.title"
|
| 436 |
+
:message="delayedConfirmConfig.message"
|
| 437 |
+
:type="delayedConfirmConfig.type"
|
| 438 |
+
:confirm-text="delayedConfirmConfig.confirmText"
|
| 439 |
+
:cancel-text="delayedConfirmConfig.cancelText"
|
| 440 |
+
:delay-seconds="delayedConfirmConfig.delaySeconds"
|
| 441 |
+
@confirm="delayedConfirmConfig.onConfirm"
|
| 442 |
+
@cancel="delayedConfirmConfig.onCancel"
|
| 443 |
+
/>
|
| 444 |
+
|
| 445 |
<!-- 移动端选择器弹窗 -->
|
| 446 |
<div v-if="showSelector" class="mobile-selector-overlay" @click="closeSelector">
|
| 447 |
<div class="mobile-selector-content" @click.stop>
|
|
|
|
| 479 |
import { useToastStore } from '@/stores/toast'
|
| 480 |
import { MUSIC_SOURCES, QUALITY_OPTIONS } from '@/services/musicApi'
|
| 481 |
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
| 482 |
+
import DelayedConfirmDialog from '@/components/common/DelayedConfirmDialog.vue'
|
| 483 |
|
| 484 |
const router = useRouter()
|
| 485 |
const settingsStore = useSettingsStore()
|
|
|
|
| 499 |
|
| 500 |
// 确认对话框相关
|
| 501 |
const confirmDialog = ref(null)
|
| 502 |
+
const delayedConfirmDialog = ref(null)
|
| 503 |
const confirmConfig = ref({
|
| 504 |
title: '确认操作',
|
| 505 |
message: '',
|
|
|
|
| 510 |
onCancel: () => {}
|
| 511 |
})
|
| 512 |
|
| 513 |
+
const delayedConfirmConfig = ref({
|
| 514 |
+
title: '危险操作确认',
|
| 515 |
+
message: '',
|
| 516 |
+
type: 'danger',
|
| 517 |
+
confirmText: '确定清除',
|
| 518 |
+
cancelText: '取消',
|
| 519 |
+
delaySeconds: 5,
|
| 520 |
+
onConfirm: () => {},
|
| 521 |
+
onCancel: () => {}
|
| 522 |
+
})
|
| 523 |
+
|
| 524 |
// 计算属性
|
| 525 |
const settings = computed(() => settingsStore.settings)
|
| 526 |
const appVersion = computed(() => '1.0.0')
|
|
|
|
| 629 |
}
|
| 630 |
|
| 631 |
const clearFavorites = async () => {
|
| 632 |
+
delayedConfirmConfig.value = {
|
| 633 |
title: '清除收藏数据',
|
| 634 |
+
message: '确定要清除所有收藏歌曲吗?此操作不可撤销,将删除您收藏的所有音乐。',
|
| 635 |
type: 'danger',
|
| 636 |
+
confirmText: '确定清除',
|
| 637 |
+
cancelText: '取消',
|
| 638 |
+
delaySeconds: 5,
|
| 639 |
onConfirm: async () => {
|
| 640 |
+
try {
|
| 641 |
+
await favoritesStore.clearFavorites()
|
| 642 |
+
toastStore.success('收藏数据已清除')
|
| 643 |
+
} catch (error) {
|
| 644 |
+
console.error('清除收藏失败:', error)
|
| 645 |
+
toastStore.error('清除失败')
|
| 646 |
+
}
|
| 647 |
+
},
|
| 648 |
+
onCancel: () => {}
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
delayedConfirmDialog.value?.show()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
}
|
| 653 |
|
| 654 |
const clearAllData = async () => {
|
| 655 |
+
delayedConfirmConfig.value = {
|
| 656 |
title: '清除全部数据',
|
| 657 |
+
message: '⚠️ 极危险操作!这将清除所有应用数据:播放历史、收藏歌曲、歌单、播放队列、设置等。此操作不可撤销!',
|
| 658 |
type: 'danger',
|
| 659 |
+
confirmText: '确定清除所有数据',
|
| 660 |
+
cancelText: '取消',
|
| 661 |
+
delaySeconds: 5,
|
| 662 |
onConfirm: async () => {
|
| 663 |
+
try {
|
| 664 |
+
// 清除各种存储数据,使用实际的storage key
|
| 665 |
+
const keysToRemove = [
|
| 666 |
+
// 搜索相关
|
| 667 |
+
'vue-music-search-history',
|
| 668 |
+
'vue-music-search-settings',
|
| 669 |
+
|
| 670 |
+
// 播放历史
|
| 671 |
+
'vue-music-play-history',
|
| 672 |
+
|
| 673 |
+
// 收藏数据
|
| 674 |
+
'vue-music-my-favorites',
|
| 675 |
+
|
| 676 |
+
// 歌单数据
|
| 677 |
+
'vue-music-playlists',
|
| 678 |
+
|
| 679 |
+
// 播放队列
|
| 680 |
+
'vue-music-play-queue',
|
| 681 |
+
|
| 682 |
+
// 播放器状态
|
| 683 |
+
'vue-music-player-state',
|
| 684 |
+
|
| 685 |
+
// 设置数据
|
| 686 |
+
'music-settings',
|
| 687 |
+
|
| 688 |
+
// URL缓存
|
| 689 |
+
'music-url-cache-v1',
|
| 690 |
+
|
| 691 |
+
// 歌词字体大小
|
| 692 |
+
'lyrics-font-size'
|
| 693 |
+
]
|
| 694 |
+
|
| 695 |
+
// 清除以 img_ 开头的图片缓存
|
| 696 |
+
const allKeys = Object.keys(localStorage)
|
| 697 |
+
allKeys.forEach(key => {
|
| 698 |
+
if (key.startsWith('img_')) {
|
| 699 |
+
keysToRemove.push(key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
}
|
| 701 |
+
})
|
| 702 |
+
|
| 703 |
+
keysToRemove.forEach(key => {
|
| 704 |
+
localStorage.removeItem(key)
|
| 705 |
+
})
|
| 706 |
+
|
| 707 |
+
// 调用stores的清除方法
|
| 708 |
+
await Promise.all([
|
| 709 |
+
searchStore.clearHistory(),
|
| 710 |
+
historyStore.clearHistory(),
|
| 711 |
+
favoritesStore.clearFavorites()
|
| 712 |
+
])
|
| 713 |
+
|
| 714 |
+
// 清除播放列表和播放队列
|
| 715 |
+
const { usePlaylistStore } = await import('@/stores/playlist')
|
| 716 |
+
const { usePlayQueueStore } = await import('@/stores/playqueue')
|
| 717 |
+
const playlistStore = usePlaylistStore()
|
| 718 |
+
const playQueueStore = usePlayQueueStore()
|
| 719 |
+
playlistStore.clearAllPlaylists()
|
| 720 |
+
playQueueStore.clearQueue()
|
| 721 |
+
|
| 722 |
+
toastStore.success('所有数据已清除')
|
| 723 |
+
} catch (error) {
|
| 724 |
+
console.error('清除数据失败:', error)
|
| 725 |
+
toastStore.error('清除失败')
|
| 726 |
+
}
|
| 727 |
+
},
|
| 728 |
+
onCancel: () => {}
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
delayedConfirmDialog.value?.show()
|
| 732 |
}
|
| 733 |
|
| 734 |
// 响应式处理
|