ahutchen commited on
Commit
021ee94
·
1 Parent(s): 9e40388

feat(component): 新增歌单相关组件和功能

Browse files

- 新增 ActionMenu 组件,用于显示歌曲或歌单的操作菜单
- 新增 DelayedConfirmDialog 组件,用于显示带倒计时的确认对话框
- 新增 PlaylistItem 组件,用于显示歌单列表项
- 新增 SongCover 组件,用于显示歌曲封面
- 新增 FavoriteItem 组件,用于显示收藏列表项
- 优化 App 组件,加载播放队列状态

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
- open()
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 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0zMiAyMEw0MCAzMkgzNlY0NEgyOFYzMkgyNEwzMiAyMFoiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4zKSIvPgo8L3N2Zz4K'
34
  }
35
 
36
  // 加载专辑封面
37
  const loadCover = async () => {
38
  if (!props.song) {
39
- coverUrl.value = getDefaultCover()
 
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 = getDefaultCover()
49
  }
50
  } catch (error) {
51
  console.error('加载封面失败:', error)
52
- coverUrl.value = getDefaultCover()
53
  }
54
  }
55
 
56
  // 图片加载错误处理
57
- const handleImageError = () => {
58
- coverUrl.value = getDefaultCover()
 
59
  }
60
 
61
  // 监听歌曲变化
@@ -63,7 +73,7 @@ watch(() => props.song, (newSong) => {
63
  if (newSong) {
64
  loadCover()
65
  } else {
66
- coverUrl.value = getDefaultCover()
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('/default-album.png')
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
- loadCoverUrl()
 
 
144
  })
145
 
146
  // 是否是当前播放歌曲
@@ -214,7 +244,8 @@ const formatPlayCount = (count) => {
214
 
215
  // 处理图片加载错误
216
  const handleImageError = (event) => {
217
- event.target.src = '/default-album.png'
 
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="song-info">
6
  <img
7
- :src="song.cover || defaultCover"
 
8
  :alt="song.name"
9
- class="song-cover"
 
10
  />
 
 
 
11
 
12
- <div class="song-details">
13
- <div class="song-name">{{ song.name }}</div>
14
- <div class="song-artist">{{ song.artist }}</div>
15
- <div class="song-album" v-if="song.album">{{ song.album }}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  </div>
17
  </div>
18
 
19
  <!-- 操作按钮列表 -->
20
  <div class="actions-list">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  <button
 
 
22
  class="action-item"
23
- @click="handleAction('favorite')"
24
- :class="{ active: isFavorite }"
25
  >
26
- <i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
27
- <span>{{ isFavorite ? '取消收藏' : '添加到我的收藏' }}</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,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSIxMiIvPgo8cGF0aCBkPSJNNDAgMjhMNDYgNDBINDJWNTBIMzhWNDBIMzRMNDAgMjhaIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMykiLz4KPC9zdmc+Cg=='
 
 
 
 
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
- emit('action', action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  }
125
 
126
  // 关闭播放列表选择器
@@ -130,7 +268,7 @@ const closePlaylistSelector = () => {
130
 
131
  // 处理添加到歌单成功
132
  const handleAddedToPlaylist = (data) => {
133
- console.log('歌曲已添加到歌单:', data.message)
134
  // 关闭当前面板
135
  emit('close')
136
  }
@@ -160,7 +298,7 @@ const handleAddedToPlaylist = (data) => {
160
  overflow: hidden;
161
  }
162
 
163
- .song-info {
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
- .song-cover {
172
  width: 64px;
173
  height: 64px;
174
  border-radius: 12px;
@@ -176,12 +314,25 @@ const handleAddedToPlaylist = (data) => {
176
  flex-shrink: 0;
177
  }
178
 
179
- .song-details {
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  flex: 1;
181
  min-width: 0;
182
  }
183
 
184
- .song-name {
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
- .song-artist {
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
- .song-album {
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
- .song-info {
288
  padding: 20px 16px 16px;
289
  }
290
 
291
- .song-cover {
292
  width: 56px;
293
  height: 56px;
294
  border-radius: 10px;
295
  }
296
 
297
- .song-name {
298
  font-size: 16px;
299
  }
300
 
301
- .song-artist {
302
  font-size: 13px;
303
  }
304
 
305
- .song-album {
 
306
  font-size: 11px;
307
  }
308
 
@@ -336,11 +503,11 @@ const handleAddedToPlaylist = (data) => {
336
  padding: 20px;
337
  }
338
 
339
- .song-info {
340
  padding: 28px 24px 24px;
341
  }
342
 
343
- .song-cover {
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="{ 'disabled': playlist.isDefault }"
 
 
 
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" v-if="playlist.isDefault">
38
- <i class="fas fa-info-circle"></i>
 
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
- <div v-if="showCreateForm" class="create-form">
53
- <div class="form-group">
54
- <label>播放列表名称</label>
55
- <input
56
- type="text"
57
- v-model="newPlaylistName"
58
- placeholder="请输入播放列表名称"
59
- maxlength="50"
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, nextTick } from 'vue'
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 showCreateForm = ref(false)
96
- const newPlaylistName = ref('')
97
- const nameInput = ref(null)
98
 
99
- // 获取所有播放列表(排除默认播放列表,因为那只是当前播放列表)
100
  const playlists = computed(() => {
101
- return playlistStore.playlists.filter(p => !p.isDefault)
102
  })
103
 
 
 
 
 
 
 
104
  // 选择播放列表
105
- const selectPlaylist = async (playlist) => {
106
- if (playlist.isDefault) {
107
- // 默认播放列表(当前播放列表)不能添加歌曲
108
- return
109
  }
110
-
111
- try {
112
- const result = playlistStore.addSongToPlaylist(playlist.id, props.song)
113
-
114
- if (result.success) {
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 openCreatePlaylist = () => {
128
- showCreateForm.value = true
129
- nextTick(() => {
130
- if (nameInput.value) {
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: newPlaylist,
156
- song: props.song,
157
- message: `已添加到新播放列表"${newPlaylist.name}"`
158
  })
159
  handleClose()
160
  } else {
161
  toastStore.error(result.message)
162
  }
163
  } catch (error) {
164
- console.error('创建播放列表并添加歌曲失败:', error)
165
- toastStore.error('操作失败,请重试')
166
  }
167
  }
168
 
169
  // 关闭对话框
170
  const handleClose = () => {
171
- showCreateForm.value = false
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.5;
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: 16px;
326
- }
327
-
328
- .create-new {
329
- border-top: 1px solid var(--border-lighter);
330
- padding-top: 16px;
331
  }
332
 
333
- .create-new-btn {
334
- display: flex;
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
- .create-new-btn:hover {
349
- border-color: var(--accent-red);
350
  color: var(--accent-red);
351
- background: var(--overlay-lighter);
352
  }
353
 
354
- .create-form {
 
 
 
 
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
- .form-group input {
373
- width: 100%;
374
- padding: 12px 16px;
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
- .form-group input:focus {
386
- outline: none;
387
- border-color: var(--accent-red);
388
- background: rgba(255, 255, 255, 0.08);
389
  }
390
 
391
- .form-group input::placeholder {
 
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, defineAsyncComponent, createApp } from 'vue'
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
- return imageCacheManager.getDefaultImage()
 
46
  }
47
 
48
  /**
49
  * 处理图片加载错误
50
  */
51
  const handleImageError = (event) => {
52
- event.target.src = getDefaultCover()
 
 
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: name.trim(),
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 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDMwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIHJ4PSIxMiIgZmlsbD0iIzE4MTgxOCIvPjxwYXRoIGQ9Ik0xODcgMTUwQzE4NyAxNzEuNTM5IDE2OS41MzkgMTg5IDE0OCAxODlDMTI2LjQ2MSAxODkgMTA5IDE3MS41MzkgMTA5IDE1MEMxMDkgMTI4LjQ2MSAxMjYuNDYxIDExMSAxNDggMTExQzE2OS41MzkgMTExIDE4NyAxMjguNDYxIDE4NyAxNTBaIiBzdHJva2U9IiM2NjY2NjYiIHN0cm9rZS13aWR0aD0iNiIvPjxjaXJjbGUgY3g9IjE0OCIgY3k9IjE1MCIgcj0iNiIgZmlsbD0iIzY2NjY2NiIvPjwvc3ZnPgo='
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
- padding: 16px;
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 16px;
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 12px;
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
- playerStore.previous()
 
 
 
449
  }
450
 
451
  const handleNext = () => {
452
- playerStore.next()
 
 
 
453
  }
454
 
455
  const handleTogglePlayMode = () => {
456
- playerStore.togglePlayMode()
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('recent') // 'recent', 'grouped', 'top'
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
- if (diff < 60 * 1000) {
358
- return '刚刚播放'
359
- } else if (diff < 60 * 60 * 1000) {
360
- const minutes = Math.floor(diff / (60 * 1000))
361
- return `${minutes}分钟前`
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-blue);
603
  }
604
 
605
  .header-actions {
@@ -613,7 +587,7 @@ onMounted(() => {
613
  gap: 6px;
614
  padding: 8px 16px;
615
  border: none;
616
- background: var(--accent-blue);
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-blue-hover);
627
  transform: translateY(-1px);
628
  }
629
 
@@ -637,7 +611,7 @@ onMounted(() => {
637
 
638
  .stats-grid {
639
  display: grid;
640
- grid-template-columns: repeat(3, 1fr);
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-blue);
658
  margin-bottom: 4px;
659
  }
660
 
@@ -696,7 +670,7 @@ onMounted(() => {
696
  }
697
 
698
  .filter-tab.active {
699
- background: var(--accent-blue);
700
  color: white;
701
  }
702
 
@@ -780,7 +754,7 @@ onMounted(() => {
780
  gap: 8px;
781
  padding: 12px 24px;
782
  border: none;
783
- background: var(--accent-blue);
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-blue-hover);
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-gradient);
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="playlist-header">
5
- <button class="back-btn" @click="goBack">
6
- <i class="fas fa-arrow-left"></i>
7
- </button>
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
- <div class="playlist-actions">
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
- <div
71
  v-for="(song, index) in playlist.songs"
72
  :key="`${song.id}-${index}`"
73
- class="song-item"
74
- :class="{
75
- active: isCurrentSong(song),
76
- 'edit-mode': editMode
77
- }"
78
- >
79
- <!-- 拖拽手柄 -->
80
- <div v-if="editMode" class="drag-handle">
81
- <i class="fas fa-grip-vertical"></i>
82
- </div>
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
- const isCurrentSong = (song) => {
213
- return playerStore.currentSong?.id === song.id &&
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
- const success = playlistStore.removeSongFromPlaylist(playlist.value.id, index)
304
- if (success) {
305
- toastStore.success('歌曲已移除')
306
- } else {
307
- toastStore.error('移除失败')
 
 
 
 
 
308
  }
309
  }
310
  confirmDialogRef.value?.show()
@@ -312,13 +335,33 @@ const removeSong = (index) => {
312
 
313
  // 操作菜单
314
  const showMoreActions = () => {
315
- showActions.value = true
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
- toastStore.info('功能开发中...')
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  }
345
 
346
  const removeSongFromMenu = () => {
@@ -349,9 +404,40 @@ const removeSongFromMenu = () => {
349
  }
350
 
351
  const editPlaylistInfo = () => {
352
- // TODO: 实现编辑播放列表信息的功能
353
  hideActions()
354
- toastStore.info('编辑功能开发中...')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- .playlist-header {
 
417
  display: flex;
418
- align-items: flex-start;
419
- gap: 16px;
420
  padding: 16px;
421
- background: var(--bg-card);
422
- margin: 16px;
423
- border-radius: var(--radius-small);
424
- border: 1px solid var(--border-light);
 
 
 
 
 
 
 
 
 
 
 
 
425
  }
426
 
427
- .back-btn {
428
- width: 40px;
429
- height: 40px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  border: none;
431
  background: rgba(255, 255, 255, 0.1);
432
  color: var(--text-secondary);
433
  border-radius: 50%;
434
- font-size: 16px;
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
- .back-btn:hover {
444
- background: rgba(255, 255, 255, 0.2);
445
- color: var(--text-primary);
 
446
  }
447
 
448
- .playlist-cover {
449
- width: 80px;
450
- height: 80px;
451
- border-radius: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 32px;
471
  }
472
 
473
- .playlist-info {
 
 
 
 
 
 
 
 
 
474
  flex: 1;
475
- min-width: 0;
 
 
476
  }
477
 
478
- .playlist-name {
479
- font-size: 20px;
480
- font-weight: 700;
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 8px;
492
- line-height: 1.4;
 
 
 
 
 
 
 
493
  }
494
 
495
  .playlist-stats {
@@ -499,38 +640,105 @@ watch(() => route.params.id, () => {
499
  color: var(--text-tertiary);
500
  }
501
 
502
- .playlist-actions {
503
- flex-shrink: 0;
 
 
504
  }
505
 
506
- .action-btn {
507
- width: 40px;
508
- height: 40px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- .action-btn:hover {
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
- padding: 16px;
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 16px;
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-grid">
19
- <div
20
- v-for="playlist in customPlaylists"
21
  :key="playlist.id"
22
- class="playlist-card"
23
- @click="openPlaylist(playlist)"
24
- >
25
- <div class="playlist-cover">
26
- <img
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>新建歌单</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()">创建</button>
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
- if (!newPlaylistName.value.trim()) return
 
 
 
 
 
 
 
 
 
166
 
167
  try {
168
- const newPlaylist = playlistStore.createPlaylist(
169
- newPlaylistName.value.trim(),
170
- newPlaylistDescription.value.trim()
171
- )
172
 
173
- console.log('创建歌单成功:', newPlaylist)
174
- closeCreatePlaylist()
175
-
176
- toastStore.success(`歌单 "${newPlaylist.name}" 创建成功!`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  } catch (error) {
178
- console.error('创建歌单失败:', error)
179
- toastStore.error('创建歌单失败,请重试')
180
  }
181
  }
182
 
@@ -266,113 +387,9 @@ onMounted(async () => {
266
  min-height: 0;
267
  }
268
 
269
- /* 歌单样式 */
270
- .playlists-grid {
271
- display: grid;
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-grid {
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-grid {
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
- showConfirm({
607
  title: '清除收藏数据',
608
- message: '确定要清除所有收藏歌曲吗?此操作不可撤销!',
609
  type: 'danger',
610
- confirmText: '清除',
 
 
611
  onConfirm: async () => {
612
- // 二次确认
613
- showConfirm({
614
- title: '最后确认',
615
- message: '最后确认:真的要删除所有收藏吗?',
616
- type: 'danger',
617
- confirmText: '确定删除',
618
- onConfirm: async () => {
619
- try {
620
- await favoritesStore.clearFavorites()
621
- toastStore.success('收藏数据已清除')
622
- } catch (error) {
623
- console.error('清除收藏失败:', error)
624
- toastStore.error('清除失败')
625
- }
626
- }
627
- })
628
- }
629
- })
630
  }
631
 
632
  const clearAllData = async () => {
633
- showConfirm({
634
  title: '清除全部数据',
635
- message: '确定要清除所有数据吗?这将删除播放历史、收藏歌曲、播放列表等所有数据,此操作不可撤销!',
636
  type: 'danger',
637
- confirmText: '清除',
 
 
638
  onConfirm: async () => {
639
- // 二次确认
640
- showConfirm({
641
- title: '最后确认',
642
- message: '最后确认:真的要清除所有数据吗?',
643
- type: 'danger',
644
- confirmText: '确定清除',
645
- onConfirm: async () => {
646
- try {
647
- // 清除各种存储数据,使用统一的新存储key
648
- const keysToRemove = [
649
- // 搜索相关
650
- 'vue-music-search-history',
651
- 'vue-music-search-settings',
652
-
653
- // 播放历史 - 新的统一key
654
- 'vue-music-play-history',
655
- 'music-history', // 兼容旧key
656
-
657
- // 收藏数据 - 新的统一key
658
- 'vue-music-my-favorites',
659
- 'vue-music-favorites', // 兼容旧key
660
-
661
- // 播放列表 - 新的统一key
662
- 'vue-music-playlists',
663
-
664
- // 播放列表 - 新的统一key
665
- 'vue-music-play-queue',
666
-
667
- // 播放器状态
668
- 'vue-music-player-state'
669
- ]
670
-
671
- keysToRemove.forEach(key => {
672
- localStorage.removeItem(key)
673
- })
674
-
675
- // 调用stores的清除方法
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
  // 响应式处理