ahutchen commited on
Commit
8f2df2b
·
1 Parent(s): f897a53

docs(README): 更新功能特性说明

Browse files

- 调整了功能特性部分的格式
- 优化了核心功能的描述方式
- 保持了结尾的音乐编程口号

README.md CHANGED
@@ -12,7 +12,7 @@ app_file: dist/index.html
12
 
13
  一个基于 Vue3 的渐进式网络应用(PWA),模仿网易云音乐界面风格,支持13个音乐源,提供完整的音乐播放体验。
14
 
15
- ## ✨ 功能特性
16
 
17
  ### 🎯 核心功能
18
  - **多源音乐搜索** - 支持13个主流音乐平台
@@ -191,4 +191,4 @@ vue-music/
191
 
192
  ---
193
 
194
- **🎵 享受音乐,享受编程!**
 
12
 
13
  一个基于 Vue3 的渐进式网络应用(PWA),模仿网易云音乐界面风格,支持13个音乐源,提供完整的音乐播放体验。
14
 
15
+ ## ✨ 功能特性
16
 
17
  ### 🎯 核心功能
18
  - **多源音乐搜索** - 支持13个主流音乐平台
 
191
 
192
  ---
193
 
194
+ **🎵 享受音乐,享受编程!**
demo.html DELETED
@@ -1,1495 +0,0 @@
1
- <!DOCTYPE html>
2
- <!DOCTYPE html>
3
- <html lang="zh-CN">
4
- <head>
5
- <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>云音乐 - 在线音乐播放器</title>
8
- <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
- <style>
10
- * {
11
- margin: 0;
12
- padding: 0;
13
- box-sizing: border-box;
14
- }
15
-
16
- body {
17
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
18
- background: #0c0c0c;
19
- color: #fff;
20
- overflow-x: hidden;
21
- }
22
-
23
- /* 背景动画 */
24
- .bg-animation {
25
- position: fixed;
26
- top: 0;
27
- left: 0;
28
- width: 100%;
29
- height: 100%;
30
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
31
- background-size: 400% 400%;
32
- animation: gradientBG 15s ease infinite;
33
- z-index: -2;
34
- }
35
-
36
- .bg-overlay {
37
- position: fixed;
38
- top: 0;
39
- left: 0;
40
- width: 100%;
41
- height: 100%;
42
- background: rgba(0, 0, 0, 0.85);
43
- backdrop-filter: blur(20px);
44
- z-index: -1;
45
- }
46
-
47
- @keyframes gradientBG {
48
- 0% { background-position: 0% 50%; }
49
- 50% { background-position: 100% 50%; }
50
- 100% { background-position: 0% 50%; }
51
- }
52
-
53
- /* 顶部导航 */
54
- .navbar {
55
- background: rgba(255, 255, 255, 0.1);
56
- backdrop-filter: blur(20px);
57
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
58
- padding: 15px 0;
59
- position: sticky;
60
- top: 0;
61
- z-index: 100;
62
- }
63
-
64
- .nav-container {
65
- max-width: 1400px;
66
- margin: 0 auto;
67
- padding: 0 30px;
68
- display: flex;
69
- align-items: center;
70
- justify-content: space-between;
71
- }
72
-
73
- .logo {
74
- display: flex;
75
- align-items: center;
76
- gap: 12px;
77
- font-size: 24px;
78
- font-weight: bold;
79
- color: #fff;
80
- }
81
-
82
- .logo i {
83
- color: #ff6b6b;
84
- font-size: 28px;
85
- }
86
-
87
- .search-container {
88
- flex: 1;
89
- max-width: 600px;
90
- margin: 0 40px;
91
- position: relative;
92
- }
93
-
94
- .search-wrapper {
95
- display: flex;
96
- background: rgba(255, 255, 255, 0.15);
97
- border-radius: 25px;
98
- overflow: hidden;
99
- border: 1px solid rgba(255, 255, 255, 0.2);
100
- transition: all 0.3s ease;
101
- }
102
-
103
- .search-wrapper:focus-within {
104
- background: rgba(255, 255, 255, 0.2);
105
- border-color: #ff6b6b;
106
- box-shadow: 0 0 20px rgba(255, 107, 107, 0.3);
107
- }
108
-
109
- .search-input {
110
- flex: 1;
111
- padding: 12px 20px;
112
- background: transparent;
113
- border: none;
114
- color: #fff;
115
- font-size: 16px;
116
- outline: none;
117
- }
118
-
119
- .search-input::placeholder {
120
- color: rgba(255, 255, 255, 0.6);
121
- }
122
-
123
- .source-select {
124
- background: rgba(255, 255, 255, 0.1);
125
- border: none;
126
- color: #fff;
127
- padding: 12px 15px;
128
- outline: none;
129
- cursor: pointer;
130
- }
131
-
132
- .source-select option {
133
- background: #2a2a2a;
134
- color: #fff;
135
- padding: 8px;
136
- }
137
-
138
- .search-btn {
139
- background: #ff6b6b;
140
- border: none;
141
- color: #fff;
142
- padding: 12px 20px;
143
- cursor: pointer;
144
- transition: all 0.3s ease;
145
- }
146
-
147
- .search-btn:hover {
148
- background: #ff5252;
149
- }
150
-
151
- /* 主要内容区域 */
152
- .main-container {
153
- max-width: 1600px;
154
- margin: 0 auto;
155
- padding: 30px;
156
- display: grid;
157
- grid-template-columns: 600px 450px 350px;
158
- gap: 25px;
159
- min-height: calc(100vh - 200px);
160
- align-items: start;
161
- }
162
-
163
- /* 搜索结果区域 */
164
- .content-section {
165
- background: rgba(255, 255, 255, 0.05);
166
- border-radius: 20px;
167
- padding: 25px;
168
- backdrop-filter: blur(20px);
169
- border: 1px solid rgba(255, 255, 255, 0.1);
170
- min-width: 0;
171
- overflow: hidden;
172
- }
173
-
174
- .section-title {
175
- font-size: 20px;
176
- font-weight: 600;
177
- margin-bottom: 20px;
178
- color: #fff;
179
- display: flex;
180
- align-items: center;
181
- gap: 10px;
182
- }
183
-
184
- .search-results {
185
- max-height: 600px;
186
- overflow-y: auto;
187
- scrollbar-width: thin;
188
- scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
189
- }
190
-
191
- .search-results::-webkit-scrollbar {
192
- width: 6px;
193
- }
194
-
195
- .search-results::-webkit-scrollbar-track {
196
- background: transparent;
197
- }
198
-
199
- .search-results::-webkit-scrollbar-thumb {
200
- background: rgba(255, 255, 255, 0.3);
201
- border-radius: 3px;
202
- }
203
-
204
- .song-item {
205
- display: flex;
206
- align-items: center;
207
- padding: 15px 20px;
208
- border-radius: 12px;
209
- cursor: pointer;
210
- transition: all 0.3s ease;
211
- margin-bottom: 8px;
212
- position: relative;
213
- overflow: hidden;
214
- }
215
-
216
- .song-item::before {
217
- content: '';
218
- position: absolute;
219
- top: 0;
220
- left: -100%;
221
- width: 100%;
222
- height: 100%;
223
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
224
- transition: left 0.5s ease;
225
- }
226
-
227
- .song-item:hover {
228
- background: rgba(255, 255, 255, 0.1);
229
- transform: translateY(-2px);
230
- }
231
-
232
- .song-item:hover::before {
233
- left: 100%;
234
- }
235
-
236
- .song-item.active {
237
- background: linear-gradient(135deg, rgba(255, 107, 107, 0.3), rgba(255, 107, 107, 0.1));
238
- border: 1px solid rgba(255, 107, 107, 0.5);
239
- }
240
-
241
- .song-index {
242
- width: 40px;
243
- height: 40px;
244
- border-radius: 50%;
245
- background: rgba(255, 255, 255, 0.1);
246
- display: flex;
247
- align-items: center;
248
- justify-content: center;
249
- margin-right: 15px;
250
- font-size: 14px;
251
- font-weight: 600;
252
- color: rgba(255, 255, 255, 0.7);
253
- }
254
-
255
- .song-item.active .song-index {
256
- background: linear-gradient(135deg, #ff6b6b, #ff5252);
257
- color: #fff;
258
- }
259
-
260
- .song-info {
261
- flex: 1;
262
- min-width: 0;
263
- }
264
-
265
- .song-name {
266
- font-weight: 600;
267
- margin-bottom: 5px;
268
- font-size: 16px;
269
- white-space: nowrap;
270
- overflow: hidden;
271
- text-overflow: ellipsis;
272
- }
273
-
274
- .song-artist {
275
- color: rgba(255, 255, 255, 0.7);
276
- font-size: 14px;
277
- white-space: nowrap;
278
- overflow: hidden;
279
- text-overflow: ellipsis;
280
- }
281
-
282
- .song-duration {
283
- color: rgba(255, 255, 255, 0.5);
284
- font-size: 14px;
285
- margin-left: 15px;
286
- }
287
-
288
- /* 播放器区域 */
289
- .player-section {
290
- background: rgba(255, 255, 255, 0.05);
291
- border-radius: 20px;
292
- padding: 25px;
293
- backdrop-filter: blur(20px);
294
- border: 1px solid rgba(255, 255, 255, 0.1);
295
- position: sticky;
296
- top: 120px;
297
- height: fit-content;
298
- min-height: calc(100vh - 240px);
299
- display: flex;
300
- flex-direction: column;
301
- justify-content: space-between;
302
- }
303
-
304
- .current-song {
305
- text-align: center;
306
- margin-bottom: 25px;
307
- }
308
-
309
- .current-cover-container {
310
- position: relative;
311
- display: inline-block;
312
- margin-bottom: 20px;
313
- }
314
-
315
- .current-cover {
316
- width: 200px;
317
- height: 200px;
318
- border-radius: 50%;
319
- object-fit: cover;
320
- box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
321
- transition: all 0.3s ease;
322
- border: 6px solid rgba(255, 255, 255, 0.1);
323
- position: relative;
324
- }
325
-
326
- .current-cover::before {
327
- content: '';
328
- position: absolute;
329
- top: 50%;
330
- left: 50%;
331
- transform: translate(-50%, -50%);
332
- width: 40px;
333
- height: 40px;
334
- background: rgba(0, 0, 0, 0.3);
335
- border-radius: 50%;
336
- backdrop-filter: blur(10px);
337
- }
338
-
339
- .current-cover::after {
340
- content: '';
341
- position: absolute;
342
- top: 50%;
343
- left: 50%;
344
- transform: translate(-50%, -50%);
345
- width: 12px;
346
- height: 12px;
347
- background: rgba(255, 255, 255, 0.8);
348
- border-radius: 50%;
349
- }
350
-
351
- .current-cover.playing {
352
- animation: rotate 20s linear infinite;
353
- }
354
-
355
- @keyframes rotate {
356
- from { transform: rotate(0deg); }
357
- to { transform: rotate(360deg); }
358
- }
359
-
360
- .current-info h3 {
361
- font-size: 20px;
362
- font-weight: 600;
363
- margin-bottom: 8px;
364
- color: #fff;
365
- }
366
-
367
- .current-info p {
368
- color: rgba(255, 255, 255, 0.7);
369
- font-size: 16px;
370
- }
371
-
372
- /* 播放控制 */
373
- .player-controls {
374
- display: flex;
375
- justify-content: center;
376
- align-items: center;
377
- gap: 20px;
378
- margin-bottom: 25px;
379
- }
380
-
381
- .control-btn {
382
- background: rgba(255, 255, 255, 0.1);
383
- border: none;
384
- border-radius: 50%;
385
- color: #fff;
386
- cursor: pointer;
387
- transition: all 0.3s ease;
388
- display: flex;
389
- align-items: center;
390
- justify-content: center;
391
- }
392
-
393
- .control-btn:hover {
394
- background: rgba(255, 255, 255, 0.2);
395
- transform: scale(1.1);
396
- }
397
-
398
- .control-btn.small {
399
- width: 45px;
400
- height: 45px;
401
- font-size: 18px;
402
- }
403
-
404
- .play-btn {
405
- width: 65px;
406
- height: 65px;
407
- font-size: 28px;
408
- background: linear-gradient(135deg, #ff6b6b, #ff5252);
409
- box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
410
- }
411
-
412
- .play-btn:hover {
413
- background: linear-gradient(135deg, #ff5252, #ff4444);
414
- box-shadow: 0 12px 35px rgba(255, 107, 107, 0.6);
415
- }
416
-
417
- /* 进度条 */
418
- .progress-container {
419
- margin-bottom: 20px;
420
- }
421
-
422
- .progress-bar {
423
- width: 100%;
424
- height: 6px;
425
- background: rgba(255, 255, 255, 0.2);
426
- border-radius: 3px;
427
- cursor: pointer;
428
- margin-bottom: 10px;
429
- position: relative;
430
- overflow: hidden;
431
- }
432
-
433
- .progress-fill {
434
- height: 100%;
435
- background: linear-gradient(90deg, #ff6b6b, #ff8a80);
436
- border-radius: 3px;
437
- width: 0%;
438
- transition: width 0.1s ease;
439
- position: relative;
440
- }
441
-
442
- .progress-fill::after {
443
- content: '';
444
- position: absolute;
445
- right: -2px;
446
- top: 50%;
447
- transform: translateY(-50%);
448
- width: 12px;
449
- height: 12px;
450
- background: #fff;
451
- border-radius: 50%;
452
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
453
- }
454
-
455
- .time-info {
456
- display: flex;
457
- justify-content: space-between;
458
- font-size: 13px;
459
- color: rgba(255, 255, 255, 0.7);
460
- }
461
-
462
- /* 音量控制 */
463
- .volume-container {
464
- display: flex;
465
- align-items: center;
466
- gap: 12px;
467
- margin-bottom: 25px;
468
- }
469
-
470
- .volume-icon {
471
- color: rgba(255, 255, 255, 0.7);
472
- font-size: 18px;
473
- }
474
-
475
- .volume-slider {
476
- flex: 1;
477
- height: 4px;
478
- background: rgba(255, 255, 255, 0.2);
479
- border-radius: 2px;
480
- outline: none;
481
- cursor: pointer;
482
- -webkit-appearance: none;
483
- }
484
-
485
- .volume-slider::-webkit-slider-thumb {
486
- -webkit-appearance: none;
487
- width: 14px;
488
- height: 14px;
489
- background: #ff6b6b;
490
- border-radius: 50%;
491
- cursor: pointer;
492
- }
493
-
494
- /* 音质选择 */
495
- .quality-container {
496
- display: flex;
497
- align-items: center;
498
- justify-content: space-between;
499
- margin-bottom: 20px;
500
- padding: 12px 15px;
501
- background: rgba(255, 255, 255, 0.05);
502
- border-radius: 10px;
503
- border: 1px solid rgba(255, 255, 255, 0.1);
504
- }
505
-
506
- .quality-label {
507
- display: flex;
508
- align-items: center;
509
- gap: 8px;
510
- color: rgba(255, 255, 255, 0.8);
511
- font-size: 14px;
512
- }
513
-
514
- .quality-select {
515
- background: rgba(255, 255, 255, 0.1);
516
- border: 1px solid rgba(255, 255, 255, 0.2);
517
- border-radius: 8px;
518
- color: #fff;
519
- padding: 8px 12px;
520
- outline: none;
521
- cursor: pointer;
522
- font-size: 14px;
523
- }
524
-
525
- .quality-select option {
526
- background: #2a2a2a;
527
- color: #fff;
528
- padding: 8px;
529
- }
530
-
531
- /* 下载区域 */
532
- .download-container {
533
- display: grid;
534
- grid-template-columns: 1fr 1fr;
535
- gap: 10px;
536
- margin-bottom: 20px;
537
- }
538
-
539
- .download-btn {
540
- display: flex;
541
- align-items: center;
542
- justify-content: center;
543
- gap: 8px;
544
- padding: 12px 15px;
545
- background: rgba(255, 255, 255, 0.1);
546
- border: 1px solid rgba(255, 255, 255, 0.2);
547
- border-radius: 10px;
548
- color: #fff;
549
- cursor: pointer;
550
- transition: all 0.3s ease;
551
- font-size: 14px;
552
- }
553
-
554
- .download-btn:hover:not(:disabled) {
555
- background: rgba(255, 255, 255, 0.2);
556
- border-color: #ff6b6b;
557
- color: #ff6b6b;
558
- }
559
-
560
- .download-btn:disabled {
561
- opacity: 0.5;
562
- cursor: not-allowed;
563
- }
564
-
565
- /* 歌曲操作按钮 */
566
- .song-actions {
567
- display: flex;
568
- gap: 8px;
569
- margin-right: 15px;
570
- }
571
-
572
- .action-btn {
573
- width: 32px;
574
- height: 32px;
575
- border-radius: 50%;
576
- background: rgba(255, 255, 255, 0.1);
577
- border: none;
578
- color: rgba(255, 255, 255, 0.7);
579
- cursor: pointer;
580
- transition: all 0.3s ease;
581
- display: flex;
582
- align-items: center;
583
- justify-content: center;
584
- font-size: 12px;
585
- }
586
-
587
- .action-btn:hover {
588
- background: rgba(255, 107, 107, 0.3);
589
- color: #ff6b6b;
590
- transform: scale(1.1);
591
- }
592
-
593
- /* 歌词区域 */
594
- .lyrics-section {
595
- background: rgba(255, 255, 255, 0.05);
596
- border-radius: 20px;
597
- padding: 25px;
598
- backdrop-filter: blur(20px);
599
- border: 1px solid rgba(255, 255, 255, 0.1);
600
- position: sticky;
601
- top: 120px;
602
- height: calc(100vh - 240px);
603
- overflow: hidden;
604
- display: flex;
605
- flex-direction: column;
606
- }
607
-
608
- .lyrics-container {
609
- flex: 1;
610
- overflow-y: auto;
611
- scrollbar-width: thin;
612
- scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
613
- padding-right: 10px;
614
- }
615
-
616
- .lyrics-container::-webkit-scrollbar {
617
- width: 6px;
618
- }
619
-
620
- .lyrics-container::-webkit-scrollbar-track {
621
- background: transparent;
622
- }
623
-
624
- .lyrics-container::-webkit-scrollbar-thumb {
625
- background: rgba(255, 255, 255, 0.3);
626
- border-radius: 3px;
627
- }
628
-
629
- .lyric-line {
630
- padding: 8px 0;
631
- transition: all 0.3s ease;
632
- cursor: pointer;
633
- border-radius: 6px;
634
- padding-left: 10px;
635
- margin-bottom: 4px;
636
- color: rgba(255, 255, 255, 0.6);
637
- line-height: 1.6;
638
- }
639
-
640
- .lyric-line:hover {
641
- background: rgba(255, 255, 255, 0.05);
642
- color: rgba(255, 255, 255, 0.8);
643
- }
644
-
645
- .lyric-line.active {
646
- color: #ff6b6b;
647
- font-weight: 600;
648
- background: rgba(255, 107, 107, 0.1);
649
- transform: scale(1.02);
650
- border-left: 3px solid #ff6b6b;
651
- }
652
-
653
- /* 加载和错误状态 */
654
- .loading, .error, .empty-state {
655
- text-align: center;
656
- padding: 40px 20px;
657
- color: rgba(255, 255, 255, 0.7);
658
- }
659
-
660
- .loading i, .error i, .empty-state i {
661
- font-size: 48px;
662
- margin-bottom: 15px;
663
- display: block;
664
- }
665
-
666
- .loading i {
667
- animation: spin 1s linear infinite;
668
- color: #ff6b6b;
669
- }
670
-
671
- @keyframes spin {
672
- from { transform: rotate(0deg); }
673
- to { transform: rotate(360deg); }
674
- }
675
-
676
- .error i {
677
- color: #ff5252;
678
- }
679
-
680
- .empty-state i {
681
- color: rgba(255, 255, 255, 0.4);
682
- }
683
-
684
- /* 响应式设计 */
685
- @media (max-width: 1400px) {
686
- .main-container {
687
- grid-template-columns: 1fr 400px;
688
- gap: 20px;
689
- max-width: 1200px;
690
- }
691
-
692
- .lyrics-section {
693
- display: none;
694
- }
695
- }
696
-
697
- @media (max-width: 1024px) {
698
- .main-container {
699
- grid-template-columns: 1fr;
700
- gap: 20px;
701
- padding: 20px;
702
- }
703
-
704
- .nav-container {
705
- padding: 0 20px;
706
- }
707
-
708
- .search-container {
709
- margin: 0 20px;
710
- }
711
-
712
- .lyrics-section {
713
- display: block;
714
- position: static;
715
- max-height: 300px;
716
- }
717
-
718
- .player-section {
719
- position: static;
720
- min-height: auto;
721
- height: auto;
722
- }
723
- }
724
-
725
- @media (max-width: 768px) {
726
- .nav-container {
727
- flex-direction: column;
728
- gap: 15px;
729
- }
730
-
731
- .search-container {
732
- margin: 0;
733
- max-width: none;
734
- }
735
-
736
- .current-cover {
737
- width: 180px;
738
- height: 180px;
739
- }
740
-
741
- .player-controls {
742
- gap: 15px;
743
- }
744
- }
745
-
746
- /* 自定义滚动条样式 */
747
- ::-webkit-scrollbar {
748
- width: 8px;
749
- }
750
-
751
- ::-webkit-scrollbar-track {
752
- background: rgba(255, 255, 255, 0.1);
753
- border-radius: 4px;
754
- }
755
-
756
- ::-webkit-scrollbar-thumb {
757
- background: rgba(255, 255, 255, 0.3);
758
- border-radius: 4px;
759
- }
760
-
761
- ::-webkit-scrollbar-thumb:hover {
762
- background: rgba(255, 255, 255, 0.5);
763
- }
764
- </style>
765
- </head>
766
- <body>
767
- <div class="bg-animation"></div>
768
- <div class="bg-overlay"></div>
769
-
770
- <!-- 顶部导航 -->
771
- <nav class="navbar">
772
- <div class="nav-container">
773
- <div class="logo">
774
- <i class="fas fa-music"></i>
775
- <span>云音乐</span>
776
- </div>
777
-
778
- <div class="search-container">
779
- <div class="search-wrapper">
780
- <input type="text" class="search-input" placeholder="搜索音乐、歌手、专辑..." id="searchInput">
781
- <select class="source-select" id="sourceSelect">
782
- <option value="netease">网易云音乐</option>
783
- <option value="tencent">QQ音乐</option>
784
- <option value="kuwo">酷我音乐</option>
785
- <option value="joox">JOOX</option>
786
- <option value="kugou">酷狗音乐</option>
787
- <option value="migu">咪咕音乐</option>
788
- <option value="deezer">Deezer</option>
789
- <option value="spotify">Spotify</option>
790
- <option value="apple">Apple Music</option>
791
- <option value="ytmusic">YouTube Music</option>
792
- <option value="tidal">TIDAL</option>
793
- <option value="qobuz">Qobuz</option>
794
- <option value="ximalaya">喜马拉雅</option>
795
- </select>
796
- <button class="search-btn" onclick="searchMusic()">
797
- <i class="fas fa-search"></i>
798
- </button>
799
- </div>
800
- </div>
801
- </div>
802
- </nav>
803
-
804
- <!-- 主要内容 -->
805
- <div class="main-container">
806
- <!-- 搜索结果区域 -->
807
- <div class="content-section">
808
- <h2 class="section-title">
809
- <i class="fas fa-list-music"></i>
810
- 搜索结果
811
- </h2>
812
- <div class="search-results" id="searchResults">
813
- <div class="empty-state">
814
- <i class="fas fa-search"></i>
815
- <div>在上方搜索框输入关键词开始搜索音乐</div>
816
- </div>
817
- </div>
818
- </div>
819
-
820
- <!-- 播放器区域 -->
821
- <div class="player-section">
822
- <div class="current-song">
823
- <div class="current-cover-container">
824
- <img class="current-cover" id="currentCover" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjIwIiBoZWlnaHQ9IjIyMCIgdmlld0JveD0iMCAwIDIyMCAyMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMjAiIGhlaWdodD0iMjIwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSIyMCIvPgo8cGF0aCBkPSJNMTEwIDcwTDE0MCAx MTBIMTIwVjE1MEg5MFYxMTBINzBMMTEwIDcwWiIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjMpIi8+Cjwvc3ZnPgo=" alt="专辑封面">
825
- </div>
826
- <div class="current-info">
827
- <h3 id="currentTitle">未选择歌曲</h3>
828
- <p id="currentArtist">请搜索并选择要播放的歌曲</p>
829
- </div>
830
- </div>
831
-
832
- <div class="player-controls">
833
- <button class="control-btn small" onclick="previousSong()">
834
- <i class="fas fa-step-backward"></i>
835
- </button>
836
- <button class="control-btn play-btn" id="playBtn" onclick="togglePlay()">
837
- <i class="fas fa-play"></i>
838
- </button>
839
- <button class="control-btn small" onclick="nextSong()">
840
- <i class="fas fa-step-forward"></i>
841
- </button>
842
- </div>
843
-
844
- <div class="progress-container">
845
- <div class="progress-bar" onclick="seekTo(event)">
846
- <div class="progress-fill" id="progressFill"></div>
847
- </div>
848
- <div class="time-info">
849
- <span id="currentTime">0:00</span>
850
- <span id="totalTime">0:00</span>
851
- </div>
852
- </div>
853
-
854
- <!-- 音质选择 -->
855
- <div class="quality-container">
856
- <div class="quality-label">
857
- <i class="fas fa-music"></i>
858
- <span>音质</span>
859
- </div>
860
- <select class="quality-select" id="qualitySelect">
861
- <option value="128">标准 128K</option>
862
- <option value="192">较高 192K</option>
863
- <option value="320" selected>高品质 320K</option>
864
- <option value="740">无损 FLAC</option>
865
- <option value="999">Hi-Res</option>
866
- </select>
867
- </div>
868
-
869
- <div class="volume-container">
870
- <i class="fas fa-volume-up volume-icon"></i>
871
- <input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="80" onchange="setVolume(this.value)">
872
- </div>
873
-
874
- <!-- 下载区域 -->
875
- <div class="download-container">
876
- <button class="download-btn" onclick="downloadCurrentSong()" id="downloadSongBtn" disabled>
877
- <i class="fas fa-download"></i>
878
- <span>下载音乐</span>
879
- </button>
880
- <button class="download-btn" onclick="downloadCurrentLyric()" id="downloadLyricBtn" disabled>
881
- <i class="fas fa-file-text"></i>
882
- <span>下载歌词</span>
883
- </button>
884
- </div>
885
-
886
- <audio id="audioPlayer" preload="metadata"></audio>
887
- </div>
888
-
889
- <!-- 歌词区域 -->
890
- <div class="lyrics-section">
891
- <h2 class="section-title">
892
- <i class="fas fa-align-left"></i>
893
- 歌词
894
- </h2>
895
- <div class="lyrics-container" id="lyricsContainer">
896
- <div class="lyric-line">暂无歌词</div>
897
- </div>
898
- </div>
899
- </div>
900
-
901
- <script>
902
- const API_BASE = 'https://music-api.gdstudio.xyz/api.php';
903
- let currentPlaylist = [];
904
- let currentIndex = -1;
905
- let currentLyrics = [];
906
- let isPlaying = false;
907
-
908
- const audioPlayer = document.getElementById('audioPlayer');
909
- const playBtn = document.getElementById('playBtn');
910
- const progressFill = document.getElementById('progressFill');
911
- const currentTimeSpan = document.getElementById('currentTime');
912
- const totalTimeSpan = document.getElementById('totalTime');
913
- const lyricsContainer = document.getElementById('lyricsContainer');
914
- const currentCover = document.getElementById('currentCover');
915
-
916
- // 搜索音乐
917
- async function searchMusic() {
918
- const keyword = document.getElementById('searchInput').value.trim();
919
- const source = document.getElementById('sourceSelect').value;
920
-
921
- if (!keyword) {
922
- showNotification('请输入搜索关键词', 'warning');
923
- return;
924
- }
925
-
926
- const resultsContainer = document.getElementById('searchResults');
927
- resultsContainer.innerHTML = `
928
- <div class="loading">
929
- <i class="fas fa-spinner"></i>
930
- <div>正在搜索音乐...</div>
931
- </div>
932
- `;
933
-
934
- try {
935
- const response = await fetch(`${API_BASE}?types=search&source=${source}&name=${encodeURIComponent(keyword)}&count=30`);
936
- const data = await response.json();
937
-
938
- if (data && data.length > 0) {
939
- currentPlaylist = data;
940
- displaySearchResults(data);
941
- } else {
942
- resultsContainer.innerHTML = `
943
- <div class="error">
944
- <i class="fas fa-exclamation-triangle"></i>
945
- <div>未找到相关歌曲,请尝试其他关键词</div>
946
- </div>
947
- `;
948
- }
949
- } catch (error) {
950
- console.error('搜索失败:', error);
951
- resultsContainer.innerHTML = `
952
- <div class="error">
953
- <i class="fas fa-wifi"></i>
954
- <div>网络连接失败,请检查网络后重试</div>
955
- </div>
956
- `;
957
- }
958
- }
959
-
960
- // 获取专辑图片URL
961
- async function getAlbumCoverUrl(song, size = 300) {
962
- if (!song.pic_id) {
963
- return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjU1IiBoZWlnaHQ9IjU1IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0yNy41IDE4TDM1IDI3LjVIMzBWMzdIMjVWMjcuNUgyMEwyNy41IDE4WiIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjMpIi8+Cjwvc3ZnPgo=';
964
- }
965
-
966
- try {
967
- const response = await fetch(`${API_BASE}?types=pic&source=${song.source}&id=${song.pic_id}&size=${size}`);
968
- const data = await response.json();
969
-
970
- if (data && data.url) {
971
- return data.url;
972
- }
973
- } catch (error) {
974
- console.error('获取专辑图失败:', error);
975
- }
976
-
977
- return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNTUiIHZpZXdCb3g9IjAgMCA1NSA1NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjU1IiBoZWlnaHQ9IjU1IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0yNy41IDE4TDM1IDI3LjVIMzBWMzdIMjVWMjcuNUgyMEwyNy41IDE4WiIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjMpIi8+Cjwvc3ZnPgo=';
978
- }
979
-
980
- // 显示搜索结果
981
- async function displaySearchResults(songs) {
982
- const resultsContainer = document.getElementById('searchResults');
983
- resultsContainer.innerHTML = '';
984
-
985
- for (let index = 0; index < songs.length; index++) {
986
- const song = songs[index];
987
- const songItem = document.createElement('div');
988
- songItem.className = 'song-item';
989
- songItem.onclick = () => playSong(index);
990
-
991
- songItem.innerHTML = `
992
- <div class="song-index">${(index + 1).toString().padStart(2, '0')}</div>
993
- <div class="song-info">
994
- <div class="song-name">${song.name}</div>
995
- <div class="song-artist">${Array.isArray(song.artist) ? song.artist.join(' / ') : song.artist} · ${song.album}</div>
996
- </div>
997
- <div class="song-actions">
998
- <button class="action-btn" onclick="downloadSong(${index})" title="下载音乐">
999
- <i class="fas fa-download"></i>
1000
- </button>
1001
- <button class="action-btn" onclick="downloadLyric(${index})" title="下载歌词">
1002
- <i class="fas fa-file-text"></i>
1003
- </button>
1004
- </div>
1005
- <div class="song-duration">--:--</div>
1006
- `;
1007
-
1008
- resultsContainer.appendChild(songItem);
1009
- }
1010
- }
1011
-
1012
- // 播放歌曲
1013
- async function playSong(index) {
1014
- if (index < 0 || index >= currentPlaylist.length) return;
1015
-
1016
- currentIndex = index;
1017
- const song = currentPlaylist[index];
1018
-
1019
- // 更新UI
1020
- await updateCurrentSongInfo(song);
1021
- updateActiveItem();
1022
-
1023
- try {
1024
- showNotification('正在加载音乐...', 'info');
1025
-
1026
- // 获取当前选择的音质
1027
- const quality = document.getElementById('qualitySelect').value;
1028
-
1029
- // 获取音乐URL
1030
- const urlResponse = await fetch(`${API_BASE}?types=url&source=${song.source}&id=${song.id}&br=${quality}`);
1031
- const urlData = await urlResponse.json();
1032
-
1033
- if (urlData && urlData.url) {
1034
- audioPlayer.src = urlData.url;
1035
- audioPlayer.load();
1036
-
1037
- // 获取歌词
1038
- loadLyrics(song);
1039
-
1040
- // 启用下载按钮
1041
- document.getElementById('downloadSongBtn').disabled = false;
1042
- document.getElementById('downloadLyricBtn').disabled = false;
1043
-
1044
- // 自动播放
1045
- const playPromise = audioPlayer.play();
1046
- if (playPromise !== undefined) {
1047
- playPromise.then(() => {
1048
- isPlaying = true;
1049
- updatePlayButton();
1050
- currentCover.classList.add('playing');
1051
- showNotification(`开始播放 (${getQualityText(urlData.br || quality)})`, 'success');
1052
- }).catch(error => {
1053
- console.error('播放失败:', error);
1054
- showNotification('播放失败,请尝试其他歌曲', 'error');
1055
- });
1056
- }
1057
- } else {
1058
- showNotification('无法获取音乐链接,请尝试其他歌曲或更换音质', 'error');
1059
- }
1060
- } catch (error) {
1061
- console.error('播放失败:', error);
1062
- showNotification('播放失败,请检查网络连接', 'error');
1063
- }
1064
- }
1065
-
1066
- // 获取音质文本
1067
- function getQualityText(br) {
1068
- const qualityMap = {
1069
- '128': '标准音质',
1070
- '192': '较高音质',
1071
- '320': '高品质',
1072
- '740': '无损音质',
1073
- '999': 'Hi-Res音质'
1074
- };
1075
- return qualityMap[br] || `${br}K`;
1076
- }
1077
-
1078
- // 下载当前播放的歌曲
1079
- async function downloadCurrentSong() {
1080
- if (currentIndex === -1) {
1081
- showNotification('请先选择要下载的歌曲', 'warning');
1082
- return;
1083
- }
1084
-
1085
- const song = currentPlaylist[currentIndex];
1086
- await downloadSong(currentIndex);
1087
- }
1088
-
1089
- // 下载当前播放的歌词
1090
- async function downloadCurrentLyric() {
1091
- if (currentIndex === -1) {
1092
- showNotification('请先选择要下载歌词的��曲', 'warning');
1093
- return;
1094
- }
1095
-
1096
- await downloadLyric(currentIndex);
1097
- }
1098
-
1099
- // 下载歌曲
1100
- async function downloadSong(index) {
1101
- const song = currentPlaylist[index];
1102
- const quality = document.getElementById('qualitySelect').value;
1103
-
1104
- try {
1105
- showNotification('正在获取下载链接...', 'info');
1106
-
1107
- const response = await fetch(`${API_BASE}?types=url&source=${song.source}&id=${song.id}&br=${quality}`);
1108
- const data = await response.json();
1109
-
1110
- if (data && data.url) {
1111
- // 创建下载链接
1112
- const link = document.createElement('a');
1113
- link.href = data.url;
1114
- link.download = `${song.name} - ${Array.isArray(song.artist) ? song.artist.join(', ') : song.artist}.mp3`;
1115
- link.target = '_blank';
1116
-
1117
- // 触发下载
1118
- document.body.appendChild(link);
1119
- link.click();
1120
- document.body.removeChild(link);
1121
-
1122
- showNotification('开始下载音乐文件', 'success');
1123
- } else {
1124
- showNotification('无法获取下载链接', 'error');
1125
- }
1126
- } catch (error) {
1127
- console.error('下载失败:', error);
1128
- showNotification('下载失败,请稍后重试', 'error');
1129
- }
1130
- }
1131
-
1132
- // 下载歌词
1133
- async function downloadLyric(index) {
1134
- const song = currentPlaylist[index];
1135
-
1136
- try {
1137
- showNotification('正在获取歌词...', 'info');
1138
-
1139
- const response = await fetch(`${API_BASE}?types=lyric&source=${song.source}&id=${song.lyric_id || song.id}`);
1140
- const data = await response.json();
1141
-
1142
- if (data && data.lyric) {
1143
- // 创建歌词文件内容
1144
- let lyricContent = `歌曲:${song.name}
1145
- `;
1146
- lyricContent += `歌手:${Array.isArray(song.artist) ? song.artist.join(', ') : song.artist}
1147
- `;
1148
- lyricContent += `专辑:${song.album}
1149
- `;
1150
- lyricContent += `来源:${song.source}
1151
-
1152
- `;
1153
- lyricContent += data.lyric;
1154
-
1155
- if (data.tlyric) {
1156
- lyricContent += '=== 翻译歌词 ===';
1157
- lyricContent += data.tlyric;
1158
- }
1159
-
1160
- // 创建Blob并下载
1161
- const blob = new Blob([lyricContent], { type: 'text/plain;charset=utf-8' });
1162
- const url = URL.createObjectURL(blob);
1163
-
1164
- const link = document.createElement('a');
1165
- link.href = url;
1166
- link.download = `${song.name} - ${Array.isArray(song.artist) ? song.artist.join(', ') : song.artist}.lrc`;
1167
-
1168
- document.body.appendChild(link);
1169
- link.click();
1170
- document.body.removeChild(link);
1171
-
1172
- URL.revokeObjectURL(url);
1173
- showNotification('歌词下载完成', 'success');
1174
- } else {
1175
- showNotification('该歌曲暂无歌词', 'warning');
1176
- }
1177
- } catch (error) {
1178
- console.error('下载歌词失败:', error);
1179
- showNotification('下载歌词失败,请稍后重试', 'error');
1180
- }
1181
- }
1182
-
1183
- // 音质改变时重新加载当前歌曲
1184
- document.getElementById('qualitySelect').addEventListener('change', () => {
1185
- if (currentIndex !== -1 && audioPlayer.src) {
1186
- const currentTime = audioPlayer.currentTime;
1187
- const wasPlaying = isPlaying;
1188
-
1189
- playSong(currentIndex).then(() => {
1190
- // 恢复播放位置
1191
- audioPlayer.currentTime = currentTime;
1192
- if (!wasPlaying) {
1193
- audioPlayer.pause();
1194
- }
1195
- });
1196
- }
1197
- });
1198
-
1199
- // 更新当前歌曲信息
1200
- async function updateCurrentSongInfo(song) {
1201
- document.getElementById('currentTitle').textContent = song.name;
1202
- document.getElementById('currentArtist').textContent =
1203
- `${Array.isArray(song.artist) ? song.artist.join(' / ') : song.artist} · ${song.album}`;
1204
-
1205
- // 获取专辑图片URL
1206
- const coverUrl = await getAlbumCoverUrl(song, 500);
1207
- currentCover.src = coverUrl;
1208
- }
1209
-
1210
- // 更新活跃项目
1211
- function updateActiveItem() {
1212
- document.querySelectorAll('.song-item').forEach((item, index) => {
1213
- item.classList.toggle('active', index === currentIndex);
1214
- });
1215
- }
1216
-
1217
- // 更新播放按钮
1218
- function updatePlayButton() {
1219
- const icon = playBtn.querySelector('i');
1220
- if (isPlaying) {
1221
- icon.className = 'fas fa-pause';
1222
- } else {
1223
- icon.className = 'fas fa-play';
1224
- }
1225
- }
1226
-
1227
- // 加载歌词
1228
- async function loadLyrics(song) {
1229
- try {
1230
- const response = await fetch(`${API_BASE}?types=lyric&source=${song.source}&id=${song.lyric_id || song.id}`);
1231
- const data = await response.json();
1232
-
1233
- if (data && data.lyric) {
1234
- parseLyrics(data.lyric);
1235
- } else {
1236
- lyricsContainer.innerHTML = '<div class="lyric-line">暂无歌词</div>';
1237
- currentLyrics = [];
1238
- }
1239
- } catch (error) {
1240
- console.error('获取歌词失败:', error);
1241
- lyricsContainer.innerHTML = '<div class="lyric-line">歌词加载失败</div>';
1242
- currentLyrics = [];
1243
- }
1244
- }
1245
-
1246
- // 解析LRC歌词
1247
- function parseLyrics(lrcText) {
1248
- const lines = lrcText.split('\n');
1249
- currentLyrics = [];
1250
-
1251
- lines.forEach(line => {
1252
- const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/);
1253
- if (match) {
1254
- const minutes = parseInt(match[1]);
1255
- const seconds = parseInt(match[2]);
1256
- const milliseconds = parseInt(match[3].padEnd(3, '0'));
1257
- const text = match[4].trim();
1258
-
1259
- if (text) {
1260
- const time = minutes * 60 + seconds + milliseconds / 1000;
1261
- currentLyrics.push({ time, text });
1262
- }
1263
- }
1264
- });
1265
-
1266
- currentLyrics.sort((a, b) => a.time - b.time);
1267
- displayLyrics();
1268
- }
1269
-
1270
- // 显示歌词
1271
- function displayLyrics() {
1272
- lyricsContainer.innerHTML = '';
1273
- if (currentLyrics.length === 0) {
1274
- lyricsContainer.innerHTML = '<div class="lyric-line">暂无歌词</div>';
1275
- return;
1276
- }
1277
-
1278
- currentLyrics.forEach((lyric, index) => {
1279
- const lyricLine = document.createElement('div');
1280
- lyricLine.className = 'lyric-line';
1281
- lyricLine.textContent = lyric.text;
1282
- lyricLine.onclick = () => {
1283
- audioPlayer.currentTime = lyric.time;
1284
- };
1285
- lyricsContainer.appendChild(lyricLine);
1286
- });
1287
- }
1288
-
1289
- // 更新歌词高亮
1290
- function updateLyricHighlight() {
1291
- const currentTime = audioPlayer.currentTime;
1292
- let activeIndex = -1;
1293
-
1294
- for (let i = 0; i < currentLyrics.length; i++) {
1295
- if (currentLyrics[i].time <= currentTime) {
1296
- activeIndex = i;
1297
- } else {
1298
- break;
1299
- }
1300
- }
1301
-
1302
- const lyricLines = document.querySelectorAll('.lyric-line');
1303
- lyricLines.forEach((line, index) => {
1304
- line.classList.toggle('active', index === activeIndex);
1305
- });
1306
-
1307
- // 改进的自动滚动逻辑
1308
- if (activeIndex >= 0 && activeIndex < lyricLines.length) {
1309
- const activeLine = lyricLines[activeIndex];
1310
- const container = document.getElementById('lyricsContainer');
1311
-
1312
- if (activeLine && container) {
1313
- const containerHeight = container.clientHeight;
1314
- const lineHeight = activeLine.offsetHeight;
1315
- const lineOffsetTop = activeLine.offsetTop;
1316
- const currentScrollTop = container.scrollTop;
1317
-
1318
- // 计算理想的滚动位置(将当前歌词放在容器中间)
1319
- const idealScrollTop = lineOffsetTop - (containerHeight / 2) + (lineHeight / 2);
1320
-
1321
- // 只有当需要滚动超过一定距离时才滚动
1322
- const scrollThreshold = containerHeight * 0.3;
1323
- if (Math.abs(idealScrollTop - currentScrollTop) > scrollThreshold) {
1324
- container.scrollTo({
1325
- top: Math.max(0, idealScrollTop),
1326
- behavior: 'smooth'
1327
- });
1328
- }
1329
- }
1330
- }
1331
- }
1332
-
1333
- // 播放控制
1334
- function togglePlay() {
1335
- if (audioPlayer.src) {
1336
- if (isPlaying) {
1337
- audioPlayer.pause();
1338
- } else {
1339
- audioPlayer.play();
1340
- }
1341
- } else {
1342
- showNotification('请先选择要播放的歌曲', 'warning');
1343
- }
1344
- }
1345
-
1346
- function previousSong() {
1347
- if (currentIndex > 0) {
1348
- playSong(currentIndex - 1);
1349
- } else {
1350
- showNotification('已经是第一首歌曲', 'info');
1351
- }
1352
- }
1353
-
1354
- function nextSong() {
1355
- if (currentIndex < currentPlaylist.length - 1) {
1356
- playSong(currentIndex + 1);
1357
- } else {
1358
- showNotification('已经是最后一首歌曲', 'info');
1359
- }
1360
- }
1361
-
1362
- // 进度控制
1363
- function seekTo(event) {
1364
- if (audioPlayer.duration) {
1365
- const rect = event.target.getBoundingClientRect();
1366
- const percent = (event.clientX - rect.left) / rect.width;
1367
- audioPlayer.currentTime = percent * audioPlayer.duration;
1368
- }
1369
- }
1370
-
1371
- function setVolume(value) {
1372
- audioPlayer.volume = value / 100;
1373
-
1374
- // 更新音量图标
1375
- const volumeIcon = document.querySelector('.volume-icon');
1376
- if (value == 0) {
1377
- volumeIcon.className = 'fas fa-volume-mute volume-icon';
1378
- } else if (value < 50) {
1379
- volumeIcon.className = 'fas fa-volume-down volume-icon';
1380
- } else {
1381
- volumeIcon.className = 'fas fa-volume-up volume-icon';
1382
- }
1383
- }
1384
-
1385
- // 格式化时间
1386
- function formatTime(seconds) {
1387
- const mins = Math.floor(seconds / 60);
1388
- const secs = Math.floor(seconds % 60);
1389
- return `${mins}:${secs.toString().padStart(2, '0')}`;
1390
- }
1391
-
1392
- // 通知系统
1393
- function showNotification(message, type = 'info') {
1394
- // 创建通知元素
1395
- const notification = document.createElement('div');
1396
- notification.style.cssText = `
1397
- position: fixed;
1398
- top: 100px;
1399
- right: 30px;
1400
- background: ${type === 'success' ? 'rgba(76, 175, 80, 0.9)' :
1401
- type === 'error' ? 'rgba(244, 67, 54, 0.9)' :
1402
- type === 'warning' ? 'rgba(255, 152, 0, 0.9)' :
1403
- 'rgba(33, 150, 243, 0.9)'};
1404
- color: white;
1405
- padding: 15px 20px;
1406
- border-radius: 10px;
1407
- backdrop-filter: blur(10px);
1408
- box-shadow: 0 8px 25px rgba(0,0,0,0.3);
1409
- z-index: 1000;
1410
- transform: translateX(400px);
1411
- transition: transform 0.3s ease;
1412
- max-width: 300px;
1413
- font-size: 14px;
1414
- `;
1415
- notification.textContent = message;
1416
-
1417
- document.body.appendChild(notification);
1418
-
1419
- // 显示动画
1420
- setTimeout(() => {
1421
- notification.style.transform = 'translateX(0)';
1422
- }, 100);
1423
-
1424
- // 自动隐藏
1425
- setTimeout(() => {
1426
- notification.style.transform = 'translateX(400px)';
1427
- setTimeout(() => {
1428
- document.body.removeChild(notification);
1429
- }, 300);
1430
- }, 3000);
1431
- }
1432
-
1433
- // 音频事件监听
1434
- audioPlayer.addEventListener('timeupdate', () => {
1435
- if (audioPlayer.duration) {
1436
- const percent = (audioPlayer.currentTime / audioPlayer.duration) * 100;
1437
- progressFill.style.width = percent + '%';
1438
- currentTimeSpan.textContent = formatTime(audioPlayer.currentTime);
1439
- updateLyricHighlight();
1440
- }
1441
- });
1442
-
1443
- audioPlayer.addEventListener('loadedmetadata', () => {
1444
- totalTimeSpan.textContent = formatTime(audioPlayer.duration);
1445
- });
1446
-
1447
- audioPlayer.addEventListener('ended', () => {
1448
- nextSong();
1449
- });
1450
-
1451
- audioPlayer.addEventListener('play', () => {
1452
- isPlaying = true;
1453
- updatePlayButton();
1454
- currentCover.classList.add('playing');
1455
- });
1456
-
1457
- audioPlayer.addEventListener('pause', () => {
1458
- isPlaying = false;
1459
- updatePlayButton();
1460
- currentCover.classList.remove('playing');
1461
- });
1462
-
1463
- // 键盘快捷键
1464
- document.addEventListener('keydown', (e) => {
1465
- if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
1466
- e.preventDefault();
1467
- togglePlay();
1468
- } else if (e.code === 'ArrowLeft' && e.target.tagName !== 'INPUT') {
1469
- e.preventDefault();
1470
- previousSong();
1471
- } else if (e.code === 'ArrowRight' && e.target.tagName !== 'INPUT') {
1472
- e.preventDefault();
1473
- nextSong();
1474
- }
1475
- });
1476
-
1477
- // 搜索框回车事件
1478
- document.getElementById('searchInput').addEventListener('keypress', (e) => {
1479
- if (e.key === 'Enter') {
1480
- searchMusic();
1481
- }
1482
- });
1483
-
1484
- // 初始化
1485
- setVolume(80);
1486
-
1487
- // 页面加载完成后的欢迎信息
1488
- window.addEventListener('load', () => {
1489
- setTimeout(() => {
1490
- showNotification('欢迎使用云音乐播放器!', 'success');
1491
- }, 1000);
1492
- });
1493
- </script>
1494
- </body>
1495
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html CHANGED
@@ -19,9 +19,6 @@
19
  <!-- 主题色 -->
20
  <meta name="theme-color" content="#ff6b6b">
21
  <meta name="msapplication-TileColor" content="#ff6b6b">
22
-
23
- <!-- Font Awesome图标库 -->
24
- <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
25
  </head>
26
  <body>
27
  <div id="app"></div>
 
19
  <!-- 主题色 -->
20
  <meta name="theme-color" content="#ff6b6b">
21
  <meta name="msapplication-TileColor" content="#ff6b6b">
 
 
 
22
  </head>
23
  <body>
24
  <div id="app"></div>
package-lock.json CHANGED
@@ -8,6 +8,7 @@
8
  "name": "vue-music-pwa",
9
  "version": "1.0.0",
10
  "dependencies": {
 
11
  "@vueuse/core": "^10.5.0",
12
  "pinia": "^2.1.0",
13
  "vue": "^3.4.0",
@@ -1963,6 +1964,15 @@
1963
  "node": ">=12"
1964
  }
1965
  },
 
 
 
 
 
 
 
 
 
1966
  "node_modules/@jridgewell/gen-mapping": {
1967
  "version": "0.3.13",
1968
  "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
 
8
  "name": "vue-music-pwa",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "@fortawesome/fontawesome-free": "^7.0.1",
12
  "@vueuse/core": "^10.5.0",
13
  "pinia": "^2.1.0",
14
  "vue": "^3.4.0",
 
1964
  "node": ">=12"
1965
  }
1966
  },
1967
+ "node_modules/@fortawesome/fontawesome-free": {
1968
+ "version": "7.0.1",
1969
+ "resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.0.1.tgz",
1970
+ "integrity": "sha512-RLmb9U6H2rJDnGxEqXxzy7ANPrQz7WK2/eTjdZqyU9uRU5W+FkAec9uU5gTYzFBH7aoXIw2WTJSCJR4KPlReQw==",
1971
+ "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
1972
+ "engines": {
1973
+ "node": ">=6"
1974
+ }
1975
+ },
1976
  "node_modules/@jridgewell/gen-mapping": {
1977
  "version": "0.3.13",
1978
  "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
package.json CHANGED
@@ -8,10 +8,11 @@
8
  "preview": "vite preview"
9
  },
10
  "dependencies": {
11
- "vue": "^3.4.0",
12
- "vue-router": "^4.2.0",
13
  "pinia": "^2.1.0",
14
- "@vueuse/core": "^10.5.0"
 
15
  },
16
  "devDependencies": {
17
  "@vitejs/plugin-vue": "^4.5.0",
@@ -19,4 +20,4 @@
19
  "vite-plugin-pwa": "^0.17.0",
20
  "workbox-window": "^7.0.0"
21
  }
22
- }
 
8
  "preview": "vite preview"
9
  },
10
  "dependencies": {
11
+ "@fortawesome/fontawesome-free": "^7.0.1",
12
+ "@vueuse/core": "^10.5.0",
13
  "pinia": "^2.1.0",
14
+ "vue": "^3.4.0",
15
+ "vue-router": "^4.2.0"
16
  },
17
  "devDependencies": {
18
  "@vitejs/plugin-vue": "^4.5.0",
 
20
  "vite-plugin-pwa": "^0.17.0",
21
  "workbox-window": "^7.0.0"
22
  }
23
+ }
src/App.vue CHANGED
@@ -61,6 +61,7 @@ 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 { useSettingsStore } from '@/stores/settings'
65
  import { musicApi, utils } from '@/services/musicApi'
66
  import AppTabBar from '@/components/layout/AppTabBar.vue'
@@ -75,6 +76,7 @@ const route = useRoute()
75
  const playerStore = usePlayerStore()
76
  const searchStore = useSearchStore()
77
  const favoritesStore = useFavoritesStore()
 
78
  const settingsStore = useSettingsStore()
79
 
80
  // 响应式数据
@@ -202,7 +204,7 @@ const loadAndPlaySong = async (song) => {
202
  }
203
 
204
  // 添加到播放历史
205
- favoritesStore.addToHistory(song)
206
 
207
  console.log('歌曲加载成功:', song.name, result)
208
 
@@ -342,7 +344,7 @@ onMounted(() => {
342
  searchStore.loadSearchSettings()
343
  searchStore.loadSearchHistory()
344
  favoritesStore.loadFavorites()
345
- favoritesStore.loadPlayHistory()
346
  settingsStore.loadSettings()
347
 
348
  // 建立音频元素连接
 
61
  import { usePlayerStore } from '@/stores/player'
62
  import { useSearchStore } from '@/stores/search'
63
  import { useFavoritesStore } from '@/stores/favorites'
64
+ import { useHistoryStore } from '@/stores/history'
65
  import { useSettingsStore } from '@/stores/settings'
66
  import { musicApi, utils } from '@/services/musicApi'
67
  import AppTabBar from '@/components/layout/AppTabBar.vue'
 
76
  const playerStore = usePlayerStore()
77
  const searchStore = useSearchStore()
78
  const favoritesStore = useFavoritesStore()
79
+ const historyStore = useHistoryStore()
80
  const settingsStore = useSettingsStore()
81
 
82
  // 响应式数据
 
204
  }
205
 
206
  // 添加到播放历史
207
+ historyStore.addToHistory(song)
208
 
209
  console.log('歌曲加载成功:', song.name, result)
210
 
 
344
  searchStore.loadSearchSettings()
345
  searchStore.loadSearchHistory()
346
  favoritesStore.loadFavorites()
347
+ historyStore.loadHistory()
348
  settingsStore.loadSettings()
349
 
350
  // 建立音频元素连接
src/components/common/SongCover.vue ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
12
+
13
+ <script setup>
14
+ import { ref, watch, onMounted } from 'vue'
15
+ import { usePlayerStore } from '@/stores/player'
16
+
17
+ const props = defineProps({
18
+ song: {
19
+ type: Object,
20
+ required: true
21
+ },
22
+ size: {
23
+ type: Number,
24
+ default: 300
25
+ }
26
+ })
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
+ // 监听歌曲变化
62
+ watch(() => props.song, (newSong) => {
63
+ if (newSong) {
64
+ loadCover()
65
+ } else {
66
+ coverUrl.value = getDefaultCover()
67
+ }
68
+ }, { immediate: true })
69
+
70
+ onMounted(() => {
71
+ if (props.song) {
72
+ loadCover()
73
+ }
74
+ })
75
+ </script>
76
+
77
+ <style scoped>
78
+ .song-cover {
79
+ position: relative;
80
+ overflow: hidden;
81
+ border-radius: 8px;
82
+ background: var(--bg-secondary);
83
+ }
84
+
85
+ .cover-image {
86
+ width: 100%;
87
+ height: 100%;
88
+ object-fit: cover;
89
+ transition: var(--transition-normal);
90
+ }
91
+ </style>
src/components/favorites/FavoriteButton.vue CHANGED
@@ -59,7 +59,7 @@ const loading = ref(false)
59
 
60
  // 是否已收藏
61
  const isFavorited = computed(() => {
62
- return favoritesStore.isFavorite(props.song.id)
63
  })
64
 
65
  // 图标类名
@@ -78,10 +78,10 @@ const handleToggle = async () => {
78
 
79
  try {
80
  if (isFavorited.value) {
81
- await favoritesStore.removeFavorite(props.song.id)
82
  emit('unfavorited', props.song)
83
  } else {
84
- await favoritesStore.addFavorite(props.song)
85
  emit('favorited', props.song)
86
  }
87
 
 
59
 
60
  // 是否已收藏
61
  const isFavorited = computed(() => {
62
+ return favoritesStore.isFavorite(props.song)
63
  })
64
 
65
  // 图标类名
 
78
 
79
  try {
80
  if (isFavorited.value) {
81
+ await favoritesStore.removeFromFavorites(props.song)
82
  emit('unfavorited', props.song)
83
  } else {
84
+ await favoritesStore.addToFavorites(props.song)
85
  emit('favorited', props.song)
86
  }
87
 
src/components/favorites/FavoriteItem.vue CHANGED
@@ -60,10 +60,18 @@
60
  <!-- 更多操作菜单 -->
61
  <div class="more-menu" v-if="showMenu" @click.stop>
62
  <button @click="handlePlayNext">下一首播放</button>
63
- <button @click="handleAddToPlaylist">添加到播放列表</button>
64
  <button @click="handleCopyLink">复制链接</button>
65
  <button @click="handleViewDetails">查看详情</button>
66
  </div>
 
 
 
 
 
 
 
 
67
  </div>
68
  </template>
69
 
@@ -72,6 +80,7 @@ import { ref, computed } from 'vue'
72
  import { usePlayerStore } from '@/stores/player'
73
  import { useHistoryStore } from '@/stores/history'
74
  import FavoriteButton from './FavoriteButton.vue'
 
75
 
76
  const props = defineProps({
77
  // 歌曲信息
@@ -111,6 +120,7 @@ const emit = defineEmits(['play', 'unfavorited', 'more-action'])
111
  const playerStore = usePlayerStore()
112
  const historyStore = useHistoryStore()
113
  const showMenu = ref(false)
 
114
 
115
  // 专辑封面URL
116
  const albumCoverUrl = computed(() => {
@@ -224,9 +234,20 @@ const handlePlayNext = () => {
224
 
225
  // 添加到播放列表
226
  const handleAddToPlaylist = () => {
227
- playerStore.addToPlaylist(props.song)
228
  showMenu.value = false
229
- emit('more-action', { action: 'add-to-playlist', song: props.song })
 
 
 
 
 
 
 
 
 
 
 
 
230
  }
231
 
232
  // 复制链接
 
60
  <!-- 更多操作菜单 -->
61
  <div class="more-menu" v-if="showMenu" @click.stop>
62
  <button @click="handlePlayNext">下一首播放</button>
63
+ <button @click="handleAddToPlaylist">添加到歌单</button>
64
  <button @click="handleCopyLink">复制链接</button>
65
  <button @click="handleViewDetails">查看详情</button>
66
  </div>
67
+
68
+ <!-- 播放列表选择对话框 -->
69
+ <PlaylistSelector
70
+ :show="showPlaylistSelector"
71
+ :song="song"
72
+ @close="closePlaylistSelector"
73
+ @added="handleAddedToPlaylist"
74
+ />
75
  </div>
76
  </template>
77
 
 
80
  import { usePlayerStore } from '@/stores/player'
81
  import { useHistoryStore } from '@/stores/history'
82
  import FavoriteButton from './FavoriteButton.vue'
83
+ import PlaylistSelector from '@/components/playlist/PlaylistSelector.vue'
84
 
85
  const props = defineProps({
86
  // 歌曲信息
 
120
  const playerStore = usePlayerStore()
121
  const historyStore = useHistoryStore()
122
  const showMenu = ref(false)
123
+ const showPlaylistSelector = ref(false)
124
 
125
  // 专辑封面URL
126
  const albumCoverUrl = computed(() => {
 
234
 
235
  // 添加到播放列表
236
  const handleAddToPlaylist = () => {
 
237
  showMenu.value = false
238
+ showPlaylistSelector.value = true
239
+ }
240
+
241
+ // 关闭播放列表选择器
242
+ const closePlaylistSelector = () => {
243
+ showPlaylistSelector.value = false
244
+ }
245
+
246
+ // 处理添加到播放列表成功
247
+ const handleAddedToPlaylist = (data) => {
248
+ console.log('歌曲已添加到播放列表:', data.message)
249
+ // 可以显示提示信息
250
+ emit('more-action', { action: 'added-to-playlist', song: props.song, data })
251
  }
252
 
253
  // 复制链接
src/components/favorites/FavoritesList.vue CHANGED
@@ -88,7 +88,7 @@
88
  </div>
89
  <div class="batch-buttons">
90
  <button @click="batchPlay">播放选中</button>
91
- <button @click="batchAddToPlaylist">添加到播放列表</button>
92
  <button @click="batchRemove" class="danger">删除选中</button>
93
  </div>
94
  </div>
@@ -150,8 +150,9 @@
150
  <div class="song-items">
151
  <div
152
  v-for="(item, index) in paginatedList"
153
- :key="item.song.id"
154
  class="song-item-wrapper"
 
155
  >
156
  <!-- 批量选择复选框 -->
157
  <label v-if="batchMode" class="item-checkbox">
@@ -186,6 +187,16 @@
186
  </div>
187
  </div>
188
  </div>
 
 
 
 
 
 
 
 
 
 
189
  </div>
190
  </template>
191
 
@@ -197,6 +208,7 @@ import { useHistoryStore } from '@/stores/history'
197
  import FavoriteItem from './FavoriteItem.vue'
198
  import Loading from '@/components/common/Loading.vue'
199
  import Empty from '@/components/common/Empty.vue'
 
200
 
201
  const props = defineProps({
202
  // 是否显示搜索
@@ -234,6 +246,7 @@ const sortOrder = ref('desc')
234
  const batchMode = ref(false)
235
  const selectedItems = ref([])
236
  const displayCount = ref(props.initialCount)
 
237
 
238
  // 计算属性
239
  const favoritesList = computed(() => {
@@ -419,21 +432,25 @@ const batchAddToPlaylist = () => {
419
  .filter(item => selectedItems.value.includes(item.song.id))
420
  .map(item => item.song)
421
 
422
- songs.forEach(song => {
423
- playerStore.addToPlaylist(song)
424
- })
425
-
426
- emit('batch-action', { action: 'add-to-playlist', songs })
427
  }
428
 
429
- const batchRemove = async () => {
430
- if (confirm(`确定要删除选中的 ${selectedItems.value.length} 首歌曲吗?`)) {
431
- for (const songId of selectedItems.value) {
432
- await favoritesStore.removeFavorite(songId)
 
 
 
 
 
 
433
  }
434
- selectedItems.value = []
435
- emit('batch-action', { action: 'remove', count: selectedItems.value.length })
436
  }
 
 
437
  }
438
 
439
  const handlePlay = (song) => {
 
88
  </div>
89
  <div class="batch-buttons">
90
  <button @click="batchPlay">播放选中</button>
91
+ <button @click="batchAddToPlaylist">添加到歌单</button>
92
  <button @click="batchRemove" class="danger">删除选中</button>
93
  </div>
94
  </div>
 
150
  <div class="song-items">
151
  <div
152
  v-for="(item, index) in paginatedList"
153
+ :key="item?.song?.id || `item-${index}`"
154
  class="song-item-wrapper"
155
+ v-if="item && item.song"
156
  >
157
  <!-- 批量选择复选框 -->
158
  <label v-if="batchMode" class="item-checkbox">
 
187
  </div>
188
  </div>
189
  </div>
190
+
191
+ <!-- 确认对话框 -->
192
+ <ConfirmDialog
193
+ ref="confirmDialogRef"
194
+ title="批量删除收藏"
195
+ :message="`确定要删除选中的 ${selectedItems.length} 首歌曲吗?`"
196
+ confirm-text="删除"
197
+ type="danger"
198
+ @confirm="confirmBatchRemove"
199
+ />
200
  </div>
201
  </template>
202
 
 
208
  import FavoriteItem from './FavoriteItem.vue'
209
  import Loading from '@/components/common/Loading.vue'
210
  import Empty from '@/components/common/Empty.vue'
211
+ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
212
 
213
  const props = defineProps({
214
  // 是否显示搜索
 
246
  const batchMode = ref(false)
247
  const selectedItems = ref([])
248
  const displayCount = ref(props.initialCount)
249
+ const confirmDialogRef = ref(null)
250
 
251
  // 计算属性
252
  const favoritesList = computed(() => {
 
432
  .filter(item => selectedItems.value.includes(item.song.id))
433
  .map(item => item.song)
434
 
435
+ if (songs.length > 0) {
436
+ emit('batch-action', { action: 'add-to-playlist', songs })
437
+ }
 
 
438
  }
439
 
440
+ const batchRemove = () => {
441
+ confirmDialogRef.value?.show()
442
+ }
443
+
444
+ const confirmBatchRemove = async () => {
445
+ const removedCount = selectedItems.value.length
446
+ for (const songId of selectedItems.value) {
447
+ const song = filteredList.value.find(item => item.song.id === songId)?.song
448
+ if (song) {
449
+ await favoritesStore.removeFromFavorites(song)
450
  }
 
 
451
  }
452
+ selectedItems.value = []
453
+ emit('batch-action', { action: 'remove', count: removedCount })
454
  }
455
 
456
  const handlePlay = (song) => {
src/components/layout/AppTabBar.vue CHANGED
@@ -33,16 +33,22 @@ const tabs = [
33
  icon: 'fas fa-home'
34
  },
35
  {
36
- name: 'MyMusic',
37
- path: '/my-music',
38
- label: '我的音乐',
39
  icon: 'fas fa-heart'
40
  },
41
  {
42
- name: 'Settings',
43
- path: '/settings',
44
- label: '设置',
45
- icon: 'fas fa-cog'
 
 
 
 
 
 
46
  }
47
  ]
48
 
 
33
  icon: 'fas fa-home'
34
  },
35
  {
36
+ name: 'Favorites',
37
+ path: '/favorites',
38
+ label: '我喜欢',
39
  icon: 'fas fa-heart'
40
  },
41
  {
42
+ name: 'Playlists',
43
+ path: '/playlists',
44
+ label: '歌单',
45
+ icon: 'fas fa-music'
46
+ },
47
+ {
48
+ name: 'PlayQueue',
49
+ path: '/play-queue',
50
+ label: '播放列表',
51
+ icon: 'fas fa-list'
52
  }
53
  ]
54
 
src/components/player/MoreActionsPanel.vue CHANGED
@@ -43,12 +43,21 @@
43
  取消
44
  </button>
45
  </div>
 
 
 
 
 
 
 
 
46
  </div>
47
  </template>
48
 
49
  <script setup>
50
- import { computed } from 'vue'
51
  import { useFavoritesStore } from '@/stores/favorites'
 
52
 
53
  const props = defineProps({
54
  song: {
@@ -60,6 +69,7 @@ const props = defineProps({
60
  const emit = defineEmits(['close', 'action'])
61
 
62
  const favoritesStore = useFavoritesStore()
 
63
 
64
  // 计算属性
65
  const isFavorite = computed(() => {
@@ -88,7 +98,8 @@ const handleAction = async (action) => {
88
  break
89
 
90
  case 'addToPlaylist':
91
- // 实现添加到播放列表
 
92
  break
93
 
94
  case 'download':
@@ -111,6 +122,18 @@ const handleAction = async (action) => {
111
 
112
  emit('action', action)
113
  }
 
 
 
 
 
 
 
 
 
 
 
 
114
  </script>
115
 
116
  <style scoped>
 
43
  取消
44
  </button>
45
  </div>
46
+
47
+ <!-- 播放列表选择对话框 -->
48
+ <PlaylistSelector
49
+ :show="showPlaylistSelector"
50
+ :song="song"
51
+ @close="closePlaylistSelector"
52
+ @added="handleAddedToPlaylist"
53
+ />
54
  </div>
55
  </template>
56
 
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: {
 
69
  const emit = defineEmits(['close', 'action'])
70
 
71
  const favoritesStore = useFavoritesStore()
72
+ const showPlaylistSelector = ref(false)
73
 
74
  // 计算属性
75
  const isFavorite = computed(() => {
 
98
  break
99
 
100
  case 'addToPlaylist':
101
+ // 打开播放列表选择器
102
+ showPlaylistSelector.value = true
103
  break
104
 
105
  case 'download':
 
122
 
123
  emit('action', action)
124
  }
125
+
126
+ // 关闭播放列表选择器
127
+ const closePlaylistSelector = () => {
128
+ showPlaylistSelector.value = false
129
+ }
130
+
131
+ // 处理添加到播放列表成功
132
+ const handleAddedToPlaylist = (data) => {
133
+ console.log('歌曲已添加到播放列表:', data.message)
134
+ // 关闭当前面板
135
+ emit('close')
136
+ }
137
  </script>
138
 
139
  <style scoped>
src/components/player/PlayControls.vue CHANGED
@@ -52,6 +52,7 @@
52
  <script setup>
53
  import { computed } from 'vue'
54
  import { usePlayerStore } from '@/stores/player'
 
55
 
56
  const props = defineProps({
57
  loading: {
@@ -69,15 +70,20 @@ const emit = defineEmits([
69
  ])
70
 
71
  const playerStore = usePlayerStore()
 
72
 
73
  // 计算属性
74
  const isPlaying = computed(() => playerStore.isPlaying)
75
- const playMode = computed(() => playerStore.playMode)
76
- const hasPrevious = computed(() => playerStore.hasPrevious)
77
- const hasNext = computed(() => playerStore.hasNext)
78
- const playlistCount = computed(() => playerStore.playlist.length)
79
  const hasAudio = computed(() => !!playerStore.audioSrc)
80
 
 
 
 
 
81
  const playModeIcon = computed(() => {
82
  switch (playMode.value) {
83
  case 'single':
@@ -101,17 +107,6 @@ const playModeText = computed(() => {
101
  return '列表循环'
102
  }
103
  })
104
-
105
- // 方法
106
- const togglePlayMode = () => {
107
- const modes = ['list', 'random', 'single']
108
- const currentIndex = modes.indexOf(playMode.value)
109
- const nextIndex = (currentIndex + 1) % modes.length
110
- const nextMode = modes[nextIndex]
111
-
112
- playerStore.setPlayMode(nextMode)
113
- emit('togglePlayMode', nextMode)
114
- }
115
  </script>
116
 
117
  <style scoped>
 
52
  <script setup>
53
  import { computed } from 'vue'
54
  import { usePlayerStore } from '@/stores/player'
55
+ import { usePlayQueueStore } from '@/stores/playqueue'
56
 
57
  const props = defineProps({
58
  loading: {
 
70
  ])
71
 
72
  const playerStore = usePlayerStore()
73
+ const playQueueStore = usePlayQueueStore()
74
 
75
  // 计算属性
76
  const isPlaying = computed(() => playerStore.isPlaying)
77
+ const playMode = computed(() => playQueueStore.playMode)
78
+ const hasPrevious = computed(() => playQueueStore.hasPrevious)
79
+ const hasNext = computed(() => playQueueStore.hasNext)
80
+ const playlistCount = computed(() => playQueueStore.queueLength)
81
  const hasAudio = computed(() => !!playerStore.audioSrc)
82
 
83
+ const togglePlayMode = () => {
84
+ playQueueStore.togglePlayMode()
85
+ }
86
+
87
  const playModeIcon = computed(() => {
88
  switch (playMode.value) {
89
  case 'single':
 
107
  return '列表循环'
108
  }
109
  })
 
 
 
 
 
 
 
 
 
 
 
110
  </script>
111
 
112
  <style scoped>
src/components/player/PlaylistPanel.vue CHANGED
@@ -133,6 +133,7 @@
133
  import { computed, ref } from 'vue'
134
  import { usePlayerStore } from '@/stores/player'
135
  import { useFavoritesStore } from '@/stores/favorites'
 
136
  import { utils } from '@/services/musicApi'
137
  import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
138
 
@@ -140,6 +141,7 @@ const emit = defineEmits(['close', 'play', 'remove'])
140
 
141
  const playerStore = usePlayerStore()
142
  const favoritesStore = useFavoritesStore()
 
143
  const confirmDialog = ref(null)
144
 
145
  // 计算属性
@@ -200,7 +202,7 @@ const clearPlaylist = () => {
200
  }
201
 
202
  const handleClearConfirm = () => {
203
- playerStore.setPlaylist([])
204
  emit('close')
205
  }
206
 
 
133
  import { computed, ref } from 'vue'
134
  import { usePlayerStore } from '@/stores/player'
135
  import { useFavoritesStore } from '@/stores/favorites'
136
+ import { usePlayQueueStore } from '@/stores/playqueue'
137
  import { utils } from '@/services/musicApi'
138
  import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
139
 
 
141
 
142
  const playerStore = usePlayerStore()
143
  const favoritesStore = useFavoritesStore()
144
+ const playQueueStore = usePlayQueueStore()
145
  const confirmDialog = ref(null)
146
 
147
  // 计算属性
 
202
  }
203
 
204
  const handleClearConfirm = () => {
205
+ playQueueStore.clearQueue()
206
  emit('close')
207
  }
208
 
src/components/playlist/PlaylistSelector.vue ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="playlist-selector-overlay" v-if="show" @click="handleClose">
3
+ <div class="playlist-selector-dialog" @click.stop>
4
+ <div class="dialog-header">
5
+ <h3>添加到播放列表</h3>
6
+ <button class="close-btn" @click="handleClose">
7
+ <i class="fas fa-times"></i>
8
+ </button>
9
+ </div>
10
+
11
+ <div class="dialog-body">
12
+ <div class="playlist-list">
13
+ <div
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">
21
+ <img
22
+ v-if="playlist.cover"
23
+ :src="playlist.cover"
24
+ :alt="playlist.name"
25
+ />
26
+ <div v-else class="default-cover">
27
+ <i class="fas fa-music"></i>
28
+ </div>
29
+ </div>
30
+
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
+
80
+ const props = defineProps({
81
+ show: {
82
+ type: Boolean,
83
+ default: false
84
+ },
85
+ song: {
86
+ type: Object,
87
+ required: true
88
+ }
89
+ })
90
+
91
+ 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>
176
+
177
+ <style scoped>
178
+ .playlist-selector-overlay {
179
+ position: fixed;
180
+ top: 0;
181
+ left: 0;
182
+ right: 0;
183
+ bottom: 0;
184
+ background: rgba(0, 0, 0, 0.6);
185
+ backdrop-filter: blur(4px);
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ z-index: 2000;
190
+ animation: fadeIn 0.3s ease;
191
+ }
192
+
193
+ .playlist-selector-dialog {
194
+ width: 90%;
195
+ max-width: 480px;
196
+ max-height: 80vh;
197
+ background: var(--bg-card);
198
+ border-radius: 16px;
199
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
200
+ border: 1px solid var(--border-light);
201
+ animation: slideIn 0.3s ease;
202
+ overflow: hidden;
203
+ }
204
+
205
+ .dialog-header {
206
+ display: flex;
207
+ align-items: center;
208
+ justify-content: space-between;
209
+ padding: 20px 24px;
210
+ border-bottom: 1px solid var(--border-lighter);
211
+ }
212
+
213
+ .dialog-header h3 {
214
+ font-size: 18px;
215
+ font-weight: 600;
216
+ color: var(--text-primary);
217
+ margin: 0;
218
+ }
219
+
220
+ .close-btn {
221
+ width: 32px;
222
+ height: 32px;
223
+ border: none;
224
+ background: rgba(255, 255, 255, 0.1);
225
+ border-radius: 50%;
226
+ color: var(--text-secondary);
227
+ cursor: pointer;
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ font-size: 14px;
232
+ transition: var(--transition-fast);
233
+ }
234
+
235
+ .close-btn:hover {
236
+ background: rgba(255, 255, 255, 0.2);
237
+ color: var(--text-primary);
238
+ }
239
+
240
+ .dialog-body {
241
+ padding: 16px;
242
+ max-height: 400px;
243
+ overflow-y: auto;
244
+ }
245
+
246
+ .playlist-list {
247
+ margin-bottom: 16px;
248
+ }
249
+
250
+ .playlist-item {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 12px;
254
+ padding: 12px;
255
+ border-radius: 8px;
256
+ cursor: pointer;
257
+ transition: var(--transition-fast);
258
+ border: 1px solid transparent;
259
+ }
260
+
261
+ .playlist-item:hover:not(.disabled) {
262
+ background: rgba(255, 255, 255, 0.05);
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;
274
+ border-radius: 6px;
275
+ overflow: hidden;
276
+ flex-shrink: 0;
277
+ }
278
+
279
+ .playlist-cover img {
280
+ width: 100%;
281
+ height: 100%;
282
+ object-fit: cover;
283
+ }
284
+
285
+ .default-cover {
286
+ width: 100%;
287
+ height: 100%;
288
+ background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
289
+ display: flex;
290
+ align-items: center;
291
+ justify-content: center;
292
+ color: white;
293
+ font-size: 20px;
294
+ }
295
+
296
+ .playlist-info {
297
+ flex: 1;
298
+ min-width: 0;
299
+ }
300
+
301
+ .playlist-name {
302
+ font-size: 14px;
303
+ font-weight: 600;
304
+ color: var(--text-primary);
305
+ margin: 0 0 4px;
306
+ overflow: hidden;
307
+ text-overflow: ellipsis;
308
+ white-space: nowrap;
309
+ }
310
+
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: rgba(255, 107, 107, 0.05);
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: rgba(255, 255, 255, 0.05);
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 {
403
+ padding: 10px 20px;
404
+ border-radius: 8px;
405
+ font-size: 14px;
406
+ font-weight: 500;
407
+ cursor: pointer;
408
+ transition: var(--transition-fast);
409
+ border: none;
410
+ }
411
+
412
+ .btn-cancel {
413
+ background: rgba(255, 255, 255, 0.1);
414
+ color: var(--text-secondary);
415
+ }
416
+
417
+ .btn-cancel:hover {
418
+ background: rgba(255, 255, 255, 0.2);
419
+ color: var(--text-primary);
420
+ }
421
+
422
+ .btn-create {
423
+ background: var(--accent-red);
424
+ color: white;
425
+ }
426
+
427
+ .btn-create:hover:not(:disabled) {
428
+ background: var(--accent-red-hover);
429
+ }
430
+
431
+ .btn-create:disabled {
432
+ background: rgba(255, 255, 255, 0.1);
433
+ color: var(--text-tertiary);
434
+ cursor: not-allowed;
435
+ }
436
+
437
+ @keyframes fadeIn {
438
+ from { opacity: 0; }
439
+ to { opacity: 1; }
440
+ }
441
+
442
+ @keyframes slideIn {
443
+ from {
444
+ opacity: 0;
445
+ transform: translateY(-20px) scale(0.95);
446
+ }
447
+ to {
448
+ opacity: 1;
449
+ transform: translateY(0) scale(1);
450
+ }
451
+ }
452
+
453
+ /* 响应式 */
454
+ @media (max-width: 375px) {
455
+ .playlist-selector-dialog {
456
+ width: 95%;
457
+ max-height: 85vh;
458
+ }
459
+
460
+ .dialog-header {
461
+ padding: 16px 20px;
462
+ }
463
+
464
+ .dialog-body {
465
+ padding: 12px;
466
+ }
467
+
468
+ .create-form {
469
+ padding: 16px 20px;
470
+ }
471
+
472
+ .playlist-item {
473
+ padding: 10px;
474
+ }
475
+
476
+ .playlist-cover {
477
+ width: 40px;
478
+ height: 40px;
479
+ }
480
+ }
481
+ </style>
src/components/search/SearchHistory.vue CHANGED
@@ -57,6 +57,16 @@
57
  </div>
58
  </div>
59
  </div>
 
 
 
 
 
 
 
 
 
 
60
  </div>
61
  </template>
62
 
@@ -64,6 +74,7 @@
64
  import { ref, computed } from 'vue'
65
  import { useSearchStore } from '@/stores/search'
66
  import { useSettingsStore } from '@/stores/settings'
 
67
 
68
  const emit = defineEmits(['search', 'clear'])
69
 
@@ -73,6 +84,7 @@ const settingsStore = useSettingsStore()
73
  // 响应式数据
74
  const expanded = ref(false)
75
  const maxDisplay = ref(5)
 
76
 
77
  const props = defineProps({
78
  showEmpty: {
@@ -107,10 +119,12 @@ const handleRemove = (index) => {
107
  }
108
 
109
  const handleClear = () => {
110
- if (confirm('确定要清空所有搜索历史吗?')) {
111
- searchStore.clearHistory()
112
- emit('clear')
113
- }
 
 
114
  }
115
 
116
  const toggleExpanded = () => {
 
57
  </div>
58
  </div>
59
  </div>
60
+
61
+ <!-- 确认对话框 -->
62
+ <ConfirmDialog
63
+ ref="confirmDialogRef"
64
+ title="清空搜索历史"
65
+ message="确定要清空所有搜索历史吗?此操作不可撤销。"
66
+ confirm-text="清空"
67
+ type="danger"
68
+ @confirm="confirmClear"
69
+ />
70
  </div>
71
  </template>
72
 
 
74
  import { ref, computed } from 'vue'
75
  import { useSearchStore } from '@/stores/search'
76
  import { useSettingsStore } from '@/stores/settings'
77
+ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
78
 
79
  const emit = defineEmits(['search', 'clear'])
80
 
 
84
  // 响应式数据
85
  const expanded = ref(false)
86
  const maxDisplay = ref(5)
87
+ const confirmDialogRef = ref(null)
88
 
89
  const props = defineProps({
90
  showEmpty: {
 
119
  }
120
 
121
  const handleClear = () => {
122
+ confirmDialogRef.value?.show()
123
+ }
124
+
125
+ const confirmClear = () => {
126
+ searchStore.clearHistory()
127
+ emit('clear')
128
  }
129
 
130
  const toggleExpanded = () => {
src/components/search/SearchResults.vue CHANGED
@@ -86,6 +86,9 @@
86
  import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
87
  import { useSearchStore } from '@/stores/search'
88
  import { usePlayerStore } from '@/stores/player'
 
 
 
89
  import SongItem from './SongItem.vue'
90
 
91
  const props = defineProps({
@@ -103,6 +106,9 @@ const emit = defineEmits(['retry', 'search', 'play', 'loadMore'])
103
 
104
  const searchStore = useSearchStore()
105
  const playerStore = usePlayerStore()
 
 
 
106
 
107
  // 响应式数据
108
  const songsContainer = ref(null)
@@ -123,10 +129,29 @@ const loadingMore = computed(() => loading.value && results.value.length > 0)
123
 
124
  // 方法
125
  const handlePlay = async (song, index) => {
126
- // 设置播放列表和播放歌曲
127
- playerStore.setPlaylist(results.value, index)
128
- await playerStore.playSong(song, index)
129
- emit('play', song, index)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
131
 
132
  // 设置智能滚动加载 - 可视区域超过一半时加载下一页
 
86
  import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
87
  import { useSearchStore } from '@/stores/search'
88
  import { usePlayerStore } from '@/stores/player'
89
+ import { usePlayQueueStore } from '@/stores/playqueue'
90
+ import { useHistoryStore } from '@/stores/history'
91
+ import { useToastStore } from '@/stores/toast'
92
  import SongItem from './SongItem.vue'
93
 
94
  const props = defineProps({
 
106
 
107
  const searchStore = useSearchStore()
108
  const playerStore = usePlayerStore()
109
+ const playQueueStore = usePlayQueueStore()
110
+ const historyStore = useHistoryStore()
111
+ const toastStore = useToastStore()
112
 
113
  // 响应式数据
114
  const songsContainer = ref(null)
 
129
 
130
  // 方法
131
  const handlePlay = async (song, index) => {
132
+ try {
133
+ // SOLID原则:使用playQueueStore管理播放队列
134
+ const result = playQueueStore.setQueue(results.value, index)
135
+
136
+ if (result) {
137
+ // 开始播放歌曲
138
+ await playerStore.playSong(song)
139
+
140
+ // 添加到播放历史
141
+ historyStore.addToHistory(song)
142
+
143
+ // 用户反馈
144
+ toastStore.success(`开始播放 "${song.name}"`)
145
+
146
+ // 通知父组件
147
+ emit('play', song, index)
148
+ } else {
149
+ throw new Error('设置播放队列失败')
150
+ }
151
+ } catch (error) {
152
+ console.error('播放失败:', error)
153
+ toastStore.error('播放失败,请重试')
154
+ }
155
  }
156
 
157
  // 设置智能滚动加载 - 可视区域超过一半时加载下一页
src/components/search/SongItem.vue CHANGED
@@ -18,15 +18,41 @@
18
  >
19
  <i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
20
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  </div>
 
 
 
 
 
 
 
 
22
  </div>
23
  </template>
24
 
25
  <script setup>
26
- import { computed } from 'vue'
27
  import { usePlayerStore } from '@/stores/player'
 
28
  import { useFavoritesStore } from '@/stores/favorites'
 
29
  import { utils } from '@/services/musicApi'
 
30
 
31
  const props = defineProps({
32
  song: {
@@ -42,7 +68,11 @@ const props = defineProps({
42
  const emit = defineEmits(['play'])
43
 
44
  const playerStore = usePlayerStore()
 
45
  const favoritesStore = useFavoritesStore()
 
 
 
46
 
47
  // 计算属性
48
  const isActive = computed(() => {
@@ -71,6 +101,54 @@ const toggleFavorite = () => {
71
  navigator.vibrate(50)
72
  }
73
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </script>
75
 
76
  <style scoped>
@@ -192,6 +270,44 @@ const toggleFavorite = () => {
192
  color: var(--accent-red-hover);
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  /* 响应式 */
196
  @media (max-width: 375px) {
197
  .song-item {
 
18
  >
19
  <i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
20
  </button>
21
+
22
+ <button
23
+ class="action-btn more-btn"
24
+ @click.stop="showMoreActions"
25
+ :title="'更多操作'"
26
+ >
27
+ <i class="fas fa-ellipsis-v"></i>
28
+ </button>
29
+ </div>
30
+
31
+ <!-- 更多操作菜单 -->
32
+ <div class="more-menu" v-if="showMenu" @click.stop>
33
+ <button @click="handlePlayNext">下一首播放</button>
34
+ <button @click="handleAddToPlaylist">添加到播放列表</button>
35
+ <button @click="handleCopyLink">复制链接</button>
36
  </div>
37
+
38
+ <!-- 播放列表选择对话框 -->
39
+ <PlaylistSelector
40
+ :show="showPlaylistSelector"
41
+ :song="song"
42
+ @close="closePlaylistSelector"
43
+ @added="handleAddedToPlaylist"
44
+ />
45
  </div>
46
  </template>
47
 
48
  <script setup>
49
+ import { computed, ref } from 'vue'
50
  import { usePlayerStore } from '@/stores/player'
51
+ import { usePlayQueueStore } from '@/stores/playqueue'
52
  import { useFavoritesStore } from '@/stores/favorites'
53
+ import { useToastStore } from '@/stores/toast'
54
  import { utils } from '@/services/musicApi'
55
+ import PlaylistSelector from '@/components/playlist/PlaylistSelector.vue'
56
 
57
  const props = defineProps({
58
  song: {
 
68
  const emit = defineEmits(['play'])
69
 
70
  const playerStore = usePlayerStore()
71
+ const playQueueStore = usePlayQueueStore()
72
  const favoritesStore = useFavoritesStore()
73
+ const toastStore = useToastStore()
74
+ const showMenu = ref(false)
75
+ const showPlaylistSelector = ref(false)
76
 
77
  // 计算属性
78
  const isActive = computed(() => {
 
101
  navigator.vibrate(50)
102
  }
103
  }
104
+
105
+ // 显示更多操作菜单
106
+ const showMoreActions = () => {
107
+ showMenu.value = !showMenu.value
108
+ }
109
+
110
+ // 下一首播放
111
+ const handlePlayNext = () => {
112
+ const result = playQueueStore.addToQueue(props.song, 'next')
113
+ if (result.success) {
114
+ toastStore.success(`"${props.song.name}" 已添加到下一首播放`)
115
+ } else {
116
+ toastStore.error(result.message)
117
+ }
118
+ showMenu.value = false
119
+ }
120
+
121
+ // 添加到播放列表
122
+ const handleAddToPlaylist = () => {
123
+ showMenu.value = false
124
+ showPlaylistSelector.value = true
125
+ }
126
+
127
+ // 关闭播放列表选择器
128
+ const closePlaylistSelector = () => {
129
+ showPlaylistSelector.value = false
130
+ }
131
+
132
+ // 处理添加到播放列表成功
133
+ const handleAddedToPlaylist = (data) => {
134
+ toastStore.success(data.message)
135
+ }
136
+
137
+ // 复制链接
138
+ const handleCopyLink = () => {
139
+ const url = `${window.location.origin}/?play=${props.song.source}&id=${props.song.id}`
140
+ navigator.clipboard.writeText(url).then(() => {
141
+ toastStore.success('链接已复制到剪贴板')
142
+ }).catch(() => {
143
+ toastStore.error('复制失败,请重试')
144
+ })
145
+ showMenu.value = false
146
+ }
147
+
148
+ // 点击外部关闭菜单
149
+ document.addEventListener('click', () => {
150
+ showMenu.value = false
151
+ })
152
  </script>
153
 
154
  <style scoped>
 
270
  color: var(--accent-red-hover);
271
  }
272
 
273
+ .more-btn {
274
+ opacity: 0;
275
+ position: relative;
276
+ }
277
+
278
+ .song-item:hover .more-btn {
279
+ opacity: 1;
280
+ }
281
+
282
+ .more-menu {
283
+ position: absolute;
284
+ top: 100%;
285
+ right: 0;
286
+ background: var(--bg-card);
287
+ border-radius: 8px;
288
+ padding: 8px 0;
289
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
290
+ z-index: 10;
291
+ min-width: 120px;
292
+ }
293
+
294
+ .more-menu button {
295
+ display: block;
296
+ width: 100%;
297
+ padding: 8px 16px;
298
+ border: none;
299
+ background: transparent;
300
+ color: var(--text-primary);
301
+ text-align: left;
302
+ cursor: pointer;
303
+ font-size: 12px;
304
+ transition: var(--transition-fast);
305
+ }
306
+
307
+ .more-menu button:hover {
308
+ background: rgba(255, 255, 255, 0.1);
309
+ }
310
+
311
  /* 响应式 */
312
  @media (max-width: 375px) {
313
  .song-item {
src/composables/useSongCoverLoader.js ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ref, onMounted, onUnmounted, nextTick } from 'vue'
2
+ import { usePlayerStore } from '@/stores/player'
3
+ import { imageCacheManager } from '@/utils/imageCache'
4
+
5
+ /**
6
+ * 歌曲封面懒加载工具
7
+ * 使用 IntersectionObserver API 实现正确的图片懒加载并逐步请求
8
+ */
9
+ export const useSongCoverLoader = () => {
10
+ const playerStore = usePlayerStore()
11
+ const observer = ref(null)
12
+ const observedImages = new Set() // 记录被观察的图片
13
+ const loadQueue = [] // 加载队列
14
+ const isProcessingQueue = ref(false) // 是否正在处理队列
15
+
16
+ /**
17
+ * 处理加载队列,逐个加载图片
18
+ */
19
+ const processLoadQueue = async () => {
20
+ if (isProcessingQueue.value || loadQueue.length === 0) return
21
+
22
+ isProcessingQueue.value = true
23
+
24
+ while (loadQueue.length > 0) {
25
+ const { img, song } = loadQueue.shift()
26
+
27
+ // 检查元素是否还在DOM中
28
+ if (img.parentNode) {
29
+ await loadCoverForElement(img, song)
30
+ }
31
+
32
+ // 逐个加载,每次间隔200ms
33
+ if (loadQueue.length > 0) {
34
+ await new Promise(resolve => setTimeout(resolve, 200))
35
+ }
36
+ }
37
+
38
+ isProcessingQueue.value = false
39
+ }
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
+ * 初始化懒加载观察器
56
+ */
57
+ const initLazyLoading = () => {
58
+ if (!window.IntersectionObserver) {
59
+ console.warn('浏览器不支持 IntersectionObserver,使用立即加载')
60
+ return false
61
+ }
62
+
63
+ observer.value = new IntersectionObserver(
64
+ (entries) => {
65
+ entries.forEach(entry => {
66
+ if (entry.isIntersecting) {
67
+ const img = entry.target
68
+ const songData = img.dataset.song
69
+
70
+ if (songData) {
71
+ try {
72
+ const song = JSON.parse(songData)
73
+
74
+ // 添加到加载队列而非立即加载
75
+ loadQueue.push({ img, song })
76
+
77
+ // 开始处理队列
78
+ processLoadQueue()
79
+
80
+ // 停止观察该元素
81
+ observer.value.unobserve(img)
82
+ observedImages.delete(img)
83
+ } catch (error) {
84
+ console.error('解析歌曲数据失败:', error)
85
+ }
86
+ }
87
+ }
88
+ })
89
+ },
90
+ {
91
+ // 提前 100px 开始加载
92
+ rootMargin: '100px 0px',
93
+ threshold: 0.1
94
+ }
95
+ )
96
+
97
+ return true
98
+ }
99
+
100
+ /**
101
+ * 为图片元素加载封面
102
+ */
103
+ const loadCoverForElement = async (imgElement, song) => {
104
+ if (!imgElement || !song) return
105
+
106
+ try {
107
+ // 检查元素是否还存在于DOM中
108
+ if (!imgElement.parentNode) {
109
+ return
110
+ }
111
+
112
+ const coverUrl = await playerStore.getAlbumCover(song, 300)
113
+ if (coverUrl && imgElement.parentNode) {
114
+ imgElement.src = coverUrl
115
+ }
116
+ } catch (error) {
117
+ console.error('加载封面失败:', error)
118
+ if (imgElement.parentNode) {
119
+ imgElement.src = getDefaultCover()
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 观察图片元素进行懒加载
126
+ * @param {HTMLImageElement} imgElement - 图片元素
127
+ * @param {Object} song - 歌曲对象
128
+ */
129
+ const observeImage = (imgElement, song) => {
130
+ if (!imgElement || !song || observedImages.has(imgElement)) return
131
+
132
+ // 设置默认封面
133
+ imgElement.src = getDefaultCover()
134
+
135
+ // 将歌曲数据存储在 data 属性中
136
+ imgElement.dataset.song = JSON.stringify(song)
137
+
138
+ // 如果观察器未初始化,先初始化
139
+ if (!observer.value && !initLazyLoading()) {
140
+ // 不支持 IntersectionObserver,加入队列逐步加载
141
+ loadQueue.push({ imgElement, song })
142
+ processLoadQueue()
143
+ return
144
+ }
145
+
146
+ // 开始观察
147
+ observer.value.observe(imgElement)
148
+ observedImages.add(imgElement)
149
+ }
150
+
151
+ /**
152
+ * 立即加载歌曲封面(不延时)
153
+ * @param {Object} song - 歌曲对象
154
+ * @param {Number} size - 图片尺寸
155
+ */
156
+ const loadCoverImmediately = async (song, size = 300) => {
157
+ if (!song) return getDefaultCover()
158
+
159
+ try {
160
+ const coverUrl = await playerStore.getAlbumCover(song, size)
161
+ return coverUrl || getDefaultCover()
162
+ } catch (error) {
163
+ console.error('加载封面失败:', error)
164
+ return getDefaultCover()
165
+ }
166
+ }
167
+
168
+ /**
169
+ * 为图片元素设置封面
170
+ * @param {HTMLImageElement} imgElement - 图片元素
171
+ * @param {Object} song - 歌曲对象
172
+ * @param {Number} size - 图片尺寸
173
+ */
174
+ const setCoverForElement = async (imgElement, song, size = 300) => {
175
+ if (!imgElement || !song) return
176
+
177
+ try {
178
+ const coverUrl = await playerStore.getAlbumCover(song, size)
179
+ if (coverUrl) {
180
+ imgElement.src = coverUrl
181
+ }
182
+ } catch (error) {
183
+ console.error('加载封面失败:', error)
184
+ imgElement.src = getDefaultCover()
185
+ }
186
+ }
187
+
188
+ // 组件卸载时清理资源
189
+ onUnmounted(() => {
190
+ if (observer.value) {
191
+ observer.value.disconnect()
192
+ observer.value = null
193
+ }
194
+ observedImages.clear()
195
+ })
196
+
197
+ return {
198
+ getDefaultCover,
199
+ handleImageError,
200
+ observeImage,
201
+ loadCoverImmediately,
202
+ setCoverForElement
203
+ }
204
+ }
src/main.js CHANGED
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
3
  import router from './router/index.js'
4
  import App from './App.vue'
5
  import './styles/global.css'
 
6
 
7
  // 创建应用实例
8
  const app = createApp(App)
 
3
  import router from './router/index.js'
4
  import App from './App.vue'
5
  import './styles/global.css'
6
+ import '@fortawesome/fontawesome-free/css/all.css'
7
 
8
  // 创建应用实例
9
  const app = createApp(App)
src/router/index.js CHANGED
@@ -1,9 +1,16 @@
1
  import { createRouter, createWebHashHistory } from 'vue-router'
2
  import HomePage from '@/views/HomePage.vue'
3
- import MyMusicPage from '@/views/MyMusicPage.vue'
 
 
 
 
4
  import SettingsPage from '@/views/SettingsPage.vue'
5
  import FullPlayerPage from '@/views/FullPlayerPage.vue'
6
 
 
 
 
7
  const routes = [
8
  {
9
  path: '/',
@@ -24,9 +31,56 @@ const routes = [
24
  component: MyMusicPage,
25
  meta: {
26
  title: '我的音乐',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  keepAlive: true
28
  }
29
  },
 
 
 
 
 
 
 
 
 
30
  {
31
  path: '/settings',
32
  name: 'Settings',
 
1
  import { createRouter, createWebHashHistory } from 'vue-router'
2
  import HomePage from '@/views/HomePage.vue'
3
+ import FavoritesPage from '@/views/FavoritesPage.vue'
4
+ import HistoryPage from '@/views/HistoryPage.vue'
5
+ import PlaylistsPage from '@/views/PlaylistsPage.vue'
6
+ import PlaylistDetailPage from '@/views/PlaylistDetailPage.vue'
7
+ import PlayQueuePage from '@/views/PlayQueuePage.vue'
8
  import SettingsPage from '@/views/SettingsPage.vue'
9
  import FullPlayerPage from '@/views/FullPlayerPage.vue'
10
 
11
+ // 保留旧的MyMusicPage作为兼容
12
+ import MyMusicPage from '@/views/MyMusicPage.vue'
13
+
14
  const routes = [
15
  {
16
  path: '/',
 
31
  component: MyMusicPage,
32
  meta: {
33
  title: '我的音乐',
34
+ keepAlive: true,
35
+ deprecated: true // 标记为已废弃,但保留兼容性
36
+ }
37
+ },
38
+ // 新的独立页面 - 根据网易云音乐架构研究报告重新设计
39
+ {
40
+ path: '/favorites',
41
+ name: 'Favorites',
42
+ component: FavoritesPage,
43
+ meta: {
44
+ title: '我喜欢的音乐',
45
+ keepAlive: true
46
+ }
47
+ },
48
+ {
49
+ path: '/history',
50
+ name: 'History',
51
+ component: HistoryPage,
52
+ meta: {
53
+ title: '播放历史',
54
+ keepAlive: true
55
+ }
56
+ },
57
+ {
58
+ path: '/play-queue',
59
+ name: 'PlayQueue',
60
+ component: PlayQueuePage,
61
+ meta: {
62
+ title: '播放队列',
63
+ keepAlive: false // 播放队列需要实时更新,不缓存
64
+ }
65
+ },
66
+ {
67
+ path: '/playlists',
68
+ name: 'Playlists',
69
+ component: PlaylistsPage,
70
+ meta: {
71
+ title: '我的歌单',
72
  keepAlive: true
73
  }
74
  },
75
+ {
76
+ path: '/playlists/:id',
77
+ name: 'PlaylistDetail',
78
+ component: PlaylistDetailPage,
79
+ meta: {
80
+ title: '歌单详情',
81
+ keepAlive: false
82
+ }
83
+ },
84
  {
85
  path: '/settings',
86
  name: 'Settings',
src/stores/favorites.js CHANGED
@@ -1,155 +1,238 @@
1
  import { defineStore } from 'pinia'
2
- import { ref } from 'vue'
3
 
 
 
 
 
4
  export const useFavoritesStore = defineStore('favorites', () => {
5
  // 状态
6
  const favorites = ref([])
7
- const playHistory = ref([])
8
 
9
- // 收藏管理
 
 
 
 
 
 
 
 
10
  const isFavorite = (song) => {
11
- return favorites.value.some(fav => fav.id === song.id && fav.source === song.source)
 
 
 
12
  }
13
 
14
  const addToFavorites = (song) => {
15
- if (!isFavorite(song)) {
16
- const favoriteItem = {
17
- ...song,
18
- favoriteTime: Date.now()
19
- }
20
- favorites.value.unshift(favoriteItem)
21
- saveFavorites()
22
- return true
 
 
 
 
 
 
 
23
  }
24
- return false
 
 
 
25
  }
26
 
27
  const removeFromFavorites = (song) => {
28
- const index = favorites.value.findIndex(fav => fav.id === song.id && fav.source === song.source)
29
- if (index >= 0) {
30
- favorites.value.splice(index, 1)
31
- saveFavorites()
32
- return true
 
 
 
 
 
33
  }
34
- return false
 
 
 
35
  }
36
 
37
  const toggleFavorite = (song) => {
38
  if (isFavorite(song)) {
39
- removeFromFavorites(song)
40
- return false
41
  } else {
42
- addToFavorites(song)
43
- return true
44
  }
45
  }
46
 
47
  const clearFavorites = () => {
48
  favorites.value = []
49
- localStorage.removeItem('vue-music-favorites')
50
  }
51
 
52
- // 播放历史管理
53
- const addToHistory = (song) => {
54
- if (!song) return
55
-
56
- // 移除已存在的记录
57
- playHistory.value = playHistory.value.filter(item =>
58
- !(item.id === song.id && item.source === song.source)
59
- )
60
 
61
- // 添加到开头
62
- const historyItem = {
63
- ...song,
64
- playTime: Date.now(),
65
- playCount: 1
66
  }
67
 
68
- // 检查是否已存在,如果存在则增加播放次数
69
- const existingIndex = playHistory.value.findIndex(item =>
70
- item.id === song.id && item.source === song.source
71
- )
72
-
73
- if (existingIndex >= 0) {
74
- historyItem.playCount = playHistory.value[existingIndex].playCount + 1
75
  }
76
-
77
- playHistory.value.unshift(historyItem)
78
-
79
- // 限制历史记录数量
80
- playHistory.value = playHistory.value.slice(0, 100)
81
-
82
- savePlayHistory()
83
  }
84
 
85
- const clearHistory = () => {
86
- playHistory.value = []
87
- localStorage.removeItem('vue-music-play-history')
 
 
 
 
 
 
 
 
 
 
88
  }
89
 
90
- const getRecentlyPlayed = (limit = 20) => {
91
- return playHistory.value.slice(0, limit)
 
 
92
  }
93
 
94
- const getMostPlayed = (limit = 20) => {
95
- return [...playHistory.value]
96
- .sort((a, b) => b.playCount - a.playCount)
97
- .slice(0, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  }
99
 
100
  // 本地存储
101
  const saveFavorites = () => {
102
- localStorage.setItem('vue-music-favorites', JSON.stringify(favorites.value))
103
- }
104
-
105
- const loadFavorites = () => {
106
  try {
107
- const saved = localStorage.getItem('vue-music-favorites')
108
- if (saved) {
109
- favorites.value = JSON.parse(saved) || []
 
110
  }
 
111
  } catch (error) {
112
- console.error('加载收藏列表失败:', error)
113
  }
114
  }
115
 
116
- const savePlayHistory = () => {
117
- localStorage.setItem('vue-music-play-history', JSON.stringify(playHistory.value))
118
- }
119
-
120
- const loadPlayHistory = () => {
121
  try {
122
- const saved = localStorage.getItem('vue-music-play-history')
 
123
  if (saved) {
124
- playHistory.value = JSON.parse(saved) || []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
  } catch (error) {
127
- console.error('加载播放历史失败:', error)
 
128
  }
129
  }
130
 
131
  return {
132
  // 状态
133
- favorites,
134
- playHistory,
 
 
 
135
 
136
- // 收藏管理
137
  isFavorite,
138
  addToFavorites,
139
  removeFromFavorites,
140
  toggleFavorite,
141
  clearFavorites,
142
 
143
- // 历史管理
144
- addToHistory,
145
- clearHistory,
146
- getRecentlyPlayed,
147
- getMostPlayed,
 
 
 
 
148
 
149
- // 存储
150
  saveFavorites,
151
- loadFavorites,
152
- savePlayHistory,
153
- loadPlayHistory
154
  }
155
  })
 
1
  import { defineStore } from 'pinia'
2
+ import { ref, computed } from 'vue'
3
 
4
+ /**
5
+ * "我喜欢的音乐" Store - 单一职责:管理用户的红心收藏
6
+ * 对应网易云音乐的"我喜欢的音乐"特殊歌单
7
+ */
8
  export const useFavoritesStore = defineStore('favorites', () => {
9
  // 状态
10
  const favorites = ref([])
 
11
 
12
+ // 常量
13
+ const STORAGE_KEY = 'vue-music-my-favorites'
14
+ const MAX_FAVORITES = 1000
15
+
16
+ // 计算属性
17
+ const totalFavorites = computed(() => favorites.value.length)
18
+ const isEmpty = computed(() => favorites.value.length === 0)
19
+
20
+ // 收藏管理 - 严格按照网易云逻辑
21
  const isFavorite = (song) => {
22
+ if (!song || !song.id) return false
23
+ return favorites.value.some(fav =>
24
+ fav.song && fav.song.id === song.id && fav.song.source === song.source
25
+ )
26
  }
27
 
28
  const addToFavorites = (song) => {
29
+ if (!song || !song.id) {
30
+ return { success: false, message: '歌曲信息无效' }
31
+ }
32
+
33
+ if (isFavorite(song)) {
34
+ return { success: false, message: '歌曲已在我喜欢的音乐中' }
35
+ }
36
+
37
+ if (favorites.value.length >= MAX_FAVORITES) {
38
+ return { success: false, message: `我喜欢的音乐最多只能添加 ${MAX_FAVORITES} 首歌曲` }
39
+ }
40
+
41
+ const favoriteItem = {
42
+ song: { ...song },
43
+ favoriteTime: Date.now()
44
  }
45
+
46
+ favorites.value.unshift(favoriteItem)
47
+ saveFavorites()
48
+ return { success: true, message: '已添加到我喜欢的音乐' }
49
  }
50
 
51
  const removeFromFavorites = (song) => {
52
+ if (!song || !song.id) {
53
+ return { success: false, message: '歌曲信息无效' }
54
+ }
55
+
56
+ const index = favorites.value.findIndex(fav =>
57
+ fav.song && fav.song.id === song.id && fav.song.source === song.source
58
+ )
59
+
60
+ if (index === -1) {
61
+ return { success: false, message: '歌曲不在我喜欢的音乐中' }
62
  }
63
+
64
+ favorites.value.splice(index, 1)
65
+ saveFavorites()
66
+ return { success: true, message: '已从我喜欢的音乐中移除' }
67
  }
68
 
69
  const toggleFavorite = (song) => {
70
  if (isFavorite(song)) {
71
+ return removeFromFavorites(song)
 
72
  } else {
73
+ return addToFavorites(song)
 
74
  }
75
  }
76
 
77
  const clearFavorites = () => {
78
  favorites.value = []
79
+ saveFavorites()
80
  }
81
 
82
+ // 批量操作
83
+ const addMultipleFavorites = (songs) => {
84
+ let successCount = 0
85
+ const results = []
 
 
 
 
86
 
87
+ for (const song of songs) {
88
+ const result = addToFavorites(song)
89
+ results.push(result)
90
+ if (result.success) successCount++
 
91
  }
92
 
93
+ return {
94
+ success: successCount > 0,
95
+ successCount,
96
+ totalCount: songs.length,
97
+ results
 
 
98
  }
 
 
 
 
 
 
 
99
  }
100
 
101
+ // 搜索和筛选
102
+ const searchFavorites = (keyword) => {
103
+ if (!keyword || !keyword.trim()) {
104
+ return favorites.value
105
+ }
106
+
107
+ const lowerKeyword = keyword.toLowerCase().trim()
108
+ return favorites.value.filter(fav => {
109
+ const song = fav.song
110
+ return song.name.toLowerCase().includes(lowerKeyword) ||
111
+ song.artist.toLowerCase().includes(lowerKeyword) ||
112
+ (song.album && song.album.toLowerCase().includes(lowerKeyword))
113
+ })
114
  }
115
 
116
+ const getFavoritesByDateRange = (startDate, endDate) => {
117
+ return favorites.value.filter(fav =>
118
+ fav.favoriteTime >= startDate && fav.favoriteTime <= endDate
119
+ )
120
  }
121
 
122
+ // 导出/导入功能
123
+ const exportFavorites = () => {
124
+ try {
125
+ const data = {
126
+ favorites: favorites.value,
127
+ exportTime: Date.now(),
128
+ version: '1.0',
129
+ type: 'my-favorites'
130
+ }
131
+
132
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
133
+ type: 'application/json'
134
+ })
135
+
136
+ const url = URL.createObjectURL(blob)
137
+ const link = document.createElement('a')
138
+ link.href = url
139
+ link.download = `my-favorites-${new Date().toISOString().split('T')[0]}.json`
140
+ document.body.appendChild(link)
141
+ link.click()
142
+ document.body.removeChild(link)
143
+ URL.revokeObjectURL(url)
144
+
145
+ return { success: true, message: '导出成功' }
146
+ } catch (error) {
147
+ console.error('导出我喜欢的音乐失败:', error)
148
+ return { success: false, message: '导出失败' }
149
+ }
150
  }
151
 
152
  // 本地存储
153
  const saveFavorites = () => {
 
 
 
 
154
  try {
155
+ const data = {
156
+ favorites: favorites.value,
157
+ lastUpdated: Date.now(),
158
+ version: '2.0'
159
  }
160
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
161
  } catch (error) {
162
+ console.error('保存我喜欢的音乐失败:', error)
163
  }
164
  }
165
 
166
+ const loadFavorites = () => {
 
 
 
 
167
  try {
168
+ // 尝试加载新格式数据
169
+ const saved = localStorage.getItem(STORAGE_KEY)
170
  if (saved) {
171
+ const data = JSON.parse(saved)
172
+ favorites.value = data.favorites || []
173
+ return
174
+ }
175
+
176
+ // 兼容旧版本数据迁移
177
+ const oldData = localStorage.getItem('vue-music-favorites')
178
+ if (oldData) {
179
+ const data = JSON.parse(oldData) || []
180
+
181
+ // 数据格式迁移:处理旧格式的收藏数据
182
+ favorites.value = data.map(item => {
183
+ if (item.song && item.favoriteTime) {
184
+ return item
185
+ }
186
+ if (item.favoriteTime && item.id) {
187
+ const { favoriteTime, ...songData } = item
188
+ return {
189
+ song: songData,
190
+ favoriteTime: favoriteTime
191
+ }
192
+ }
193
+ return {
194
+ song: item,
195
+ favoriteTime: Date.now()
196
+ }
197
+ })
198
+
199
+ // 迁移完成后保存新格式并删除旧数据
200
+ saveFavorites()
201
+ localStorage.removeItem('vue-music-favorites')
202
  }
203
  } catch (error) {
204
+ console.error('加载我喜欢的音乐失败:', error)
205
+ favorites.value = []
206
  }
207
  }
208
 
209
  return {
210
  // 状态
211
+ favorites: computed(() => favorites.value),
212
+
213
+ // 计算属性
214
+ totalFavorites,
215
+ isEmpty,
216
 
217
+ // 核心方法
218
  isFavorite,
219
  addToFavorites,
220
  removeFromFavorites,
221
  toggleFavorite,
222
  clearFavorites,
223
 
224
+ // 批量操作
225
+ addMultipleFavorites,
226
+
227
+ // 搜索和筛选
228
+ searchFavorites,
229
+ getFavoritesByDateRange,
230
+
231
+ // 导出功能
232
+ exportFavorites,
233
 
234
+ // 存储管理
235
  saveFavorites,
236
+ loadFavorites
 
 
237
  }
238
  })
src/stores/history.js CHANGED
@@ -1,276 +1,414 @@
1
  import { defineStore } from 'pinia'
 
2
 
3
- export const useHistoryStore = defineStore('history', {
4
- state: () => ({
5
- history: [], // 播放历史记录
6
- playCount: {}, // 歌曲播放次数统计 { songId: count }
7
- maxHistorySize: 1000 // 最大历史记录数量
8
- }),
9
-
10
- getters: {
11
- // 总播放时长(秒)
12
- totalPlayTime: (state) => {
13
- return state.history.reduce((total, item) => {
14
- return total + (item.playTime || 0)
15
- }, 0)
16
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- // 最近播放的歌曲(去重)
19
- recentSongs: (state) => {
20
- const songMap = new Map()
21
- const recent = []
22
 
23
- // 从最新的开始遍历,保持最新的记录
24
- for (let i = state.history.length - 1; i >= 0; i--) {
25
- const item = state.history[i]
26
- if (!songMap.has(item.song.id)) {
27
- songMap.set(item.song.id, true)
28
- recent.push(item)
29
- if (recent.length >= 50) break // 最多返回50首
30
- }
31
- }
32
 
33
- return recent
34
- },
 
 
 
 
 
 
 
 
 
 
 
35
 
36
- // 按日期分组的历史记录
37
- groupedByDate: (state) => {
38
- const groups = {}
39
- state.history.forEach(item => {
40
- const date = new Date(item.timestamp).toDateString()
41
- if (!groups[date]) {
42
- groups[date] = []
43
- }
44
- groups[date].push(item)
45
- })
46
- return groups
47
- },
48
-
49
- // 播放统计
50
- playStats: (state) => {
51
- const stats = {
52
- totalPlays: state.history.length,
53
- uniqueSongs: new Set(state.history.map(item => item.song.id)).size,
54
- totalDuration: 0,
55
- topArtists: {},
56
- topSongs: {}
57
  }
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- state.history.forEach(item => {
60
- stats.totalDuration += item.playTime || 0
61
-
62
- // 统计艺术家
63
- const artist = item.song.artist
64
- stats.topArtists[artist] = (stats.topArtists[artist] || 0) + 1
65
-
66
- // 统计歌曲
67
- const songKey = `${item.song.artist} - ${item.song.name}`
68
- stats.topSongs[songKey] = (stats.topSongs[songKey] || 0) + 1
69
- })
 
 
70
 
71
- return stats
72
- },
73
 
74
- // 获取单首歌曲播放次数
75
- getPlayCount: (state) => (songId) => {
76
- return state.playCount[songId] || 0
77
- },
78
 
79
- // 获取热门歌曲(按播放次数排序)
80
- topPlayedSongs: (state) => {
81
- return Object.entries(state.playCount)
82
- .sort(([, a], [, b]) => b - a)
83
- .slice(0, 50)
84
- .map(([songId, count]) => ({
85
- songId,
86
- count,
87
- song: state.history.find(item => item.song.id === songId)?.song
88
- }))
89
- .filter(item => item.song) // 过滤掉找不到歌曲信息的项
90
  }
91
- },
92
 
93
- actions: {
94
- // 添加播放记录
95
- addToHistory(song, playTime = 0) {
96
- const historyItem = {
97
- song: { ...song },
98
- timestamp: Date.now(),
99
- playTime, // 播放时长(秒)
100
- source: song.source || 'unknown'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- // 添加到历史记录开头
104
- this.history.unshift(historyItem)
105
-
106
- // 更新播放次数
107
- const songId = song.id
108
- this.playCount[songId] = (this.playCount[songId] || 0) + 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
- // 限制历史记录数量
111
- if (this.history.length > this.maxHistorySize) {
112
- this.history = this.history.slice(0, this.maxHistorySize)
 
 
 
 
 
 
 
113
  }
114
-
115
- this.saveHistory()
116
- },
117
-
118
- // 移除历史记录
119
- removeFromHistory(index) {
120
- if (index >= 0 && index < this.history.length) {
121
- const removedItem = this.history.splice(index, 1)[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
- // 更新播放次数(如果这是该歌曲的最后一次播放记录,则不减少计数)
124
- // 这里选择保持播放次数不变,因为这反映了历史播放情况
125
 
126
- this.saveHistory()
127
- }
128
- },
129
-
130
- // 清空历史记录
131
- clearHistory() {
132
- this.history = []
133
- this.playCount = {} // 同时清空播放次数
134
- this.saveHistory()
135
- },
136
-
137
- // 按日期清理历史记录
138
- clearHistoryBefore(date) {
139
- const targetTime = new Date(date).getTime()
140
- this.history = this.history.filter(item => item.timestamp >= targetTime)
141
- this.saveHistory()
142
- },
143
-
144
- // 搜索历史记录
145
- searchHistory(keyword) {
146
- if (!keyword.trim()) return this.history
147
-
148
- const lowerKeyword = keyword.toLowerCase()
149
- return this.history.filter(item =>
150
- item.song.name.toLowerCase().includes(lowerKeyword) ||
151
- item.song.artist.toLowerCase().includes(lowerKeyword) ||
152
- (item.song.album && item.song.album.toLowerCase().includes(lowerKeyword))
153
- )
154
- },
155
-
156
- // 保存到本地存储
157
- saveHistory() {
158
- try {
159
- localStorage.setItem('music-history', JSON.stringify({
160
- history: this.history,
161
- playCount: this.playCount,
162
- lastUpdated: Date.now()
163
- }))
164
- } catch (error) {
165
- console.error('保存播放历史失败:', error)
166
- }
167
- },
168
-
169
- // 从本地存储加载
170
- async loadHistory() {
171
- try {
172
- const saved = localStorage.getItem('music-history')
173
- if (saved) {
174
- const data = JSON.parse(saved)
175
- this.history = data.history || []
176
- this.playCount = data.playCount || {}
177
-
178
- // 清理过期的历史记录(超过30天)
179
- const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000)
180
- const originalLength = this.history.length
181
- this.history = this.history.filter(item => item.timestamp > thirtyDaysAgo)
182
-
183
- // 如果清理了历史记录,重新计算播放次数
184
- if (originalLength !== this.history.length) {
185
- this.playCount = {}
186
- this.history.forEach(item => {
187
- const songId = item.song.id
188
- this.playCount[songId] = (this.playCount[songId] || 0) + 1
189
- })
190
- this.saveHistory() // 保存清理后的记录
191
  }
 
 
 
192
  }
193
- } catch (error) {
194
- console.error('加载播放历史失败:', error)
195
- this.history = []
196
- this.playCount = {}
197
- }
198
- },
199
-
200
- // 导出历史记录
201
- exportHistory() {
202
- try {
203
- const data = {
204
- history: this.history,
205
- playCount: this.playCount,
206
- stats: this.playStats,
207
- exportTime: Date.now(),
208
- version: '1.0'
209
- }
210
-
211
- const blob = new Blob([JSON.stringify(data, null, 2)], {
212
- type: 'application/json'
213
- })
214
-
215
- const url = URL.createObjectURL(blob)
216
- const link = document.createElement('a')
217
- link.href = url
218
- link.download = `music-history-${new Date().toISOString().split('T')[0]}.json`
219
- document.body.appendChild(link)
220
- link.click()
221
- document.body.removeChild(link)
222
- URL.revokeObjectURL(url)
223
 
224
- return true
225
- } catch (error) {
226
- console.error('导出播放历史失败:', error)
227
- return false
 
228
  }
229
- },
230
-
231
- // 导入历史记录
232
- async importHistory(file) {
233
- try {
234
- const text = await file.text()
235
- const data = JSON.parse(text)
236
-
237
- if (data.history && Array.isArray(data.history)) {
238
- // 合并历史记录,去重
239
- const existingIds = new Set(
240
- this.history.map(item => `${item.song.id}-${item.timestamp}`)
241
- )
242
-
243
- const newItems = data.history.filter(item =>
244
- !existingIds.has(`${item.song.id}-${item.timestamp}`)
245
- )
246
-
247
- this.history = [...this.history, ...newItems]
248
- .sort((a, b) => b.timestamp - a.timestamp)
249
- .slice(0, this.maxHistorySize)
250
-
251
- // 合并播放次数
252
- if (data.playCount) {
253
- Object.entries(data.playCount).forEach(([songId, count]) => {
254
- this.playCount[songId] = (this.playCount[songId] || 0) + count
255
- })
256
  } else {
257
- // 如果导入的数据没有播放次数,重新计算
258
- this.playCount = {}
259
- this.history.forEach(item => {
260
- const songId = item.song.id
261
- this.playCount[songId] = (this.playCount[songId] || 0) + 1
262
- })
263
  }
264
 
265
- this.saveHistory()
266
- return true
 
 
267
  }
268
-
269
- return false
270
- } catch (error) {
271
- console.error('导入播放历史失败:', error)
272
- return false
273
  }
 
 
 
 
274
  }
275
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  })
 
1
  import { defineStore } from 'pinia'
2
+ import { ref, computed } from 'vue'
3
 
4
+ /**
5
+ * 播放历史记录 Store - 单一职责:管理用户的播放历史
6
+ * 自动记录播放过的歌曲,按时间倒序排列
7
+ */
8
+ export const useHistoryStore = defineStore('history', () => {
9
+ // 状态
10
+ const history = ref([])
11
+ const playCount = ref(new Map()) // 使用Map提高性能
12
+
13
+ // 常量
14
+ const STORAGE_KEY = 'vue-music-play-history'
15
+ const MAX_HISTORY_SIZE = 1000
16
+ const AUTO_CLEANUP_DAYS = 90 // 90天自动清理
17
+
18
+ // 计算属性
19
+ const totalPlays = computed(() => history.value.length)
20
+ const uniqueSongs = computed(() => new Set(history.value.map(item => `${item.song.source}-${item.song.id}`)).size)
21
+ const isEmpty = computed(() => history.value.length === 0)
22
+
23
+ // 获取最近播放的歌曲(去重)
24
+ const getRecentSongs = (limit = 50) => {
25
+ const songMap = new Map()
26
+ const recent = []
27
+
28
+ // 从最新的开始遍历,保持最新的记录
29
+ for (let i = history.value.length - 1; i >= 0; i--) {
30
+ const item = history.value[i]
31
+ const songKey = `${item.song.source}-${item.song.id}`
32
+ if (!songMap.has(songKey)) {
33
+ songMap.set(songKey, true)
34
+ recent.push(item)
35
+ if (recent.length >= limit) break
36
+ }
37
+ }
38
+
39
+ return recent
40
+ }
41
+
42
+ // 按日期分组的历史记录
43
+ const getGroupedByDate = () => {
44
+ const groups = {}
45
+ history.value.forEach(item => {
46
+ const date = new Date(item.timestamp).toLocaleDateString('zh-CN')
47
+ if (!groups[date]) {
48
+ groups[date] = []
49
+ }
50
+ groups[date].push(item)
51
+ })
52
+ return groups
53
+ }
54
+
55
+ // 播放统计信息
56
+ const getPlayStats = () => {
57
+ const stats = {
58
+ totalPlays: totalPlays.value,
59
+ uniqueSongs: uniqueSongs.value,
60
+ totalDuration: 0,
61
+ topArtists: new Map(),
62
+ topSongs: new Map()
63
+ }
64
 
65
+ history.value.forEach(item => {
66
+ stats.totalDuration += item.playTime || 0
 
 
67
 
68
+ // 统计艺术家
69
+ const artist = item.song.artist || '未知艺术家'
70
+ stats.topArtists.set(artist, (stats.topArtists.get(artist) || 0) + 1)
 
 
 
 
 
 
71
 
72
+ // 统计歌曲
73
+ const songKey = `${item.song.artist} - ${item.song.name}`
74
+ stats.topSongs.set(songKey, (stats.topSongs.get(songKey) || 0) + 1)
75
+ })
76
+
77
+ // 转换为排序后的数组
78
+ stats.topArtists = Array.from(stats.topArtists.entries())
79
+ .sort(([, a], [, b]) => b - a)
80
+ .slice(0, 20)
81
+
82
+ stats.topSongs = Array.from(stats.topSongs.entries())
83
+ .sort(([, a], [, b]) => b - a)
84
+ .slice(0, 50)
85
 
86
+ return stats
87
+ }
88
+
89
+ // 获取单首歌曲播放次数
90
+ const getSongPlayCount = (song) => {
91
+ if (!song || !song.id) return 0
92
+ const songKey = `${song.source}-${song.id}`
93
+ return playCount.value.get(songKey) || 0
94
+ }
95
+
96
+ // 获取热门歌曲(按播放次数排序)
97
+ const getTopPlayedSongs = (limit = 50) => {
98
+ const songCounts = new Map()
99
+ const songInfo = new Map()
100
+
101
+ // 统计每首歌的播放次数,保留最新的歌曲信息
102
+ history.value.forEach(item => {
103
+ const songKey = `${item.song.source}-${item.song.id}`
104
+ songCounts.set(songKey, (songCounts.get(songKey) || 0) + 1)
105
+ if (!songInfo.has(songKey)) {
106
+ songInfo.set(songKey, item.song)
107
  }
108
+ })
109
+
110
+ return Array.from(songCounts.entries())
111
+ .sort(([, a], [, b]) => b - a)
112
+ .slice(0, limit)
113
+ .map(([songKey, count]) => ({
114
+ song: songInfo.get(songKey),
115
+ count,
116
+ songKey
117
+ }))
118
+ .filter(item => item.song)
119
+ }
120
 
121
+ // 核心方法:添加播放记录
122
+ const addToHistory = (song, playTime = 0) => {
123
+ if (!song || !song.id) {
124
+ console.warn('无效的歌曲信息,无法添加到历史记录')
125
+ return { success: false, message: '歌曲信息无效' }
126
+ }
127
+
128
+ const historyItem = {
129
+ song: { ...song },
130
+ timestamp: Date.now(),
131
+ playTime: Math.max(0, playTime), // 播放时长(秒)
132
+ source: song.source || 'unknown'
133
+ }
134
 
135
+ // 添加到历史记录开头(最新的在前面)
136
+ history.value.unshift(historyItem)
137
 
138
+ // 更新播放次数统计
139
+ const songKey = `${song.source}-${song.id}`
140
+ playCount.value.set(songKey, (playCount.value.get(songKey) || 0) + 1)
 
141
 
142
+ // 限制历史记录数量
143
+ if (history.value.length > MAX_HISTORY_SIZE) {
144
+ history.value = history.value.slice(0, MAX_HISTORY_SIZE)
 
 
 
 
 
 
 
 
145
  }
 
146
 
147
+ saveHistory()
148
+ return { success: true, message: '已添加到播放历史' }
149
+ }
150
+
151
+ // 删除历史记录
152
+ const removeFromHistory = (index) => {
153
+ if (index < 0 || index >= history.value.length) {
154
+ return { success: false, message: '无效的索引' }
155
+ }
156
+
157
+ const removedItem = history.value.splice(index, 1)[0]
158
+
159
+ // 重新计算播放次数(简单方式:重新统计所有记录)
160
+ recalculatePlayCount()
161
+
162
+ saveHistory()
163
+ return { success: true, message: '已从播放历史移除' }
164
+ }
165
+
166
+ // 批量删除历史记录
167
+ const removeMultipleFromHistory = (indices) => {
168
+ if (!Array.isArray(indices) || indices.length === 0) {
169
+ return { success: false, message: '无效的索引数组' }
170
+ }
171
+
172
+ // 按降序排序,避免删除时索引变化
173
+ const sortedIndices = [...indices].sort((a, b) => b - a)
174
+ let removedCount = 0
175
+
176
+ sortedIndices.forEach(index => {
177
+ if (index >= 0 && index < history.value.length) {
178
+ history.value.splice(index, 1)
179
+ removedCount++
180
  }
181
+ })
182
+
183
+ if (removedCount > 0) {
184
+ recalculatePlayCount()
185
+ saveHistory()
186
+ }
187
+
188
+ return {
189
+ success: removedCount > 0,
190
+ removedCount,
191
+ message: `已删除 ${removedCount} 条播放记录`
192
+ }
193
+ }
194
+
195
+ // 清空历史记录
196
+ const clearHistory = () => {
197
+ history.value = []
198
+ playCount.value.clear()
199
+ saveHistory()
200
+ return { success: true, message: '播放历史已清空' }
201
+ }
202
+
203
+ // 按日期清理历史记录
204
+ const clearHistoryBefore = (date) => {
205
+ const targetTime = new Date(date).getTime()
206
+ if (isNaN(targetTime)) {
207
+ return { success: false, message: '无效的日期' }
208
+ }
209
+
210
+ const originalLength = history.value.length
211
+ history.value = history.value.filter(item => item.timestamp >= targetTime)
212
+ const removedCount = originalLength - history.value.length
213
+
214
+ if (removedCount > 0) {
215
+ recalculatePlayCount()
216
+ saveHistory()
217
+ }
218
+
219
+ return {
220
+ success: removedCount > 0,
221
+ removedCount,
222
+ message: `已清理 ${removedCount} 条历史记录`
223
+ }
224
+ }
225
+
226
+ // 自动清理过期记录
227
+ const autoCleanup = () => {
228
+ const cutoffTime = Date.now() - (AUTO_CLEANUP_DAYS * 24 * 60 * 60 * 1000)
229
+ return clearHistoryBefore(cutoffTime)
230
+ }
231
+
232
+ // 搜索历史记录
233
+ const searchHistory = (keyword) => {
234
+ if (!keyword || !keyword.trim()) {
235
+ return history.value
236
+ }
237
 
238
+ const lowerKeyword = keyword.toLowerCase().trim()
239
+ return history.value.filter(item =>
240
+ item.song.name.toLowerCase().includes(lowerKeyword) ||
241
+ item.song.artist.toLowerCase().includes(lowerKeyword) ||
242
+ (item.song.album && item.song.album.toLowerCase().includes(lowerKeyword))
243
+ )
244
+ }
245
+
246
+ // 按艺术家筛选
247
+ const getHistoryByArtist = (artist) => {
248
+ return history.value.filter(item =>
249
+ item.song.artist === artist
250
+ )
251
+ }
252
+
253
+ // 按时间范围筛选
254
+ const getHistoryByDateRange = (startDate, endDate) => {
255
+ const start = new Date(startDate).getTime()
256
+ const end = new Date(endDate).getTime()
257
+
258
+ if (isNaN(start) || isNaN(end)) {
259
+ return []
260
+ }
261
+
262
+ return history.value.filter(item =>
263
+ item.timestamp >= start && item.timestamp <= end
264
+ )
265
+ }
266
+
267
+ // 重新计算播放次数(用于删除记录后的统计更新)
268
+ const recalculatePlayCount = () => {
269
+ playCount.value.clear()
270
+ history.value.forEach(item => {
271
+ const songKey = `${item.song.source}-${item.song.id}`
272
+ playCount.value.set(songKey, (playCount.value.get(songKey) || 0) + 1)
273
+ })
274
+ }
275
 
276
+ // 导出播放历史
277
+ const exportHistory = () => {
278
+ try {
279
+ const data = {
280
+ history: history.value,
281
+ playCount: Object.fromEntries(playCount.value), // 转换Map为普通对象
282
+ stats: getPlayStats(),
283
+ exportTime: Date.now(),
284
+ version: '2.0',
285
+ type: 'play-history'
286
  }
287
+
288
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
289
+ type: 'application/json'
290
+ })
291
+
292
+ const url = URL.createObjectURL(blob)
293
+ const link = document.createElement('a')
294
+ link.href = url
295
+ link.download = `play-history-${new Date().toISOString().split('T')[0]}.json`
296
+ document.body.appendChild(link)
297
+ link.click()
298
+ document.body.removeChild(link)
299
+ URL.revokeObjectURL(url)
300
+
301
+ return { success: true, message: '导出成功' }
302
+ } catch (error) {
303
+ console.error('导出播放历史失败:', error)
304
+ return { success: false, message: '导出失败' }
305
+ }
306
+ }
307
+
308
+ // 保存到本地存储
309
+ const saveHistory = () => {
310
+ try {
311
+ const data = {
312
+ history: history.value,
313
+ playCount: Object.fromEntries(playCount.value), // 转换Map为普通对象保存
314
+ lastUpdated: Date.now(),
315
+ version: '2.0'
316
+ }
317
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
318
+ } catch (error) {
319
+ console.error('保存播放历史失败:', error)
320
+ }
321
+ }
322
+
323
+ // 从本地存储加载
324
+ const loadHistory = () => {
325
+ try {
326
+ const saved = localStorage.getItem(STORAGE_KEY)
327
+ if (saved) {
328
+ const data = JSON.parse(saved)
329
 
330
+ // 兼容旧版本数据格式
331
+ history.value = Array.isArray(data.history) ? data.history : []
332
 
333
+ // 处理播放次数数据(兼容新旧格式)
334
+ if (data.playCount) {
335
+ if (data.playCount instanceof Map) {
336
+ playCount.value = new Map(data.playCount)
337
+ } else if (typeof data.playCount === 'object') {
338
+ playCount.value = new Map(Object.entries(data.playCount))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  }
340
+ } else {
341
+ // 如果没有播放次数数据,重新计算
342
+ recalculatePlayCount()
343
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
 
345
+ // 自动清理过期记录
346
+ const cleanupResult = autoCleanup()
347
+ if (cleanupResult.removedCount > 0) {
348
+ console.log(`自动清理了 ${cleanupResult.removedCount} 条过期历史记录`)
349
+ }
350
  }
351
+
352
+ // 兼容旧存储key的数据迁移
353
+ const oldData = localStorage.getItem('music-history')
354
+ if (oldData && !saved) {
355
+ const data = JSON.parse(oldData)
356
+ if (Array.isArray(data.history)) {
357
+ history.value = data.history
358
+ if (data.playCount && typeof data.playCount === 'object') {
359
+ playCount.value = new Map(Object.entries(data.playCount))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  } else {
361
+ recalculatePlayCount()
 
 
 
 
 
362
  }
363
 
364
+ // 保存到新格式并删除旧数据
365
+ saveHistory()
366
+ localStorage.removeItem('music-history')
367
+ console.log('已迁移播放历史数据到新格式')
368
  }
 
 
 
 
 
369
  }
370
+ } catch (error) {
371
+ console.error('加载播放历史失败:', error)
372
+ history.value = []
373
+ playCount.value = new Map()
374
  }
375
  }
376
+
377
+ return {
378
+ // 状态
379
+ history: computed(() => history.value),
380
+
381
+ // 计算属性
382
+ totalPlays,
383
+ uniqueSongs,
384
+ isEmpty,
385
+
386
+ // 获取方法
387
+ getRecentSongs,
388
+ getGroupedByDate,
389
+ getPlayStats,
390
+ getSongPlayCount,
391
+ getTopPlayedSongs,
392
+
393
+ // 核心方法
394
+ addToHistory,
395
+ removeFromHistory,
396
+ removeMultipleFromHistory,
397
+ clearHistory,
398
+ clearHistoryBefore,
399
+ autoCleanup,
400
+
401
+ // 搜索和筛选
402
+ searchHistory,
403
+ getHistoryByArtist,
404
+ getHistoryByDateRange,
405
+
406
+ // 工具方法
407
+ recalculatePlayCount,
408
+ exportHistory,
409
+
410
+ // 存储管理
411
+ saveHistory,
412
+ loadHistory
413
+ }
414
  })
src/stores/player.js CHANGED
@@ -2,123 +2,128 @@ import { defineStore } from 'pinia'
2
  import { ref, computed } from 'vue'
3
  import { musicApi } from '@/services/musicApi'
4
 
 
 
 
 
5
  export const usePlayerStore = defineStore('player', () => {
6
- // 状态
7
  const currentSong = ref(null)
8
- const playlist = ref([])
9
- const currentIndex = ref(-1)
10
  const isPlaying = ref(false)
11
  const currentTime = ref(0)
12
  const duration = ref(0)
13
  const volume = ref(80)
14
- const playMode = ref('list') // 'list', 'random', 'single'
15
  const audioSrc = ref('')
16
  const muted = ref(false)
17
- const lastVolume = ref(80) // 记住静音前的音量
18
- const cachedCovers = ref(new Map()) // 封面缓存
19
- const cachedLyrics = ref(new Map()) // 歌词缓存
20
- const audioElement = ref(null) // 添加音频元素引用
21
  const isManualSeeking = ref(false) // 标志是否正在手动拖动进度条
22
 
 
 
 
 
 
 
 
 
 
23
  // 计算属性
24
- const hasPrevious = computed(() => currentIndex.value > 0)
25
- const hasNext = computed(() => currentIndex.value < playlist.value.length - 1)
26
  const progress = computed(() => {
27
  return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
28
  })
 
29
  const effectiveVolume = computed(() => muted.value ? 0 : volume.value)
 
30
  const canRestore = computed(() => currentSong.value && audioSrc.value)
31
 
32
- // 方法
33
- const setCurrentSong = (song, index = -1, restoreProgress = false) => {
34
- currentSong.value = song
35
- if (index >= 0) {
36
- currentIndex.value = index
37
- } else {
38
- // 在播放列表中查找歌曲
39
- const foundIndex = playlist.value.findIndex(s => s.id === song.id && s.source === song.source)
40
- currentIndex.value = foundIndex >= 0 ? foundIndex : -1
 
 
 
 
 
 
 
 
41
  }
42
 
 
 
43
  // 默认重置播放时间为0,除非明确要求恢复进度
44
  if (!restoreProgress) {
45
  currentTime.value = 0
46
  }
47
 
48
  savePlayerState()
 
49
  }
50
 
51
- const setPlaylist = (songs, startIndex = 0) => {
52
- playlist.value = songs
53
- if (songs.length > 0 && startIndex >= 0 && startIndex < songs.length) {
54
- setCurrentSong(songs[startIndex], startIndex)
55
- }
56
- savePlayerState()
57
- }
58
-
59
- const addToPlaylist = (song) => {
60
- const exists = playlist.value.some(s => s.id === song.id && s.source === song.source)
61
- if (!exists) {
62
- playlist.value.push(song)
63
- savePlayerState()
64
  }
65
- }
66
-
67
- const removeFromPlaylist = (index) => {
68
- if (index >= 0 && index < playlist.value.length) {
69
- playlist.value.splice(index, 1)
70
- if (currentIndex.value > index) {
71
- currentIndex.value--
72
- } else if (currentIndex.value === index) {
73
- if (playlist.value.length === 0) {
74
- currentSong.value = null
75
- currentIndex.value = -1
76
- } else {
77
- const newIndex = Math.min(currentIndex.value, playlist.value.length - 1)
78
- setCurrentSong(playlist.value[newIndex], newIndex)
79
  }
 
 
 
 
 
 
 
80
  }
81
- savePlayerState()
 
 
82
  }
83
  }
84
 
85
- const setPlayMode = (mode) => {
86
- playMode.value = mode
87
  savePlayerState()
88
  }
89
 
90
- const setVolume = (vol) => {
91
- const newVolume = Math.max(0, Math.min(100, vol))
92
- volume.value = newVolume
93
- if (newVolume > 0) {
94
- muted.value = false
95
- lastVolume.value = newVolume
96
- }
97
  savePlayerState()
98
  }
99
-
100
- const toggleMute = () => {
101
- if (muted.value) {
102
- muted.value = false
103
- volume.value = lastVolume.value
104
- } else {
105
- muted.value = true
106
- lastVolume.value = volume.value
107
- volume.value = 0
108
  }
109
- savePlayerState()
110
- }
111
-
112
- const increaseVolume = (step = 5) => {
113
- const newVolume = Math.min(100, volume.value + step)
114
- setVolume(newVolume)
115
  }
116
-
117
- const decreaseVolume = (step = 5) => {
118
- const newVolume = Math.max(0, volume.value - step)
119
- setVolume(newVolume)
 
120
  }
121
 
 
122
  const setPlayingState = (playing) => {
123
  isPlaying.value = playing
124
  }
@@ -143,6 +148,7 @@ export const usePlayerStore = defineStore('player', () => {
143
  audioElement.value = element
144
  }
145
 
 
146
  const seekTo = (time) => {
147
  if (time >= 0 && time <= duration.value && isFinite(time)) {
148
  // 设置手动拖动标志
@@ -159,7 +165,6 @@ export const usePlayerStore = defineStore('player', () => {
159
  if (!isPlaying.value && audioSrc.value) {
160
  audioElement.value.play().then(() => {
161
  setPlayingState(true)
162
- console.log('拖动进度条后开始播放:', time)
163
  }).catch(error => {
164
  console.log('自动播放被阻止:', error)
165
  })
@@ -176,48 +181,45 @@ export const usePlayerStore = defineStore('player', () => {
176
  return false
177
  }
178
 
179
- // 播放控制方法
180
- const togglePlay = () => {
181
- setPlayingState(!isPlaying.value)
 
 
 
 
 
182
  savePlayerState()
183
  }
184
 
185
- const previous = () => {
186
- const prevSong = playPrevious()
187
- return prevSong
 
 
 
 
 
 
 
188
  }
189
 
190
- const next = () => {
191
- const nextSong = playNext()
192
- return nextSong
193
  }
194
 
195
- const togglePlayMode = () => {
196
- const modes = ['list', 'random', 'single']
197
- const currentIndex = modes.indexOf(playMode.value)
198
- const nextIndex = (currentIndex + 1) % modes.length
199
- setPlayMode(modes[nextIndex])
200
  }
201
 
202
- const playSong = async (song, index = -1) => {
203
- setCurrentSong(song, index, false) // 不恢复进度,从0开始播放
204
- setPlayingState(false) // 先暂停,等获取到URL后再播放
205
 
206
- // 获取音频URL
207
  try {
208
- const audioUrl = await getAudioUrl(song)
209
- if (audioUrl) {
210
- setAudioSrc(audioUrl)
211
- setPlayingState(true)
212
- }
213
- } catch (error) {
214
- console.error('播放歌曲失败:', error)
215
- }
216
- }
217
-
218
- const getAudioUrl = async (song) => {
219
- try {
220
- const result = await musicApi.getMusicUrl(song.source, song.id, '320')
221
  return result?.url || null
222
  } catch (error) {
223
  console.error('获取音频URL失败:', error)
@@ -226,73 +228,33 @@ export const usePlayerStore = defineStore('player', () => {
226
  }
227
 
228
  const changeQuality = async (quality) => {
229
- // 音质切换逻辑
230
  if (currentSong.value) {
231
- const newUrl = await getAudioUrl(currentSong.value)
232
  if (newUrl) {
 
 
 
233
  setAudioSrc(newUrl)
 
 
 
 
 
 
 
 
 
 
 
 
234
  }
235
  }
236
- }
237
-
238
- // 播放控制
239
- const playNext = () => {
240
- if (!playlist.value.length) return null
241
-
242
- let nextIndex = currentIndex.value
243
-
244
- switch (playMode.value) {
245
- case 'random':
246
- nextIndex = Math.floor(Math.random() * playlist.value.length)
247
- break
248
- case 'single':
249
- // 单曲循环,保持当前歌曲
250
- break
251
- case 'list':
252
- default:
253
- nextIndex = hasNext.value ? currentIndex.value + 1 : 0
254
- break
255
- }
256
-
257
- if (nextIndex < playlist.value.length) {
258
- setCurrentSong(playlist.value[nextIndex], nextIndex, false) // 不恢复进度
259
- return playlist.value[nextIndex]
260
- }
261
- return null
262
- }
263
-
264
- const playPrevious = () => {
265
- if (!playlist.value.length) return null
266
-
267
- let prevIndex = currentIndex.value
268
-
269
- switch (playMode.value) {
270
- case 'random':
271
- prevIndex = Math.floor(Math.random() * playlist.value.length)
272
- break
273
- case 'single':
274
- // 单曲循环,保持当前歌曲
275
- break
276
- case 'list':
277
- default:
278
- prevIndex = hasPrevious.value ? currentIndex.value - 1 : playlist.value.length - 1
279
- break
280
- }
281
-
282
- if (prevIndex >= 0 && prevIndex < playlist.value.length) {
283
- setCurrentSong(playlist.value[prevIndex], prevIndex, false) // 不恢复进度
284
- return playlist.value[prevIndex]
285
- }
286
- return null
287
  }
288
 
289
  // 资源缓存管理
290
- // 使用 Map 来存储正在进行的请求,避免重复请求
291
- const pendingRequests = new Map()
292
-
293
  const getCachedCover = (song, size = 300) => {
294
  if (!song) return null
295
- // 检查多种可能的图片ID字段(与MiniPlayer和FullPlayerPage保持一致)
296
  const picId = song.pic_id || song.picId || song.albumId
297
  if (!picId) return null
298
  const key = `${song.source}-${picId}-${size}`
@@ -302,7 +264,6 @@ export const usePlayerStore = defineStore('player', () => {
302
 
303
  const setCachedCover = (song, coverUrl, size = 300) => {
304
  if (!song || !coverUrl) return
305
- // 检查多种可能的图片ID字段(与MiniPlayer和FullPlayerPage保持一致)
306
  const picId = song.pic_id || song.picId || song.albumId
307
  if (!picId) return
308
  const key = `${song.source}-${picId}-${size}`
@@ -313,11 +274,9 @@ export const usePlayerStore = defineStore('player', () => {
313
  }
314
  }
315
 
316
- // 新增:获取专辑封面(带请求去重)
317
  const getAlbumCover = async (song, size = 300) => {
318
  if (!song) return null
319
 
320
- // 检查多种可能的图片ID字段
321
  const picId = song.pic_id || song.picId || song.albumId
322
  if (!picId) return null
323
 
@@ -341,7 +300,6 @@ export const usePlayerStore = defineStore('player', () => {
341
  try {
342
  const coverUrl = await request
343
  if (coverUrl) {
344
- // 缓存结果
345
  setCachedCover(song, coverUrl, size)
346
  }
347
  return coverUrl
@@ -349,12 +307,22 @@ export const usePlayerStore = defineStore('player', () => {
349
  console.error('获取专辑封面失败:', error)
350
  return null
351
  } finally {
352
- // 清除正在进行的请求
353
  pendingRequests.delete(key)
354
  }
355
  }
356
-
357
- // 新增:获取歌词(带请求去重)
 
 
 
 
 
 
 
 
 
 
 
358
  const getLyricsWithDedup = async (song) => {
359
  if (!song) return null
360
 
@@ -378,7 +346,6 @@ export const usePlayerStore = defineStore('player', () => {
378
  try {
379
  const lyricsData = await request
380
  if (lyricsData) {
381
- // 缓存结果
382
  setCachedLyrics(song, lyricsData)
383
  }
384
  return lyricsData
@@ -386,39 +353,34 @@ export const usePlayerStore = defineStore('player', () => {
386
  console.error('获取歌词失败:', error)
387
  return { lyric: '', tlyric: '' }
388
  } finally {
389
- // 清除正在进行的请求
390
  pendingRequests.delete(key)
391
  }
392
  }
393
 
394
- const getCachedLyrics = (song) => {
395
- if (!song) return null
396
- const key = `${song.source}-${song.lyric_id || song.id}`
397
- return cachedLyrics.value.get(key) || null
398
- }
399
-
400
- const setCachedLyrics = (song, lyricsData) => {
401
- if (!song || !lyricsData) return
402
- const key = `${song.source}-${song.lyric_id || song.id}`
403
- cachedLyrics.value.set(key, lyricsData)
404
- }
405
-
406
  const clearResourceCache = () => {
407
  cachedCovers.value.clear()
408
  cachedLyrics.value.clear()
 
 
 
 
 
 
 
 
 
 
 
 
409
  }
410
 
411
  // 本地存储
412
  const savePlayerState = () => {
413
- // 构建歌曲唯一标识
414
  const songKey = currentSong.value ? `${currentSong.value.source}-${currentSong.value.id}` : null
415
 
416
  const state = {
417
  currentSong: currentSong.value,
418
- playlist: playlist.value,
419
- currentIndex: currentIndex.value,
420
  volume: volume.value,
421
- playMode: playMode.value,
422
  muted: muted.value,
423
  lastVolume: lastVolume.value,
424
  audioSrc: audioSrc.value,
@@ -432,7 +394,7 @@ export const usePlayerStore = defineStore('player', () => {
432
  }
433
 
434
  try {
435
- localStorage.setItem('vue-music-player-state', JSON.stringify(state))
436
  } catch (error) {
437
  console.error('保存播放器状态失败:', error)
438
  }
@@ -440,7 +402,7 @@ export const usePlayerStore = defineStore('player', () => {
440
 
441
  const loadPlayerState = () => {
442
  try {
443
- const saved = localStorage.getItem('vue-music-player-state')
444
  if (saved) {
445
  const state = JSON.parse(saved)
446
 
@@ -456,13 +418,10 @@ export const usePlayerStore = defineStore('player', () => {
456
 
457
  // 恢复播放器状态
458
  volume.value = state.volume !== undefined ? state.volume : 80
459
- playMode.value = state.playMode || 'list'
460
  muted.value = state.muted || false
461
  lastVolume.value = state.lastVolume || 80
462
  audioSrc.value = state.audioSrc || ''
463
  duration.value = state.duration || 0
464
- playlist.value = state.playlist || []
465
- currentIndex.value = state.currentIndex !== undefined ? state.currentIndex : -1
466
 
467
  // 智能恢复播放进度:只有歌曲匹配时才恢复进度
468
  if (state.currentSong && state.savedProgress) {
@@ -493,7 +452,7 @@ export const usePlayerStore = defineStore('player', () => {
493
 
494
  const clearPlayerState = () => {
495
  try {
496
- localStorage.removeItem('vue-music-player-state')
497
  } catch (error) {
498
  console.error('清除播放器状态失败:', error)
499
  }
@@ -504,12 +463,8 @@ export const usePlayerStore = defineStore('player', () => {
504
  if (!canRestore.value) return false
505
 
506
  try {
507
- // 重新获取音频URL(因为URL可能过期)
508
  if (currentSong.value) {
509
- const freshUrl = await musicApi.getAudioUrl(
510
- currentSong.value.source,
511
- currentSong.value.id
512
- )
513
 
514
  if (freshUrl) {
515
  audioSrc.value = freshUrl
@@ -545,65 +500,66 @@ export const usePlayerStore = defineStore('player', () => {
545
  }
546
 
547
  return {
548
- // 状态
549
- currentSong,
550
- playlist,
551
- currentIndex,
552
- isPlaying,
553
- currentTime,
554
- duration,
555
- volume,
556
- playMode,
557
- audioSrc,
558
- muted,
559
- lastVolume,
560
 
561
  // 计算属性
562
- hasPrevious,
563
- hasNext,
564
  progress,
565
  effectiveVolume,
566
  canRestore,
 
 
567
 
568
- // 方法
569
  setCurrentSong,
570
- setPlaylist,
571
- addToPlaylist,
572
- removeFromPlaylist,
573
- setPlayMode,
574
- setVolume,
575
- toggleMute,
576
- increaseVolume,
577
- decreaseVolume,
578
  setPlayingState,
579
  setCurrentTime,
580
  setDuration,
581
  setAudioSrc,
582
  setAudioElement,
 
 
583
  seekTo,
584
- togglePlay,
585
- previous,
586
- next,
587
- togglePlayMode,
588
- playSong,
 
 
 
589
  getAudioUrl,
590
  changeQuality,
591
- playNext,
592
- playPrevious,
593
- savePlayerState,
594
- loadPlayerState,
595
- clearPlayerState,
596
- restorePlaybackSession,
597
- startPeriodicSave,
598
- stopPeriodicSave,
599
 
600
- // 资源缓存管理
601
  getCachedCover,
602
  setCachedCover,
603
- getAlbumCover, // 添加这一行
604
- getLyricsWithDedup, // 新增歌词去重方法
605
  getCachedLyrics,
606
  setCachedLyrics,
607
- clearResourceCache
 
 
 
 
 
 
 
 
 
608
  }
609
  })
 
2
  import { ref, computed } from 'vue'
3
  import { musicApi } from '@/services/musicApi'
4
 
5
+ /**
6
+ * 播放器控制 Store - 单一职责:管理音频播放控制逻辑
7
+ * 不包含播放队列管理,专注于播放器状态和音频控制
8
+ */
9
  export const usePlayerStore = defineStore('player', () => {
10
+ // 播放器状态
11
  const currentSong = ref(null)
 
 
12
  const isPlaying = ref(false)
13
  const currentTime = ref(0)
14
  const duration = ref(0)
15
  const volume = ref(80)
 
16
  const audioSrc = ref('')
17
  const muted = ref(false)
18
+ const lastVolume = ref(80)
19
+ const audioElement = ref(null)
 
 
20
  const isManualSeeking = ref(false) // 标志是否正在手动拖动进度条
21
 
22
+ // 资源缓存
23
+ const cachedCovers = ref(new Map())
24
+ const cachedLyrics = ref(new Map())
25
+ const pendingRequests = new Map() // 请求去重
26
+
27
+ // 常量
28
+ const STORAGE_KEY = 'vue-music-player-state'
29
+ const VOLUME_STEP = 5
30
+
31
  // 计算属性
 
 
32
  const progress = computed(() => {
33
  return duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
34
  })
35
+
36
  const effectiveVolume = computed(() => muted.value ? 0 : volume.value)
37
+
38
  const canRestore = computed(() => currentSong.value && audioSrc.value)
39
 
40
+ const formattedCurrentTime = computed(() => formatTime(currentTime.value))
41
+ const formattedDuration = computed(() => formatTime(duration.value))
42
+
43
+ // 工具函数
44
+ const formatTime = (seconds) => {
45
+ if (!seconds || isNaN(seconds)) return '0:00'
46
+ const mins = Math.floor(seconds / 60)
47
+ const secs = Math.floor(seconds % 60)
48
+ return `${mins}:${secs.toString().padStart(2, '0')}`
49
+ }
50
+
51
+ // 播放控制方法
52
+ const setCurrentSong = (song, restoreProgress = false) => {
53
+ if (!song) {
54
+ currentSong.value = null
55
+ audioSrc.value = ''
56
+ return false
57
  }
58
 
59
+ currentSong.value = { ...song }
60
+
61
  // 默认重置播放时间为0,除非明确要求恢复进度
62
  if (!restoreProgress) {
63
  currentTime.value = 0
64
  }
65
 
66
  savePlayerState()
67
+ return true
68
  }
69
 
70
+ const playSong = async (song, restoreProgress = false) => {
71
+ if (!song || !song.id) {
72
+ console.error('无效的歌曲信息')
73
+ return { success: false, message: '歌曲信息无效' }
 
 
 
 
 
 
 
 
 
74
  }
75
+
76
+ // 设置当前歌曲
77
+ setCurrentSong(song, restoreProgress)
78
+ setPlayingState(false) // 先暂停,等获取到URL后再播放
79
+
80
+ try {
81
+ const audioUrl = await getAudioUrl(song)
82
+ if (audioUrl) {
83
+ setAudioSrc(audioUrl)
84
+
85
+ // 如果不是恢复进度模式,开始播放
86
+ if (!restoreProgress) {
87
+ setPlayingState(true)
 
88
  }
89
+
90
+ // 添加到播放历史
91
+ await addToHistory(song)
92
+
93
+ return { success: true, message: '开始播放' }
94
+ } else {
95
+ return { success: false, message: '获取音频失败' }
96
  }
97
+ } catch (error) {
98
+ console.error('播放歌曲失败:', error)
99
+ return { success: false, message: '播放失败' }
100
  }
101
  }
102
 
103
+ const togglePlay = () => {
104
+ setPlayingState(!isPlaying.value)
105
  savePlayerState()
106
  }
107
 
108
+ const pause = () => {
109
+ setPlayingState(false)
 
 
 
 
 
110
  savePlayerState()
111
  }
112
+
113
+ const resume = () => {
114
+ if (currentSong.value && audioSrc.value) {
115
+ setPlayingState(true)
116
+ savePlayerState()
 
 
 
 
117
  }
 
 
 
 
 
 
118
  }
119
+
120
+ const stop = () => {
121
+ setPlayingState(false)
122
+ setCurrentTime(0)
123
+ savePlayerState()
124
  }
125
 
126
+ // 状态设置方法
127
  const setPlayingState = (playing) => {
128
  isPlaying.value = playing
129
  }
 
148
  audioElement.value = element
149
  }
150
 
151
+ // 进度控制
152
  const seekTo = (time) => {
153
  if (time >= 0 && time <= duration.value && isFinite(time)) {
154
  // 设置手动拖动标志
 
165
  if (!isPlaying.value && audioSrc.value) {
166
  audioElement.value.play().then(() => {
167
  setPlayingState(true)
 
168
  }).catch(error => {
169
  console.log('自动播放被阻止:', error)
170
  })
 
181
  return false
182
  }
183
 
184
+ // 音量控制
185
+ const setVolume = (vol) => {
186
+ const newVolume = Math.max(0, Math.min(100, vol))
187
+ volume.value = newVolume
188
+ if (newVolume > 0) {
189
+ muted.value = false
190
+ lastVolume.value = newVolume
191
+ }
192
  savePlayerState()
193
  }
194
 
195
+ const toggleMute = () => {
196
+ if (muted.value) {
197
+ muted.value = false
198
+ volume.value = lastVolume.value
199
+ } else {
200
+ muted.value = true
201
+ lastVolume.value = volume.value
202
+ volume.value = 0
203
+ }
204
+ savePlayerState()
205
  }
206
 
207
+ const increaseVolume = (step = VOLUME_STEP) => {
208
+ const newVolume = Math.min(100, volume.value + step)
209
+ setVolume(newVolume)
210
  }
211
 
212
+ const decreaseVolume = (step = VOLUME_STEP) => {
213
+ const newVolume = Math.max(0, volume.value - step)
214
+ setVolume(newVolume)
 
 
215
  }
216
 
217
+ // 音频URL获取
218
+ const getAudioUrl = async (song, quality = '320') => {
219
+ if (!song || !song.id) return null
220
 
 
221
  try {
222
+ const result = await musicApi.getMusicUrl(song.source, song.id, quality)
 
 
 
 
 
 
 
 
 
 
 
 
223
  return result?.url || null
224
  } catch (error) {
225
  console.error('获取音频URL失败:', error)
 
228
  }
229
 
230
  const changeQuality = async (quality) => {
 
231
  if (currentSong.value) {
232
+ const newUrl = await getAudioUrl(currentSong.value, quality)
233
  if (newUrl) {
234
+ const wasPlaying = isPlaying.value
235
+ const currentTimeBackup = currentTime.value
236
+
237
  setAudioSrc(newUrl)
238
+
239
+ // 恢复播放状态和进度
240
+ if (wasPlaying) {
241
+ setTimeout(() => {
242
+ if (audioElement.value) {
243
+ audioElement.value.currentTime = currentTimeBackup
244
+ setPlayingState(true)
245
+ }
246
+ }, 100)
247
+ }
248
+
249
+ return { success: true, message: '音质切换成功' }
250
  }
251
  }
252
+ return { success: false, message: '音质切换失败' }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  }
254
 
255
  // 资源缓存管理
 
 
 
256
  const getCachedCover = (song, size = 300) => {
257
  if (!song) return null
 
258
  const picId = song.pic_id || song.picId || song.albumId
259
  if (!picId) return null
260
  const key = `${song.source}-${picId}-${size}`
 
264
 
265
  const setCachedCover = (song, coverUrl, size = 300) => {
266
  if (!song || !coverUrl) return
 
267
  const picId = song.pic_id || song.picId || song.albumId
268
  if (!picId) return
269
  const key = `${song.source}-${picId}-${size}`
 
274
  }
275
  }
276
 
 
277
  const getAlbumCover = async (song, size = 300) => {
278
  if (!song) return null
279
 
 
280
  const picId = song.pic_id || song.picId || song.albumId
281
  if (!picId) return null
282
 
 
300
  try {
301
  const coverUrl = await request
302
  if (coverUrl) {
 
303
  setCachedCover(song, coverUrl, size)
304
  }
305
  return coverUrl
 
307
  console.error('获取专辑封面失败:', error)
308
  return null
309
  } finally {
 
310
  pendingRequests.delete(key)
311
  }
312
  }
313
+
314
+ const getCachedLyrics = (song) => {
315
+ if (!song) return null
316
+ const key = `${song.source}-${song.lyric_id || song.id}`
317
+ return cachedLyrics.value.get(key) || null
318
+ }
319
+
320
+ const setCachedLyrics = (song, lyricsData) => {
321
+ if (!song || !lyricsData) return
322
+ const key = `${song.source}-${song.lyric_id || song.id}`
323
+ cachedLyrics.value.set(key, lyricsData)
324
+ }
325
+
326
  const getLyricsWithDedup = async (song) => {
327
  if (!song) return null
328
 
 
346
  try {
347
  const lyricsData = await request
348
  if (lyricsData) {
 
349
  setCachedLyrics(song, lyricsData)
350
  }
351
  return lyricsData
 
353
  console.error('获取歌词失败:', error)
354
  return { lyric: '', tlyric: '' }
355
  } finally {
 
356
  pendingRequests.delete(key)
357
  }
358
  }
359
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  const clearResourceCache = () => {
361
  cachedCovers.value.clear()
362
  cachedLyrics.value.clear()
363
+ pendingRequests.clear()
364
+ }
365
+
366
+ // 添加到播放历史
367
+ const addToHistory = async (song) => {
368
+ try {
369
+ const { useHistoryStore } = await import('@/stores/history')
370
+ const historyStore = useHistoryStore()
371
+ historyStore.addToHistory(song)
372
+ } catch (error) {
373
+ console.error('添加播放历史失败:', error)
374
+ }
375
  }
376
 
377
  // 本地存储
378
  const savePlayerState = () => {
 
379
  const songKey = currentSong.value ? `${currentSong.value.source}-${currentSong.value.id}` : null
380
 
381
  const state = {
382
  currentSong: currentSong.value,
 
 
383
  volume: volume.value,
 
384
  muted: muted.value,
385
  lastVolume: lastVolume.value,
386
  audioSrc: audioSrc.value,
 
394
  }
395
 
396
  try {
397
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
398
  } catch (error) {
399
  console.error('保存播放器状态失败:', error)
400
  }
 
402
 
403
  const loadPlayerState = () => {
404
  try {
405
+ const saved = localStorage.getItem(STORAGE_KEY)
406
  if (saved) {
407
  const state = JSON.parse(saved)
408
 
 
418
 
419
  // 恢复播放器状态
420
  volume.value = state.volume !== undefined ? state.volume : 80
 
421
  muted.value = state.muted || false
422
  lastVolume.value = state.lastVolume || 80
423
  audioSrc.value = state.audioSrc || ''
424
  duration.value = state.duration || 0
 
 
425
 
426
  // 智能恢复播放进度:只有歌曲匹配时才恢复进度
427
  if (state.currentSong && state.savedProgress) {
 
452
 
453
  const clearPlayerState = () => {
454
  try {
455
+ localStorage.removeItem(STORAGE_KEY)
456
  } catch (error) {
457
  console.error('清除播放器状态失败:', error)
458
  }
 
463
  if (!canRestore.value) return false
464
 
465
  try {
 
466
  if (currentSong.value) {
467
+ const freshUrl = await getAudioUrl(currentSong.value)
 
 
 
468
 
469
  if (freshUrl) {
470
  audioSrc.value = freshUrl
 
500
  }
501
 
502
  return {
503
+ // 播放器状态
504
+ currentSong: computed(() => currentSong.value),
505
+ isPlaying: computed(() => isPlaying.value),
506
+ currentTime: computed(() => currentTime.value),
507
+ duration: computed(() => duration.value),
508
+ volume: computed(() => volume.value),
509
+ audioSrc: computed(() => audioSrc.value),
510
+ muted: computed(() => muted.value),
511
+ lastVolume: computed(() => lastVolume.value),
 
 
 
512
 
513
  // 计算属性
 
 
514
  progress,
515
  effectiveVolume,
516
  canRestore,
517
+ formattedCurrentTime,
518
+ formattedDuration,
519
 
520
+ // 播放控制
521
  setCurrentSong,
522
+ playSong,
523
+ togglePlay,
524
+ pause,
525
+ resume,
526
+ stop,
527
+
528
+ // 状态设置
 
529
  setPlayingState,
530
  setCurrentTime,
531
  setDuration,
532
  setAudioSrc,
533
  setAudioElement,
534
+
535
+ // 进度控制
536
  seekTo,
537
+
538
+ // 音量控制
539
+ setVolume,
540
+ toggleMute,
541
+ increaseVolume,
542
+ decreaseVolume,
543
+
544
+ // 音频管理
545
  getAudioUrl,
546
  changeQuality,
 
 
 
 
 
 
 
 
547
 
548
+ // 资源缓存
549
  getCachedCover,
550
  setCachedCover,
551
+ getAlbumCover,
 
552
  getCachedLyrics,
553
  setCachedLyrics,
554
+ getLyricsWithDedup,
555
+ clearResourceCache,
556
+
557
+ // 存储管理
558
+ savePlayerState,
559
+ loadPlayerState,
560
+ clearPlayerState,
561
+ restorePlaybackSession,
562
+ startPeriodicSave,
563
+ stopPeriodicSave
564
  }
565
  })
src/stores/playlist.js ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from 'pinia'
2
+ import { ref, computed } from 'vue'
3
+
4
+ /**
5
+ * 歌单管理 Store
6
+ * 管理用户创建的歌单集合,不包含播放队列逻辑
7
+ */
8
+ export const usePlaylistStore = defineStore('playlist', () => {
9
+ // 状态
10
+ const playlists = ref([])
11
+ const currentViewingPlaylist = ref(null) // 当前查看的歌单(用于详情页)
12
+
13
+ // 常量
14
+ const MAX_SONGS_PER_PLAYLIST = 500 // 每个歌单最多歌曲数
15
+ const STORAGE_KEY = 'vue-music-playlists'
16
+
17
+ // 计算属性
18
+ const totalPlaylists = computed(() => playlists.value.length)
19
+
20
+ const totalSongs = computed(() => {
21
+ return playlists.value.reduce((total, playlist) => total + playlist.songs.length, 0)
22
+ })
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(),
32
+ updatedAt: Date.now(),
33
+ cover: null, // 使用第一首歌的封面
34
+ isDefault: false // 用户创建的歌单
35
+ }
36
+
37
+ playlists.value.unshift(playlist) // 最新的在前面
38
+ saveToStorage()
39
+ return playlist
40
+ }
41
+
42
+ const deletePlaylist = (playlistId) => {
43
+ const index = playlists.value.findIndex(p => p.id === playlistId)
44
+ if (index === -1) return false
45
+
46
+ playlists.value.splice(index, 1)
47
+
48
+ // 如果删除的是当前查看的歌单,清空当前状态
49
+ if (currentViewingPlaylist.value?.id === playlistId) {
50
+ currentViewingPlaylist.value = null
51
+ }
52
+
53
+ saveToStorage()
54
+ return true
55
+ }
56
+
57
+ const updatePlaylist = (playlistId, updates) => {
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()
64
+ })
65
+
66
+ saveToStorage()
67
+ return true
68
+ }
69
+
70
+ const getPlaylist = (playlistId) => {
71
+ return playlists.value.find(p => p.id === playlistId) || null
72
+ }
73
+
74
+ // 歌曲管理方法
75
+ const addSongToPlaylist = (playlistId, song) => {
76
+ const playlist = getPlaylist(playlistId)
77
+ if (!playlist) {
78
+ return { success: false, message: '歌单不存在' }
79
+ }
80
+
81
+ // 检查数量限制
82
+ if (playlist.songs.length >= MAX_SONGS_PER_PLAYLIST) {
83
+ return { success: false, message: `歌单最多只能添加 ${MAX_SONGS_PER_PLAYLIST} 首歌曲` }
84
+ }
85
+
86
+ // 防重复:检查歌曲是否已存在
87
+ const exists = playlist.songs.some(s =>
88
+ s.id === song.id && s.source === song.source
89
+ )
90
+ if (exists) {
91
+ return { success: false, message: '歌曲已存在于该歌单中' }
92
+ }
93
+
94
+ // 添加歌曲
95
+ const songWithMeta = {
96
+ ...song,
97
+ addedAt: Date.now()
98
+ }
99
+
100
+ playlist.songs.push(songWithMeta)
101
+ playlist.updatedAt = Date.now()
102
+
103
+ // 更新封面(使用第一首歌的封面)
104
+ if (playlist.songs.length === 1 && song.pic_id) {
105
+ playlist.cover = `https://music-api.gdstudio.xyz/api.php?types=pic&source=${song.source}&id=${song.pic_id}&size=300`
106
+ }
107
+
108
+ saveToStorage()
109
+ return { success: true, message: '添加成功' }
110
+ }
111
+
112
+ const removeSongFromPlaylist = (playlistId, songId, songSource) => {
113
+ const playlist = getPlaylist(playlistId)
114
+ if (!playlist) return false
115
+
116
+ const index = playlist.songs.findIndex(s =>
117
+ s.id === songId && s.source === songSource
118
+ )
119
+
120
+ if (index === -1) return false
121
+
122
+ playlist.songs.splice(index, 1)
123
+ playlist.updatedAt = Date.now()
124
+
125
+ // 如果删除的是第一首歌且还有其他歌曲,更新封面
126
+ if (index === 0 && playlist.songs.length > 0) {
127
+ const firstSong = playlist.songs[0]
128
+ playlist.cover = firstSong.pic_id
129
+ ? `https://music-api.gdstudio.xyz/api.php?types=pic&source=${firstSong.source}&id=${firstSong.pic_id}&size=300`
130
+ : null
131
+ } else if (playlist.songs.length === 0) {
132
+ playlist.cover = null
133
+ }
134
+
135
+ saveToStorage()
136
+ return true
137
+ }
138
+
139
+ const isSongInPlaylist = (playlistId, song) => {
140
+ const playlist = getPlaylist(playlistId)
141
+ if (!playlist) return false
142
+
143
+ return playlist.songs.some(s =>
144
+ s.id === song.id && s.source === song.source
145
+ )
146
+ }
147
+
148
+ // 批量操作
149
+ const addSongsToPlaylist = (playlistId, songs) => {
150
+ const playlist = getPlaylist(playlistId)
151
+ if (!playlist) {
152
+ return { success: false, addedCount: 0, message: '歌单不存在' }
153
+ }
154
+
155
+ let addedCount = 0
156
+ const errors = []
157
+
158
+ for (const song of songs) {
159
+ const result = addSongToPlaylist(playlistId, song)
160
+ if (result.success) {
161
+ addedCount++
162
+ } else {
163
+ errors.push(result.message)
164
+ }
165
+ }
166
+
167
+ const message = addedCount > 0
168
+ ? `成功添加 ${addedCount} 首歌曲`
169
+ : '没有歌曲被添加'
170
+
171
+ return {
172
+ success: addedCount > 0,
173
+ addedCount,
174
+ message,
175
+ errors
176
+ }
177
+ }
178
+
179
+ // 存储方法
180
+ const saveToStorage = () => {
181
+ try {
182
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(playlists.value))
183
+ } catch (error) {
184
+ console.error('保存歌单失败:', error)
185
+ }
186
+ }
187
+
188
+ const loadFromStorage = () => {
189
+ try {
190
+ const saved = localStorage.getItem(STORAGE_KEY)
191
+ if (saved) {
192
+ const data = JSON.parse(saved)
193
+ if (Array.isArray(data)) {
194
+ playlists.value = data
195
+ }
196
+ }
197
+ } catch (error) {
198
+ console.error('加载歌单失败:', error)
199
+ playlists.value = []
200
+ }
201
+ }
202
+
203
+ // 清理方法
204
+ const clearAllPlaylists = () => {
205
+ playlists.value = []
206
+ currentViewingPlaylist.value = null
207
+ saveToStorage()
208
+ }
209
+
210
+ return {
211
+ // 状态
212
+ playlists: computed(() => playlists.value),
213
+ currentViewingPlaylist,
214
+
215
+ // 常量
216
+ MAX_SONGS_PER_PLAYLIST,
217
+
218
+ // 计算属性
219
+ totalPlaylists,
220
+ totalSongs,
221
+
222
+ // 歌单管理
223
+ createPlaylist,
224
+ deletePlaylist,
225
+ updatePlaylist,
226
+ getPlaylist,
227
+
228
+ // 歌曲管理
229
+ addSongToPlaylist,
230
+ removeSongFromPlaylist,
231
+ isSongInPlaylist,
232
+ addSongsToPlaylist,
233
+
234
+ // 存储管理
235
+ saveToStorage,
236
+ loadFromStorage,
237
+ clearAllPlaylists
238
+ }
239
+ })
src/stores/playqueue.js ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineStore } from 'pinia'
2
+ import { ref, computed } from 'vue'
3
+
4
+ /**
5
+ * 播放队列 Store - 单一职责:管理当前播放器的临时队列
6
+ * 对应网易云音乐的 "Now Playing" 概念
7
+ */
8
+ export const usePlayQueueStore = defineStore('playqueue', () => {
9
+ // 状态
10
+ const queue = ref([])
11
+ const currentIndex = ref(-1)
12
+ const playMode = ref('list') // 'list', 'random', 'single'
13
+
14
+ // 常量
15
+ const STORAGE_KEY = 'vue-music-play-queue'
16
+ const MAX_QUEUE_SIZE = 2000
17
+
18
+ // 计算属性
19
+ const currentSong = computed(() => {
20
+ return currentIndex.value >= 0 && currentIndex.value < queue.value.length
21
+ ? queue.value[currentIndex.value]
22
+ : null
23
+ })
24
+
25
+ const queueLength = computed(() => queue.value.length)
26
+ const isEmpty = computed(() => queue.value.length === 0)
27
+ const hasPrevious = computed(() => currentIndex.value > 0)
28
+ const hasNext = computed(() => currentIndex.value < queue.value.length - 1)
29
+
30
+ // 队列管理方法
31
+ const setQueue = (songs, startIndex = 0) => {
32
+ if (!Array.isArray(songs)) {
33
+ console.error('队列必须是数组')
34
+ return false
35
+ }
36
+
37
+ queue.value = songs.slice(0, MAX_QUEUE_SIZE)
38
+ currentIndex.value = Math.max(0, Math.min(startIndex, queue.value.length - 1))
39
+ saveQueue()
40
+ return true
41
+ }
42
+
43
+ const addToQueue = (song, position = 'end') => {
44
+ if (!song || !song.id) {
45
+ return { success: false, message: '歌曲信息无效' }
46
+ }
47
+
48
+ // 防重复:检查歌曲是否已在队列中
49
+ const exists = queue.value.some(s => s.id === song.id && s.source === song.source)
50
+ if (exists) {
51
+ return { success: false, message: '歌曲已在播放队列中' }
52
+ }
53
+
54
+ if (queue.value.length >= MAX_QUEUE_SIZE) {
55
+ return { success: false, message: `播放队列最多只能添加 ${MAX_QUEUE_SIZE} 首歌曲` }
56
+ }
57
+
58
+ switch (position) {
59
+ case 'next':
60
+ // 插入到当前播放歌曲的下一个位置
61
+ const insertIndex = Math.min(currentIndex.value + 1, queue.value.length)
62
+ queue.value.splice(insertIndex, 0, { ...song })
63
+ break
64
+ case 'start':
65
+ queue.value.unshift({ ...song })
66
+ if (currentIndex.value >= 0) {
67
+ currentIndex.value++
68
+ }
69
+ break
70
+ case 'end':
71
+ default:
72
+ queue.value.push({ ...song })
73
+ break
74
+ }
75
+
76
+ saveQueue()
77
+ return { success: true, message: '已添加到播放队列' }
78
+ }
79
+
80
+ const removeFromQueue = (index) => {
81
+ if (index < 0 || index >= queue.value.length) {
82
+ return { success: false, message: '无效的索引' }
83
+ }
84
+
85
+ queue.value.splice(index, 1)
86
+
87
+ // 调整当前播放索引
88
+ if (currentIndex.value > index) {
89
+ currentIndex.value--
90
+ } else if (currentIndex.value === index) {
91
+ // 如果删除的是当前播放的歌曲
92
+ if (queue.value.length === 0) {
93
+ currentIndex.value = -1
94
+ } else {
95
+ currentIndex.value = Math.min(currentIndex.value, queue.value.length - 1)
96
+ }
97
+ }
98
+
99
+ saveQueue()
100
+ return { success: true, message: '已从播放队列移除' }
101
+ }
102
+
103
+ const clearQueue = () => {
104
+ queue.value = []
105
+ currentIndex.value = -1
106
+ saveQueue()
107
+ }
108
+
109
+ // 播放控制方法
110
+ const playAtIndex = (index) => {
111
+ if (index < 0 || index >= queue.value.length) {
112
+ return null
113
+ }
114
+
115
+ currentIndex.value = index
116
+ saveQueue()
117
+ return queue.value[index]
118
+ }
119
+
120
+ const playNext = () => {
121
+ if (queue.value.length === 0) return null
122
+
123
+ let nextIndex = currentIndex.value
124
+
125
+ switch (playMode.value) {
126
+ case 'random':
127
+ // 随机播放:生成与当前不同的随机索引
128
+ if (queue.value.length > 1) {
129
+ do {
130
+ nextIndex = Math.floor(Math.random() * queue.value.length)
131
+ } while (nextIndex === currentIndex.value)
132
+ }
133
+ break
134
+ case 'single':
135
+ // 单曲循环:保持当前索引
136
+ break
137
+ case 'list':
138
+ default:
139
+ // 顺序播放:下一首,到末尾则循环到开头
140
+ nextIndex = hasNext.value ? currentIndex.value + 1 : 0
141
+ break
142
+ }
143
+
144
+ return playAtIndex(nextIndex)
145
+ }
146
+
147
+ const playPrevious = () => {
148
+ if (queue.value.length === 0) return null
149
+
150
+ let prevIndex = currentIndex.value
151
+
152
+ switch (playMode.value) {
153
+ case 'random':
154
+ // 随机播放:生成与当前不同的随机索引
155
+ if (queue.value.length > 1) {
156
+ do {
157
+ prevIndex = Math.floor(Math.random() * queue.value.length)
158
+ } while (prevIndex === currentIndex.value)
159
+ }
160
+ break
161
+ case 'single':
162
+ // 单曲循环:保持当前索引
163
+ break
164
+ case 'list':
165
+ default:
166
+ // 顺序播放:上一首,到开头则循环到末尾
167
+ prevIndex = hasPrevious.value ? currentIndex.value - 1 : queue.value.length - 1
168
+ break
169
+ }
170
+
171
+ return playAtIndex(prevIndex)
172
+ }
173
+
174
+ const setPlayMode = (mode) => {
175
+ const validModes = ['list', 'random', 'single']
176
+ if (validModes.includes(mode)) {
177
+ playMode.value = mode
178
+ saveQueue()
179
+ return true
180
+ }
181
+ return false
182
+ }
183
+
184
+ const togglePlayMode = () => {
185
+ const modes = ['list', 'random', 'single']
186
+ const currentIndex = modes.indexOf(playMode.value)
187
+ const nextIndex = (currentIndex + 1) % modes.length
188
+ setPlayMode(modes[nextIndex])
189
+ return playMode.value
190
+ }
191
+
192
+ // 队列操作方法
193
+ const moveInQueue = (fromIndex, toIndex) => {
194
+ if (fromIndex < 0 || fromIndex >= queue.value.length ||
195
+ toIndex < 0 || toIndex >= queue.value.length) {
196
+ return false
197
+ }
198
+
199
+ const item = queue.value.splice(fromIndex, 1)[0]
200
+ queue.value.splice(toIndex, 0, item)
201
+
202
+ // 调整当前播放索引
203
+ if (currentIndex.value === fromIndex) {
204
+ currentIndex.value = toIndex
205
+ } else if (fromIndex < currentIndex.value && toIndex >= currentIndex.value) {
206
+ currentIndex.value--
207
+ } else if (fromIndex > currentIndex.value && toIndex <= currentIndex.value) {
208
+ currentIndex.value++
209
+ }
210
+
211
+ saveQueue()
212
+ return true
213
+ }
214
+
215
+ const shuffleQueue = () => {
216
+ if (queue.value.length <= 1) return
217
+
218
+ const currentSong = currentSong.value
219
+
220
+ // Fisher-Yates洗牌算法
221
+ for (let i = queue.value.length - 1; i > 0; i--) {
222
+ const j = Math.floor(Math.random() * (i + 1))
223
+ ;[queue.value[i], queue.value[j]] = [queue.value[j], queue.value[i]]
224
+ }
225
+
226
+ // 如果有当前播放的歌曲,找到它的新位置
227
+ if (currentSong) {
228
+ const newIndex = queue.value.findIndex(song =>
229
+ song.id === currentSong.id && song.source === currentSong.source
230
+ )
231
+ if (newIndex >= 0) {
232
+ currentIndex.value = newIndex
233
+ }
234
+ }
235
+
236
+ saveQueue()
237
+ }
238
+
239
+ // 批量操作
240
+ const addMultipleToQueue = (songs, position = 'end') => {
241
+ let successCount = 0
242
+ const results = []
243
+
244
+ for (const song of songs) {
245
+ const result = addToQueue(song, position)
246
+ results.push(result)
247
+ if (result.success) successCount++
248
+ }
249
+
250
+ return {
251
+ success: successCount > 0,
252
+ successCount,
253
+ totalCount: songs.length,
254
+ results
255
+ }
256
+ }
257
+
258
+ // 搜索功能
259
+ const searchInQueue = (keyword) => {
260
+ if (!keyword || !keyword.trim()) {
261
+ return queue.value.map((song, index) => ({ song, index }))
262
+ }
263
+
264
+ const lowerKeyword = keyword.toLowerCase().trim()
265
+ return queue.value
266
+ .map((song, index) => ({ song, index }))
267
+ .filter(({ song }) =>
268
+ song.name.toLowerCase().includes(lowerKeyword) ||
269
+ song.artist.toLowerCase().includes(lowerKeyword) ||
270
+ (song.album && song.album.toLowerCase().includes(lowerKeyword))
271
+ )
272
+ }
273
+
274
+ // 存储管理
275
+ const saveQueue = () => {
276
+ try {
277
+ const data = {
278
+ queue: queue.value,
279
+ currentIndex: currentIndex.value,
280
+ playMode: playMode.value,
281
+ lastUpdated: Date.now(),
282
+ version: '1.0'
283
+ }
284
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
285
+ } catch (error) {
286
+ console.error('保存播放队列失败:', error)
287
+ }
288
+ }
289
+
290
+ const loadQueue = () => {
291
+ try {
292
+ const saved = localStorage.getItem(STORAGE_KEY)
293
+ if (saved) {
294
+ const data = JSON.parse(saved)
295
+
296
+ // 检查数据完整性
297
+ if (Array.isArray(data.queue)) {
298
+ queue.value = data.queue
299
+ currentIndex.value = data.currentIndex >= 0 ? data.currentIndex : -1
300
+ playMode.value = data.playMode || 'list'
301
+ }
302
+ }
303
+ } catch (error) {
304
+ console.error('加载播放队列失败:', error)
305
+ queue.value = []
306
+ currentIndex.value = -1
307
+ playMode.value = 'list'
308
+ }
309
+ }
310
+
311
+ return {
312
+ // 状态
313
+ queue: computed(() => queue.value),
314
+ currentIndex: computed(() => currentIndex.value),
315
+ currentSong,
316
+ playMode: computed(() => playMode.value),
317
+
318
+ // 计算属性
319
+ queueLength,
320
+ isEmpty,
321
+ hasPrevious,
322
+ hasNext,
323
+
324
+ // 队列管理
325
+ setQueue,
326
+ addToQueue,
327
+ removeFromQueue,
328
+ clearQueue,
329
+
330
+ // 播放控制
331
+ playAtIndex,
332
+ playNext,
333
+ playPrevious,
334
+ setPlayMode,
335
+ togglePlayMode,
336
+
337
+ // 队列操作
338
+ moveInQueue,
339
+ shuffleQueue,
340
+
341
+ // 批量操作
342
+ addMultipleToQueue,
343
+
344
+ // 搜索功能
345
+ searchInQueue,
346
+
347
+ // 存储管理
348
+ saveQueue,
349
+ loadQueue
350
+ }
351
+ })
src/views/CurrentPlaylistPage.vue ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="current-playlist-page">
3
+ <!-- 头部 -->
4
+ <div class="page-header">
5
+ <button class="back-btn" @click="goBack">
6
+ <i class="fas fa-arrow-left"></i>
7
+ </button>
8
+
9
+ <div class="header-info">
10
+ <h1 class="page-title">当前播放</h1>
11
+ <p class="queue-info">共 {{ currentPlaylist.length }} 首</p>
12
+ </div>
13
+
14
+ <div class="header-actions">
15
+ <button class="action-btn" @click="togglePlayMode">
16
+ <i :class="playModeIcon"></i>
17
+ </button>
18
+ <button class="action-btn" @click="showMoreActions">
19
+ <i class="fas fa-ellipsis-v"></i>
20
+ </button>
21
+ </div>
22
+ </div>
23
+
24
+ <!-- 播放控制 -->
25
+ <div class="play-controls" v-if="currentPlaylist.length > 0">
26
+ <button class="control-btn play-all" @click="playAll">
27
+ <i class="fas fa-play"></i>
28
+ 播放全部
29
+ </button>
30
+ <button class="control-btn shuffle" @click="shufflePlay">
31
+ <i class="fas fa-random"></i>
32
+ 随机播放
33
+ </button>
34
+ <button
35
+ class="control-btn edit"
36
+ :class="{ active: editMode }"
37
+ @click="toggleEditMode"
38
+ >
39
+ <i :class="editMode ? 'fas fa-check' : 'fas fa-edit'"></i>
40
+ {{ editMode ? '完成' : '管理' }}
41
+ </button>
42
+ </div>
43
+
44
+ <!-- 歌曲列表 -->
45
+ <div class="songs-content">
46
+ <div v-if="!currentPlaylist.length" class="empty-queue">
47
+ <i class="fas fa-music"></i>
48
+ <p>暂无播放内容</p>
49
+ <p class="empty-tip">去首页找一些好听的歌曲吧</p>
50
+ <button class="go-home-btn" @click="goHome">
51
+ <i class="fas fa-home"></i>
52
+ 去首页
53
+ </button>
54
+ </div>
55
+
56
+ <div v-else class="songs-list">
57
+ <div
58
+ v-for="(song, index) in currentPlaylist"
59
+ :key="`${song.id}-${index}`"
60
+ class="song-item"
61
+ :class="{
62
+ 'current': isCurrentSong(song, index),
63
+ 'edit-mode': editMode
64
+ }"
65
+ >
66
+ <!-- 拖拽把手 -->
67
+ <div
68
+ v-if="editMode"
69
+ class="drag-handle"
70
+ @mousedown="startDrag(index)"
71
+ @touchstart="startDrag(index)"
72
+ >
73
+ <i class="fas fa-grip-vertical"></i>
74
+ </div>
75
+
76
+ <!-- 序号/播放状态 -->
77
+ <div class="song-index" @click="playSongAtIndex(index)">
78
+ <template v-if="isCurrentSong(song, index) && playerStore.isPlaying">
79
+ <i class="fas fa-volume-up playing-icon"></i>
80
+ </template>
81
+ <template v-else>
82
+ {{ String(index + 1).padStart(2, '0') }}
83
+ </template>
84
+ </div>
85
+
86
+ <!-- 歌曲信息 -->
87
+ <div class="song-info" @click="playSongAtIndex(index)">
88
+ <div class="song-name">{{ song.name }}</div>
89
+ <div class="song-artist">
90
+ {{ formatArtist(song.artist) }} - {{ song.album }}
91
+ </div>
92
+ </div>
93
+
94
+ <!-- 操作按钮 -->
95
+ <div class="song-actions">
96
+ <button
97
+ v-if="!editMode"
98
+ class="action-btn favorite-btn"
99
+ :class="{ active: favoritesStore.isFavorite(song) }"
100
+ @click.stop="toggleFavorite(song)"
101
+ >
102
+ <i :class="favoritesStore.isFavorite(song) ? 'fas fa-heart' : 'far fa-heart'"></i>
103
+ </button>
104
+
105
+ <button
106
+ v-if="!editMode"
107
+ class="action-btn more-btn"
108
+ @click.stop="showSongMenu(song, index)"
109
+ >
110
+ <i class="fas fa-ellipsis-v"></i>
111
+ </button>
112
+
113
+ <button
114
+ v-if="editMode"
115
+ class="action-btn remove-btn"
116
+ @click.stop="removeSong(index)"
117
+ >
118
+ <i class="fas fa-times"></i>
119
+ </button>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- 更多操作菜单 -->
126
+ <div v-if="showActions" class="actions-overlay" @click="hideActions">
127
+ <div class="actions-menu" @click.stop>
128
+ <button @click="clearQueue" class="danger">
129
+ <i class="fas fa-trash"></i>
130
+ 清空播放队列
131
+ </button>
132
+ <button @click="saveAsPlaylist">
133
+ <i class="fas fa-save"></i>
134
+ 保存为歌单
135
+ </button>
136
+ </div>
137
+ </div>
138
+
139
+ <!-- 歌曲菜单 -->
140
+ <div v-if="showSongActions" class="actions-overlay" @click="hideSongActions">
141
+ <div class="actions-menu" @click.stop>
142
+ <button @click="playNext">
143
+ <i class="fas fa-step-forward"></i>
144
+ 下一首播放
145
+ </button>
146
+ <button @click="addToPlaylist">
147
+ <i class="fas fa-plus"></i>
148
+ 添加到歌单
149
+ </button>
150
+ <button @click="removeSongFromMenu" class="danger">
151
+ <i class="fas fa-minus"></i>
152
+ 从队列��除
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- 确认对话框 -->
158
+ <ConfirmDialog
159
+ ref="confirmDialogRef"
160
+ :title="confirmTitle"
161
+ :message="confirmMessage"
162
+ confirm-text="确认"
163
+ type="danger"
164
+ @confirm="confirmAction"
165
+ />
166
+ </div>
167
+ </template>
168
+
169
+ <script setup>
170
+ import { ref, computed } from 'vue'
171
+ import { useRouter } from 'vue-router'
172
+ import { usePlayerStore } from '@/stores/player'
173
+ import { useFavoritesStore } from '@/stores/favorites'
174
+ import { usePlayQueueStore } from '@/stores/playqueue'
175
+ import { useToastStore } from '@/stores/toast'
176
+ import { utils } from '@/services/musicApi'
177
+ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
178
+
179
+ const router = useRouter()
180
+ const playerStore = usePlayerStore()
181
+ const favoritesStore = useFavoritesStore()
182
+ const playQueueStore = usePlayQueueStore()
183
+ const toastStore = useToastStore()
184
+
185
+ // 响应式数据
186
+ const editMode = ref(false)
187
+ const showActions = ref(false)
188
+ const showSongActions = ref(false)
189
+ const selectedSong = ref(null)
190
+ const selectedIndex = ref(-1)
191
+ const confirmDialogRef = ref(null)
192
+ const confirmTitle = ref('')
193
+ const confirmMessage = ref('')
194
+ const pendingAction = ref(null)
195
+
196
+ // 计算属性
197
+ const currentPlaylist = computed(() => playQueueStore.queue || [])
198
+
199
+ const playModeIcon = computed(() => {
200
+ switch (playerStore.playMode) {
201
+ case 'list':
202
+ return 'fas fa-redo'
203
+ case 'single':
204
+ return 'fas fa-redo-alt'
205
+ case 'random':
206
+ return 'fas fa-random'
207
+ default:
208
+ return 'fas fa-redo'
209
+ }
210
+ })
211
+
212
+ const isCurrentSong = (song, index) => {
213
+ return playerStore.currentIndex === index
214
+ }
215
+
216
+ // 方法
217
+ const goBack = () => {
218
+ router.go(-1)
219
+ }
220
+
221
+ const goHome = () => {
222
+ router.push('/home')
223
+ }
224
+
225
+ const formatArtist = (artist) => {
226
+ return utils.formatArtist ? utils.formatArtist(artist) : artist
227
+ }
228
+
229
+ const togglePlayMode = () => {
230
+ playerStore.togglePlayMode()
231
+
232
+ const modeNames = {
233
+ 'list': '列表循环',
234
+ 'single': '单曲循环',
235
+ 'random': '随机播放'
236
+ }
237
+
238
+ toastStore.info(`已切换到${modeNames[playerStore.playMode]}`)
239
+ }
240
+
241
+ const playAll = () => {
242
+ if (currentPlaylist.value.length > 0) {
243
+ playerStore.playSong(currentPlaylist.value[0], 0)
244
+ }
245
+ }
246
+
247
+ const shufflePlay = () => {
248
+ if (currentPlaylist.value.length > 0) {
249
+ // 随机选择一首歌开始播放
250
+ const randomIndex = Math.floor(Math.random() * currentPlaylist.value.length)
251
+ playerStore.setPlayMode('random')
252
+ playerStore.playSong(currentPlaylist.value[randomIndex], randomIndex)
253
+ toastStore.info('开始随机播放')
254
+ }
255
+ }
256
+
257
+ const toggleEditMode = () => {
258
+ editMode.value = !editMode.value
259
+ if (!editMode.value) {
260
+ hideActions()
261
+ hideSongActions()
262
+ }
263
+ }
264
+
265
+ const playSongAtIndex = (index) => {
266
+ if (editMode.value) return
267
+
268
+ const song = currentPlaylist.value[index]
269
+ if (song) {
270
+ playerStore.playSong(song, index)
271
+ }
272
+ }
273
+
274
+ const toggleFavorite = async (song) => {
275
+ try {
276
+ await favoritesStore.toggleFavorite(song)
277
+ } catch (error) {
278
+ toastStore.error('操作失败')
279
+ }
280
+ }
281
+
282
+ const removeSong = (index) => {
283
+ if (currentPlaylist.value.length <= 1) {
284
+ toastStore.error('播放队列至少要保留一首歌曲')
285
+ return
286
+ }
287
+
288
+ playerStore.removeFromPlaylist(index)
289
+ toastStore.success('已从播放队列移除')
290
+ }
291
+
292
+ // 菜单操作
293
+ const showMoreActions = () => {
294
+ showActions.value = true
295
+ }
296
+
297
+ const hideActions = () => {
298
+ showActions.value = false
299
+ }
300
+
301
+ const showSongMenu = (song, index) => {
302
+ selectedSong.value = song
303
+ selectedIndex.value = index
304
+ showSongActions.value = true
305
+ }
306
+
307
+ const hideSongActions = () => {
308
+ showSongActions.value = false
309
+ selectedSong.value = null
310
+ selectedIndex.value = -1
311
+ }
312
+
313
+ const clearQueue = () => {
314
+ hideActions()
315
+ confirmTitle.value = '清空播放队列'
316
+ confirmMessage.value = '确定要清空当前播放队列吗?此操作不可撤销。'
317
+ pendingAction.value = () => {
318
+ playQueueStore.clearQueue()
319
+ toastStore.success('播放队列已清空')
320
+ editMode.value = false
321
+ }
322
+ confirmDialogRef.value?.show()
323
+ }
324
+
325
+ const saveAsPlaylist = () => {
326
+ hideActions()
327
+ // TODO: 实现保存为歌单功能
328
+ toastStore.info('功能开发中...')
329
+ }
330
+
331
+ const playNext = () => {
332
+ if (selectedSong.value) {
333
+ playerStore.playNext(selectedSong.value)
334
+ toastStore.success(`"${selectedSong.value.name}" 已添加到下一首播放`)
335
+ }
336
+ hideSongActions()
337
+ }
338
+
339
+ const addToPlaylist = () => {
340
+ // TODO: 实现添加到歌单功能
341
+ hideSongActions()
342
+ toastStore.info('功能开发中...')
343
+ }
344
+
345
+ const removeSongFromMenu = () => {
346
+ hideSongActions()
347
+ removeSong(selectedIndex.value)
348
+ }
349
+
350
+ const confirmAction = () => {
351
+ if (pendingAction.value) {
352
+ pendingAction.value()
353
+ pendingAction.value = null
354
+ }
355
+ }
356
+
357
+ // 拖拽功能(简化实现)
358
+ const startDrag = (index) => {
359
+ // TODO: 实现拖拽排序功能
360
+ toastStore.info('拖拽排序功能开发中...')
361
+ }
362
+ </script>
363
+
364
+ <style scoped>
365
+ .current-playlist-page {
366
+ height: 100%;
367
+ background: var(--bg-primary);
368
+ overflow-y: auto;
369
+ padding-bottom: calc(var(--mini-player-height) + var(--tabbar-height) + 20px);
370
+ }
371
+
372
+ .page-header {
373
+ display: flex;
374
+ align-items: center;
375
+ gap: 16px;
376
+ padding: 16px;
377
+ background: var(--bg-card);
378
+ margin: 16px;
379
+ border-radius: var(--radius-small);
380
+ border: 1px solid var(--border-light);
381
+ }
382
+
383
+ .back-btn {
384
+ width: 40px;
385
+ height: 40px;
386
+ border: none;
387
+ background: rgba(255, 255, 255, 0.1);
388
+ color: var(--text-secondary);
389
+ border-radius: 50%;
390
+ cursor: pointer;
391
+ display: flex;
392
+ align-items: center;
393
+ justify-content: center;
394
+ transition: var(--transition-fast);
395
+ }
396
+
397
+ .back-btn:hover {
398
+ background: rgba(255, 255, 255, 0.2);
399
+ color: var(--text-primary);
400
+ }
401
+
402
+ .header-info {
403
+ flex: 1;
404
+ }
405
+
406
+ .page-title {
407
+ font-size: 20px;
408
+ font-weight: 700;
409
+ color: var(--text-primary);
410
+ margin: 0 0 4px;
411
+ }
412
+
413
+ .queue-info {
414
+ font-size: 13px;
415
+ color: var(--text-secondary);
416
+ margin: 0;
417
+ }
418
+
419
+ .header-actions {
420
+ display: flex;
421
+ gap: 8px;
422
+ }
423
+
424
+ .action-btn {
425
+ width: 40px;
426
+ height: 40px;
427
+ border: none;
428
+ background: rgba(255, 255, 255, 0.1);
429
+ color: var(--text-secondary);
430
+ border-radius: 50%;
431
+ cursor: pointer;
432
+ display: flex;
433
+ align-items: center;
434
+ justify-content: center;
435
+ transition: var(--transition-fast);
436
+ }
437
+
438
+ .action-btn:hover {
439
+ background: rgba(255, 255, 255, 0.2);
440
+ color: var(--text-primary);
441
+ }
442
+
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 {
454
+ display: flex;
455
+ align-items: center;
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);
463
+ }
464
+
465
+ .control-btn.play-all {
466
+ background: var(--accent-red);
467
+ color: white;
468
+ }
469
+
470
+ .control-btn.play-all:hover {
471
+ background: var(--accent-red-hover);
472
+ }
473
+
474
+ .control-btn.shuffle,
475
+ .control-btn.edit {
476
+ background: rgba(255, 255, 255, 0.1);
477
+ color: var(--text-secondary);
478
+ }
479
+
480
+ .control-btn.shuffle:hover,
481
+ .control-btn.edit:hover {
482
+ background: rgba(255, 255, 255, 0.2);
483
+ color: var(--text-primary);
484
+ }
485
+
486
+ .control-btn.edit.active {
487
+ background: var(--accent-red);
488
+ color: white;
489
+ }
490
+
491
+ .empty-queue {
492
+ display: flex;
493
+ flex-direction: column;
494
+ align-items: center;
495
+ justify-content: center;
496
+ padding: 60px 40px;
497
+ text-align: center;
498
+ color: var(--text-tertiary);
499
+ }
500
+
501
+ .empty-queue i {
502
+ font-size: 64px;
503
+ margin-bottom: 20px;
504
+ opacity: 0.5;
505
+ }
506
+
507
+ .empty-queue p {
508
+ font-size: 16px;
509
+ margin-bottom: 8px;
510
+ }
511
+
512
+ .empty-tip {
513
+ font-size: 14px;
514
+ color: var(--text-secondary);
515
+ margin-bottom: 24px;
516
+ }
517
+
518
+ .go-home-btn {
519
+ display: flex;
520
+ align-items: center;
521
+ gap: 8px;
522
+ padding: 12px 24px;
523
+ border: none;
524
+ background: var(--accent-red);
525
+ color: white;
526
+ border-radius: 20px;
527
+ font-size: 14px;
528
+ cursor: pointer;
529
+ transition: var(--transition-fast);
530
+ }
531
+
532
+ .go-home-btn:hover {
533
+ background: var(--accent-red-hover);
534
+ }
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
+
544
+ .song-item {
545
+ display: flex;
546
+ align-items: center;
547
+ padding: 12px 16px;
548
+ border-bottom: 1px solid var(--border-lighter);
549
+ transition: var(--transition-fast);
550
+ position: relative;
551
+ }
552
+
553
+ .song-item:last-child {
554
+ border-bottom: none;
555
+ }
556
+
557
+ .song-item:hover {
558
+ background: rgba(255, 255, 255, 0.05);
559
+ }
560
+
561
+ .song-item.current {
562
+ background: rgba(255, 107, 107, 0.1);
563
+ border-left: 3px solid var(--accent-red);
564
+ }
565
+
566
+ .song-item.edit-mode {
567
+ padding-left: 50px;
568
+ }
569
+
570
+ .drag-handle {
571
+ position: absolute;
572
+ left: 16px;
573
+ color: var(--text-tertiary);
574
+ cursor: grab;
575
+ }
576
+
577
+ .drag-handle:active {
578
+ cursor: grabbing;
579
+ }
580
+
581
+ .song-index {
582
+ width: 32px;
583
+ height: 32px;
584
+ display: flex;
585
+ align-items: center;
586
+ justify-content: center;
587
+ font-size: 12px;
588
+ font-weight: 600;
589
+ color: var(--text-secondary);
590
+ margin-right: 12px;
591
+ cursor: pointer;
592
+ border-radius: 4px;
593
+ transition: var(--transition-fast);
594
+ }
595
+
596
+ .song-index:hover {
597
+ background: rgba(255, 255, 255, 0.1);
598
+ }
599
+
600
+ .song-item.current .song-index {
601
+ color: var(--accent-red);
602
+ }
603
+
604
+ .playing-icon {
605
+ color: var(--accent-red);
606
+ animation: pulse 1.5s infinite;
607
+ }
608
+
609
+ .song-info {
610
+ flex: 1;
611
+ min-width: 0;
612
+ margin-right: 12px;
613
+ cursor: pointer;
614
+ }
615
+
616
+ .song-name {
617
+ font-size: 15px;
618
+ font-weight: 500;
619
+ color: var(--text-primary);
620
+ margin-bottom: 4px;
621
+ overflow: hidden;
622
+ text-overflow: ellipsis;
623
+ white-space: nowrap;
624
+ }
625
+
626
+ .song-item.current .song-name {
627
+ color: var(--accent-red);
628
+ }
629
+
630
+ .song-artist {
631
+ font-size: 13px;
632
+ color: var(--text-secondary);
633
+ overflow: hidden;
634
+ text-overflow: ellipsis;
635
+ white-space: nowrap;
636
+ }
637
+
638
+ .song-actions {
639
+ display: flex;
640
+ gap: 4px;
641
+ }
642
+
643
+ .favorite-btn.active {
644
+ color: var(--accent-red);
645
+ }
646
+
647
+ .remove-btn {
648
+ background: rgba(255, 68, 68, 0.1);
649
+ color: #ff4444;
650
+ }
651
+
652
+ .remove-btn:hover {
653
+ background: rgba(255, 68, 68, 0.2);
654
+ }
655
+
656
+ .actions-overlay {
657
+ position: fixed;
658
+ top: 0;
659
+ left: 0;
660
+ right: 0;
661
+ bottom: 0;
662
+ background: rgba(0, 0, 0, 0.6);
663
+ backdrop-filter: blur(4px);
664
+ z-index: 2000;
665
+ display: flex;
666
+ align-items: center;
667
+ justify-content: center;
668
+ }
669
+
670
+ .actions-menu {
671
+ background: var(--bg-card);
672
+ border-radius: 12px;
673
+ padding: 8px 0;
674
+ min-width: 200px;
675
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
676
+ border: 1px solid var(--border-light);
677
+ }
678
+
679
+ .actions-menu button {
680
+ display: flex;
681
+ align-items: center;
682
+ gap: 12px;
683
+ width: 100%;
684
+ padding: 12px 20px;
685
+ border: none;
686
+ background: transparent;
687
+ color: var(--text-primary);
688
+ font-size: 14px;
689
+ cursor: pointer;
690
+ transition: var(--transition-fast);
691
+ }
692
+
693
+ .actions-menu button:hover {
694
+ background: rgba(255, 255, 255, 0.1);
695
+ }
696
+
697
+ .actions-menu button.danger {
698
+ color: #ff4444;
699
+ }
700
+
701
+ @keyframes pulse {
702
+ 0%, 100% { opacity: 1; }
703
+ 50% { opacity: 0.5; }
704
+ }
705
+
706
+ /* 响应式 */
707
+ @media (max-width: 375px) {
708
+ .page-header {
709
+ padding: 12px;
710
+ margin: 12px;
711
+ }
712
+
713
+ .play-controls {
714
+ padding: 12px;
715
+ margin: 0 12px 12px;
716
+ }
717
+
718
+ .songs-list {
719
+ margin: 0 12px;
720
+ }
721
+ }
722
+ </style>
src/views/FavoritesPage.vue ADDED
@@ -0,0 +1,921 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="favorites-page">
3
+ <!-- 头部 -->
4
+ <div class="page-header">
5
+ <h1 class="page-title">
6
+ <i class="fas fa-heart"></i>
7
+ 我喜欢的音乐
8
+ </h1>
9
+ <div class="header-actions">
10
+ <button v-if="!isEmpty" class="action-btn" @click="playAll">
11
+ <i class="fas fa-play"></i>
12
+ </button>
13
+ <button v-if="!isEmpty" class="action-btn" @click="showBatchActions = !showBatchActions">
14
+ <i class="fas fa-tasks"></i>
15
+ </button>
16
+ </div>
17
+ </div>
18
+
19
+ <!-- 搜索框 -->
20
+ <div v-if="!isEmpty" class="search-section">
21
+ <div class="search-box">
22
+ <i class="fas fa-search"></i>
23
+ <input
24
+ v-model="searchKeyword"
25
+ type="text"
26
+ placeholder="在我喜欢的音乐中搜索..."
27
+ class="search-input"
28
+ />
29
+ <button v-if="searchKeyword" @click="clearSearch" class="clear-btn">
30
+ <i class="fas fa-times"></i>
31
+ </button>
32
+ </div>
33
+ </div>
34
+
35
+ <!-- 批量操作栏 -->
36
+ <div v-if="showBatchActions && !isEmpty" class="batch-actions">
37
+ <div class="batch-selection">
38
+ <label class="checkbox-wrapper">
39
+ <input
40
+ type="checkbox"
41
+ :checked="isAllSelected"
42
+ @change="toggleSelectAll"
43
+ />
44
+ <span class="checkmark"></span>
45
+ <span class="checkbox-label">
46
+ {{ selectedCount > 0 ? `已选择 ${selectedCount} 首` : '全选' }}
47
+ </span>
48
+ </label>
49
+ </div>
50
+ <div v-if="selectedCount > 0" class="batch-buttons">
51
+ <button class="batch-btn" @click="addSelectedToPlaylist">
52
+ <i class="fas fa-plus"></i>
53
+ </button>
54
+ <button class="batch-btn remove-btn" @click="removeSelected">
55
+ <i class="fas fa-trash"></i>
56
+ </button>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- 歌曲列表 -->
61
+ <div class="content-area">
62
+ <div v-if="isEmpty" class="empty-state">
63
+ <i class="fas fa-heart-broken"></i>
64
+ <p>还没有收藏的歌曲</p>
65
+ <p class="empty-tip">点击歌曲右侧的红心按钮来收藏喜欢的音乐</p>
66
+ <button class="discover-btn" @click="goToHome">
67
+ <i class="fas fa-search"></i>
68
+ 去发现音乐
69
+ </button>
70
+ </div>
71
+
72
+ <div v-else class="favorites-list">
73
+ <div
74
+ v-for="(favorite, index) in displayedFavorites"
75
+ :key="`${favorite.song.id}-${favorite.favoriteTime}`"
76
+ class="favorite-item"
77
+ :class="{ selected: selectedItems.has(favorite.song.id) }"
78
+ >
79
+ <!-- 批量选择复选框 -->
80
+ <div v-if="showBatchActions" class="item-checkbox">
81
+ <label class="checkbox-wrapper">
82
+ <input
83
+ type="checkbox"
84
+ :checked="selectedItems.has(favorite.song.id)"
85
+ @change="toggleItemSelection(favorite.song)"
86
+ />
87
+ <span class="checkmark"></span>
88
+ </label>
89
+ </div>
90
+
91
+ <!-- 歌曲信息 -->
92
+ <div class="song-info" @click="playSong(favorite.song, index)">
93
+ <div class="song-cover">
94
+ <img
95
+ :src="getSongCover()"
96
+ :alt="favorite.song.name"
97
+ :data-song-data="JSON.stringify(favorite.song)"
98
+ @error="handleImageError"
99
+ />
100
+ <div class="play-overlay">
101
+ <i class="fas fa-play"></i>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="song-details">
106
+ <h3 class="song-name">{{ favorite.song.name }}</h3>
107
+ <p class="song-meta">
108
+ <span class="artist">{{ formatArtist(favorite.song.artist) }}</span>
109
+ </p>
110
+ </div>
111
+ </div>
112
+
113
+ <!-- 操作按钮 -->
114
+ <div class="song-actions">
115
+ <button class="action-btn favorite-btn active" @click="toggleFavorite(favorite.song)">
116
+ <i class="fas fa-heart"></i>
117
+ </button>
118
+ <button class="action-btn more-btn" @click="handleShowMoreActions(favorite.song, $event)">
119
+ <i class="fas fa-ellipsis-h"></i>
120
+ </button>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <!-- 更多操作菜单 -->
127
+ <MoreActionsPanel
128
+ v-if="showMoreActions && selectedSong"
129
+ :song="selectedSong"
130
+ @action="handleMoreAction"
131
+ @close="showMoreActions = false"
132
+ />
133
+
134
+ <!-- 确认对话框 -->
135
+ <ConfirmDialog
136
+ ref="confirmDialogRef"
137
+ :title="confirmDialog.title"
138
+ :message="confirmDialog.message"
139
+ :confirm-text="confirmDialog.confirmText"
140
+ :type="confirmDialog.type"
141
+ @confirm="handleConfirm"
142
+ />
143
+ </div>
144
+ </template>
145
+
146
+ <script setup>
147
+ import { ref, computed, onMounted, watch } from 'vue'
148
+ import { useRouter } from 'vue-router'
149
+ import { useFavoritesStore } from '@/stores/favorites'
150
+ import { usePlayerStore } from '@/stores/player'
151
+ import { usePlayQueueStore } from '@/stores/playqueue'
152
+ import { usePlaylistStore } from '@/stores/playlist'
153
+ import { useToastStore } from '@/stores/toast'
154
+ import { useSongCoverLoader } from '@/composables/useSongCoverLoader'
155
+ import { utils } from '@/services/musicApi'
156
+ import MoreActionsPanel from '@/components/player/MoreActionsPanel.vue'
157
+ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
158
+
159
+ const router = useRouter()
160
+ const favoritesStore = useFavoritesStore()
161
+ const playerStore = usePlayerStore()
162
+ const playQueueStore = usePlayQueueStore()
163
+ const playlistStore = usePlaylistStore()
164
+ const toastStore = useToastStore()
165
+ const { getDefaultCover, handleImageError, observeImage } = useSongCoverLoader()
166
+
167
+ // 响应式数据
168
+ const searchKeyword = ref('')
169
+ const showBatchActions = ref(false)
170
+ const selectedItems = ref(new Set())
171
+ const selectedSong = ref(null)
172
+ const showMoreActions = ref(false)
173
+ const showPlaylistSelector = ref(false)
174
+ const coverUrls = ref(new Map()) // 存储每首歌的封面URL
175
+ const moreActionsRef = ref(null)
176
+ const confirmDialogRef = ref(null)
177
+
178
+ // 确认对话框状态
179
+ const confirmDialog = ref({
180
+ title: '',
181
+ message: '',
182
+ confirmText: '确定',
183
+ type: 'warning',
184
+ action: null,
185
+ data: null
186
+ })
187
+
188
+ // 计算属性
189
+ const favorites = computed(() => favoritesStore.favorites)
190
+ const totalFavorites = computed(() => favoritesStore.totalFavorites)
191
+ const isEmpty = computed(() => favoritesStore.isEmpty)
192
+
193
+ // 搜索后的收藏列表
194
+ const displayedFavorites = computed(() => {
195
+ if (!searchKeyword.value.trim()) {
196
+ return favorites.value
197
+ }
198
+ return favoritesStore.searchFavorites(searchKeyword.value)
199
+ })
200
+
201
+ // 总时长计算
202
+ // 批量选择相关
203
+ const selectedCount = computed(() => selectedItems.value.size)
204
+ const isAllSelected = computed(() => {
205
+ return displayedFavorites.value.length > 0 &&
206
+ selectedCount.value === displayedFavorites.value.length
207
+ })
208
+
209
+ // 方法
210
+ // 图片加载相关
211
+ const getSongCover = () => {
212
+ return getDefaultCover()
213
+ }
214
+
215
+ // 初始化懒加载观察
216
+ const initLazyImages = () => {
217
+ const imageElements = document.querySelectorAll('.favorite-item .song-cover img')
218
+ imageElements.forEach((img, index) => {
219
+ const songData = img.dataset.songData
220
+ if (songData) {
221
+ try {
222
+ const song = JSON.parse(songData)
223
+ observeImage(img, song)
224
+ } catch (error) {
225
+ console.error('解析歌曲数据失败:', error)
226
+ }
227
+ }
228
+ })
229
+ }
230
+
231
+ // 格式化艺术家名字
232
+ const formatArtist = (artist) => {
233
+ return utils.formatArtist(artist)
234
+ }
235
+
236
+ const clearSearch = () => {
237
+ searchKeyword.value = ''
238
+ }
239
+
240
+ const goToHome = () => {
241
+ router.push('/home')
242
+ }
243
+
244
+ // 播放相关方法
245
+ const playSong = async (song, index) => {
246
+ if (showBatchActions.value) {
247
+ toggleItemSelection(song)
248
+ return
249
+ }
250
+
251
+ // 将所有收藏的歌曲添加到播放队列
252
+ const songs = displayedFavorites.value.map(fav => fav.song)
253
+ const result = playQueueStore.setQueue(songs, index)
254
+
255
+ if (result) {
256
+ // 播放选中的歌曲
257
+ await playerStore.playSong(song)
258
+ toastStore.success(`开始播放 "${song.name}"`)
259
+ } else {
260
+ toastStore.error('播放失败')
261
+ }
262
+ }
263
+
264
+ const playAll = async () => {
265
+ if (isEmpty.value) return
266
+
267
+ const songs = displayedFavorites.value.map(fav => fav.song)
268
+ const result = playQueueStore.setQueue(songs, 0)
269
+
270
+ if (result && songs.length > 0) {
271
+ await playerStore.playSong(songs[0])
272
+ toastStore.success(`开始播放全部 ${songs.length} 首歌曲`)
273
+ } else {
274
+ toastStore.error('播放失败')
275
+ }
276
+ }
277
+
278
+ // 收藏操作
279
+ const toggleFavorite = async (song) => {
280
+ const result = await favoritesStore.removeFromFavorites(song)
281
+ if (result.success) {
282
+ toastStore.success(result.message)
283
+ // 如果当前选中了这首歌,从选中列表中移除
284
+ selectedItems.value.delete(song.id)
285
+ } else {
286
+ toastStore.error(result.message)
287
+ }
288
+ }
289
+
290
+ // 批量操作
291
+ const toggleSelectAll = () => {
292
+ if (isAllSelected.value) {
293
+ selectedItems.value.clear()
294
+ } else {
295
+ selectedItems.value = new Set(displayedFavorites.value.map(fav => fav.song.id))
296
+ }
297
+ }
298
+
299
+ const toggleItemSelection = (song) => {
300
+ if (selectedItems.value.has(song.id)) {
301
+ selectedItems.value.delete(song.id)
302
+ } else {
303
+ selectedItems.value.add(song.id)
304
+ }
305
+ }
306
+
307
+ const playSelected = async () => {
308
+ const selectedSongs = displayedFavorites.value
309
+ .filter(fav => selectedItems.value.has(fav.song.id))
310
+ .map(fav => fav.song)
311
+
312
+ if (selectedSongs.length === 0) return
313
+
314
+ const result = playQueueStore.setQueue(selectedSongs, 0)
315
+ if (result && selectedSongs.length > 0) {
316
+ await playerStore.playSong(selectedSongs[0])
317
+ toastStore.success(`开始播放选中的 ${selectedSongs.length} 首歌曲`)
318
+ selectedItems.value.clear()
319
+ showBatchActions.value = false
320
+ } else {
321
+ toastStore.error('播放失败')
322
+ }
323
+ }
324
+
325
+ const addSelectedToPlaylist = async () => {
326
+ if (selectedCount.value === 0) return
327
+
328
+ const selectedSongs = Array.from(selectedItems.value).map(id =>
329
+ favorites.value.find(fav => fav.song.id === id)?.song
330
+ ).filter(song => song)
331
+
332
+ if (selectedSongs.length === 0) return
333
+
334
+ // 获取所有可用歌单
335
+ const playlists = playlistStore.playlists
336
+
337
+ if (!playlists || playlists.length === 0) {
338
+ toastStore.info('还没有创建歌单,请先创建一个歌单')
339
+ return
340
+ }
341
+
342
+ // 简单实现:添加到第一个歌单
343
+ if (playlists.length === 1) {
344
+ const result = playlistStore.addSongsToPlaylist(playlists[0].id, selectedSongs)
345
+ if (result.success) {
346
+ toastStore.success(`已将 ${selectedSongs.length} 首歌曲添加到"${playlists[0].name}"`)
347
+ selectedItems.value.clear()
348
+ showBatchActions.value = false
349
+ } else {
350
+ toastStore.error(result.message || '添加失败')
351
+ }
352
+ } else {
353
+ // 多个歌单,弹出选择器
354
+ showPlaylistSelector.value = true
355
+ }
356
+ }
357
+
358
+ const removeSelected = () => {
359
+ if (selectedCount.value === 0) return
360
+
361
+ confirmDialog.value = {
362
+ title: '批量取消收藏',
363
+ message: `确定要取消收藏选中的 ${selectedCount.value} 首歌曲吗?`,
364
+ confirmText: '取消收藏',
365
+ type: 'warning',
366
+ action: 'remove-selected',
367
+ data: Array.from(selectedItems.value)
368
+ }
369
+
370
+ confirmDialogRef.value?.show()
371
+ }
372
+
373
+ // 更多操作
374
+ const handleShowMoreActions = (song, event) => {
375
+ selectedSong.value = song
376
+ showMoreActions.value = true
377
+ }
378
+
379
+ const handleMoreAction = async (action) => {
380
+ if (!selectedSong.value) return
381
+
382
+ const song = selectedSong.value
383
+
384
+ switch (action) {
385
+ case 'favorite':
386
+ await toggleFavorite(song)
387
+ break
388
+
389
+ case 'addToPlaylist':
390
+ // 添加到播放队列
391
+ try {
392
+ const result = playQueueStore.addToQueue(song, 'last')
393
+ if (result.success) {
394
+ toastStore.success(`"${song.name}" 已添加到播放队列`)
395
+ } else {
396
+ toastStore.error(result.message || '添加失败')
397
+ }
398
+ } catch (error) {
399
+ console.error('添加到播放队列失败:', error)
400
+ toastStore.error('添加到播放队列失败')
401
+ }
402
+ break
403
+
404
+ case 'download':
405
+ // TODO: 实现下载
406
+ toastStore.info('下载功能开发中...')
407
+ break
408
+ }
409
+
410
+ showMoreActions.value = false
411
+ }
412
+
413
+ // 确认对话框处理
414
+ const handleConfirm = async () => {
415
+ const { action, data } = confirmDialog.value
416
+
417
+ switch (action) {
418
+ case 'remove-selected':
419
+ let removedCount = 0
420
+ for (const songId of data) {
421
+ const song = displayedFavorites.value.find(fav => fav.song.id === songId)?.song
422
+ if (song) {
423
+ const result = await favoritesStore.removeFromFavorites(song)
424
+ if (result.success) {
425
+ removedCount++
426
+ }
427
+ }
428
+ }
429
+
430
+ if (removedCount > 0) {
431
+ toastStore.success(`已取消收藏 ${removedCount} 首歌曲`)
432
+ selectedItems.value.clear()
433
+ showBatchActions.value = false
434
+ }
435
+ break
436
+ }
437
+ }
438
+
439
+ // 监听搜索关键词变化,清空选择
440
+ watch(searchKeyword, () => {
441
+ selectedItems.value.clear()
442
+ })
443
+
444
+ // 生命周期
445
+ onMounted(async () => {
446
+ // 加载收藏数据
447
+ favoritesStore.loadFavorites()
448
+
449
+ // 初始化懒加载
450
+ setTimeout(() => {
451
+ initLazyImages()
452
+ }, 100)
453
+ })
454
+ </script>
455
+
456
+ <style scoped>
457
+ .favorites-page {
458
+ height: 100%;
459
+ background: var(--bg-primary);
460
+ overflow-y: auto;
461
+ }
462
+
463
+ .page-header {
464
+ display: flex;
465
+ align-items: center;
466
+ justify-content: space-between;
467
+ padding: 16px;
468
+ border-bottom: 1px solid var(--border-lighter);
469
+ }
470
+
471
+ .page-title {
472
+ display: flex;
473
+ align-items: center;
474
+ gap: 12px;
475
+ font-size: 24px;
476
+ font-weight: 700;
477
+ color: var(--text-primary);
478
+ margin: 0;
479
+ }
480
+
481
+ .page-title i {
482
+ color: var(--accent-red);
483
+ }
484
+
485
+ .header-actions {
486
+ display: flex;
487
+ gap: 8px;
488
+ }
489
+
490
+ .action-btn {
491
+ display: flex;
492
+ align-items: center;
493
+ gap: 6px;
494
+ padding: 8px 16px;
495
+ border: none;
496
+ background: var(--accent-red);
497
+ color: white;
498
+ border-radius: 20px;
499
+ font-size: 14px;
500
+ font-weight: 500;
501
+ cursor: pointer;
502
+ transition: var(--transition-fast);
503
+ }
504
+
505
+ .action-btn:hover {
506
+ background: var(--accent-red-hover);
507
+ transform: translateY(-1px);
508
+ }
509
+
510
+ .stats-card {
511
+ display: flex;
512
+ gap: 24px;
513
+ padding: 20px 16px;
514
+ background: var(--bg-card);
515
+ margin: 16px;
516
+ border-radius: var(--radius-medium);
517
+ border: 1px solid var(--border-light);
518
+ }
519
+
520
+ .stat-item {
521
+ text-align: center;
522
+ }
523
+
524
+ .stat-number {
525
+ font-size: 20px;
526
+ font-weight: 700;
527
+ color: var(--accent-red);
528
+ margin-bottom: 4px;
529
+ }
530
+
531
+ .stat-label {
532
+ font-size: 12px;
533
+ color: var(--text-secondary);
534
+ }
535
+
536
+ .search-section {
537
+ padding: 0 16px 16px;
538
+ }
539
+
540
+ .search-box {
541
+ position: relative;
542
+ display: flex;
543
+ align-items: center;
544
+ background: var(--bg-card);
545
+ border-radius: 25px;
546
+ padding: 12px 16px;
547
+ border: 1px solid var(--border-light);
548
+ }
549
+
550
+ .search-box i {
551
+ color: var(--text-secondary);
552
+ margin-right: 12px;
553
+ }
554
+
555
+ .search-input {
556
+ flex: 1;
557
+ border: none;
558
+ background: transparent;
559
+ color: var(--text-primary);
560
+ font-size: 14px;
561
+ outline: none;
562
+ }
563
+
564
+ .search-input::placeholder {
565
+ color: var(--text-tertiary);
566
+ }
567
+
568
+ .clear-btn {
569
+ border: none;
570
+ background: transparent;
571
+ color: var(--text-secondary);
572
+ cursor: pointer;
573
+ padding: 4px;
574
+ border-radius: 50%;
575
+ transition: var(--transition-fast);
576
+ }
577
+
578
+ .clear-btn:hover {
579
+ background: rgba(255, 255, 255, 0.1);
580
+ color: var(--text-primary);
581
+ }
582
+
583
+ .batch-actions {
584
+ display: flex;
585
+ align-items: center;
586
+ justify-content: space-between;
587
+ padding: 12px 16px;
588
+ background: var(--bg-card);
589
+ border-top: 1px solid var(--border-light);
590
+ border-bottom: 1px solid var(--border-light);
591
+ }
592
+
593
+ .checkbox-wrapper {
594
+ display: flex;
595
+ align-items: center;
596
+ gap: 8px;
597
+ cursor: pointer;
598
+ }
599
+
600
+ .checkbox-wrapper input {
601
+ display: none;
602
+ }
603
+
604
+ .checkmark {
605
+ width: 18px;
606
+ height: 18px;
607
+ border: 2px solid var(--border-strong);
608
+ border-radius: 4px;
609
+ position: relative;
610
+ transition: var(--transition-fast);
611
+ }
612
+
613
+ .checkbox-wrapper input:checked + .checkmark {
614
+ background: var(--accent-red);
615
+ border-color: var(--accent-red);
616
+ }
617
+
618
+ .checkbox-wrapper input:checked + .checkmark::after {
619
+ content: '';
620
+ position: absolute;
621
+ left: 5px;
622
+ top: 2px;
623
+ width: 4px;
624
+ height: 8px;
625
+ border: solid white;
626
+ border-width: 0 2px 2px 0;
627
+ transform: rotate(45deg);
628
+ }
629
+
630
+ .checkbox-label {
631
+ font-size: 14px;
632
+ color: var(--text-secondary);
633
+ }
634
+
635
+ .batch-buttons {
636
+ display: flex;
637
+ gap: 8px;
638
+ }
639
+
640
+ .batch-btn {
641
+ display: flex;
642
+ align-items: center;
643
+ gap: 4px;
644
+ padding: 6px 12px;
645
+ border: none;
646
+ background: rgba(255, 255, 255, 0.1);
647
+ color: var(--text-primary);
648
+ border-radius: 16px;
649
+ font-size: 12px;
650
+ cursor: pointer;
651
+ transition: var(--transition-fast);
652
+ }
653
+
654
+ .batch-btn:hover {
655
+ background: rgba(255, 255, 255, 0.2);
656
+ }
657
+
658
+ .batch-btn.play-btn {
659
+ background: var(--accent-red);
660
+ color: white;
661
+ }
662
+
663
+ .batch-btn.play-btn:hover {
664
+ background: var(--accent-red-hover);
665
+ }
666
+
667
+ .batch-btn.remove-btn {
668
+ background: #ff4444;
669
+ color: white;
670
+ }
671
+
672
+ .batch-btn.remove-btn:hover {
673
+ background: #ff6666;
674
+ }
675
+
676
+ .content-area {
677
+ flex: 1;
678
+ min-height: 0;
679
+ }
680
+
681
+ .empty-state {
682
+ display: flex;
683
+ flex-direction: column;
684
+ align-items: center;
685
+ justify-content: center;
686
+ padding: 60px 40px;
687
+ text-align: center;
688
+ color: var(--text-tertiary);
689
+ }
690
+
691
+ .empty-state i {
692
+ font-size: 64px;
693
+ margin-bottom: 20px;
694
+ opacity: 0.5;
695
+ }
696
+
697
+ .empty-state p {
698
+ font-size: 16px;
699
+ margin-bottom: 8px;
700
+ }
701
+
702
+ .empty-tip {
703
+ font-size: 14px;
704
+ color: var(--text-secondary);
705
+ margin-bottom: 24px;
706
+ }
707
+
708
+ .discover-btn {
709
+ display: flex;
710
+ align-items: center;
711
+ gap: 8px;
712
+ padding: 12px 24px;
713
+ border: none;
714
+ background: var(--accent-red);
715
+ color: white;
716
+ border-radius: 25px;
717
+ font-size: 14px;
718
+ font-weight: 500;
719
+ cursor: pointer;
720
+ transition: var(--transition-fast);
721
+ }
722
+
723
+ .discover-btn:hover {
724
+ background: var(--accent-red-hover);
725
+ transform: scale(1.05);
726
+ }
727
+
728
+ .favorites-list {
729
+ padding-bottom: calc(var(--mini-player-height) + var(--tabbar-height) + 20px);
730
+ }
731
+
732
+ .favorite-item {
733
+ display: flex;
734
+ align-items: center;
735
+ padding: 12px 16px;
736
+ border-bottom: 1px solid var(--border-lighter);
737
+ background: var(--bg-card);
738
+ transition: var(--transition-fast);
739
+ }
740
+
741
+ .favorite-item:hover {
742
+ background: var(--bg-hover);
743
+ }
744
+
745
+ .favorite-item.selected {
746
+ background: rgba(255, 107, 107, 0.1);
747
+ border-left: 4px solid var(--accent-red);
748
+ }
749
+
750
+ .item-checkbox {
751
+ margin-right: 12px;
752
+ }
753
+
754
+ .song-info {
755
+ display: flex;
756
+ align-items: center;
757
+ flex: 1;
758
+ cursor: pointer;
759
+ gap: 12px;
760
+ }
761
+
762
+ .song-cover {
763
+ position: relative;
764
+ width: 50px;
765
+ height: 50px;
766
+ border-radius: var(--radius-small);
767
+ overflow: hidden;
768
+ }
769
+
770
+ .song-cover img {
771
+ width: 100%;
772
+ height: 100%;
773
+ object-fit: cover;
774
+ }
775
+
776
+ .play-overlay {
777
+ position: absolute;
778
+ top: 0;
779
+ left: 0;
780
+ right: 0;
781
+ bottom: 0;
782
+ background: rgba(0, 0, 0, 0.5);
783
+ display: flex;
784
+ align-items: center;
785
+ justify-content: center;
786
+ opacity: 0;
787
+ transition: var(--transition-fast);
788
+ }
789
+
790
+ .song-cover:hover .play-overlay {
791
+ opacity: 1;
792
+ }
793
+
794
+ .play-overlay i {
795
+ color: white;
796
+ font-size: 16px;
797
+ }
798
+
799
+ .song-details {
800
+ flex: 1;
801
+ min-width: 0;
802
+ }
803
+
804
+ .song-name {
805
+ font-size: 16px;
806
+ font-weight: 600;
807
+ color: var(--text-primary);
808
+ margin: 0 0 4px;
809
+ overflow: hidden;
810
+ text-overflow: ellipsis;
811
+ white-space: nowrap;
812
+ }
813
+
814
+ .song-meta {
815
+ font-size: 13px;
816
+ color: var(--text-secondary);
817
+ margin: 0 0 4px;
818
+ overflow: hidden;
819
+ text-overflow: ellipsis;
820
+ white-space: nowrap;
821
+ }
822
+
823
+ .favorite-time {
824
+ font-size: 11px;
825
+ color: var(--text-tertiary);
826
+ margin: 0;
827
+ }
828
+
829
+ .song-actions {
830
+ display: flex;
831
+ gap: 8px;
832
+ }
833
+
834
+ .song-actions .action-btn {
835
+ width: 36px;
836
+ height: 36px;
837
+ padding: 0;
838
+ border-radius: 50%;
839
+ background: rgba(255, 255, 255, 0.1);
840
+ color: var(--text-secondary);
841
+ display: flex;
842
+ align-items: center;
843
+ justify-content: center;
844
+ font-size: 14px;
845
+ }
846
+
847
+ .song-actions .action-btn:hover {
848
+ background: rgba(255, 255, 255, 0.2);
849
+ color: var(--text-primary);
850
+ transform: none;
851
+ }
852
+
853
+ .favorite-btn.active {
854
+ background: rgba(255, 107, 107, 0.2);
855
+ color: var(--accent-red);
856
+ }
857
+
858
+ .favorite-btn.active:hover {
859
+ background: rgba(255, 107, 107, 0.3);
860
+ color: var(--accent-red);
861
+ }
862
+
863
+ /* 响应式 */
864
+ @media (max-width: 375px) {
865
+ .page-header {
866
+ padding: 12px;
867
+ }
868
+
869
+ .page-title {
870
+ font-size: 20px;
871
+ }
872
+
873
+ .stats-card {
874
+ margin: 12px;
875
+ padding: 16px 12px;
876
+ gap: 16px;
877
+ }
878
+
879
+ .search-section {
880
+ padding: 0 12px 12px;
881
+ }
882
+
883
+ .batch-actions {
884
+ padding: 12px;
885
+ }
886
+
887
+ .favorite-item {
888
+ padding: 12px;
889
+ }
890
+
891
+ .song-cover {
892
+ width: 45px;
893
+ height: 45px;
894
+ }
895
+
896
+ .song-name {
897
+ font-size: 15px;
898
+ }
899
+
900
+ .song-meta {
901
+ font-size: 12px;
902
+ }
903
+ }
904
+
905
+ @media (min-width: 768px) {
906
+ .favorites-page {
907
+ max-width: 1200px;
908
+ margin: 0 auto;
909
+ }
910
+
911
+ .stats-card {
912
+ max-width: 600px;
913
+ margin: 16px auto;
914
+ }
915
+
916
+ .favorites-list {
917
+ max-width: 800px;
918
+ margin: 0 auto;
919
+ }
920
+ }
921
+ </style>
src/views/FullPlayerPage.vue CHANGED
@@ -164,7 +164,10 @@
164
  import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
165
  import { useRouter } from 'vue-router'
166
  import { usePlayerStore } from '@/stores/player'
 
167
  import { useFavoritesStore } from '@/stores/favorites'
 
 
168
  import { useSettingsStore } from '@/stores/settings'
169
  import { musicApi, utils } from '@/services/musicApi'
170
  import AlbumCover from '@/components/player/AlbumCover.vue'
@@ -179,7 +182,10 @@ const emit = defineEmits(['close', 'seek', 'togglePlay'])
179
 
180
  const router = useRouter()
181
  const playerStore = usePlayerStore()
 
182
  const favoritesStore = useFavoritesStore()
 
 
183
  const settingsStore = useSettingsStore()
184
 
185
  // 响应式数据
@@ -454,26 +460,47 @@ const handleShowPlaylist = () => {
454
  showPlaylist.value = true
455
  }
456
 
457
- const handlePlayFromList = (song, index) => {
458
- playerStore.playSong(song, index)
459
- showPlaylist.value = false
 
 
 
 
 
 
 
 
 
 
 
460
  }
461
 
462
  const handleRemoveFromList = (index) => {
463
- playerStore.removeFromPlaylist(index)
 
 
 
 
 
 
 
 
 
 
464
  }
465
 
466
  const toggleFavorite = async () => {
467
  if (!currentSong.value) return
468
 
469
  try {
470
- if (isFavorite.value) {
471
- await favoritesStore.removeFromFavorites(currentSong.value)
472
- } else {
473
- await favoritesStore.addToFavorites(currentSong.value)
474
- }
475
  } catch (error) {
476
  console.error('收藏操作失败:', error)
 
477
  }
478
  }
479
 
 
164
  import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
165
  import { useRouter } from 'vue-router'
166
  import { usePlayerStore } from '@/stores/player'
167
+ import { usePlayQueueStore } from '@/stores/playqueue'
168
  import { useFavoritesStore } from '@/stores/favorites'
169
+ import { useHistoryStore } from '@/stores/history'
170
+ import { useToastStore } from '@/stores/toast'
171
  import { useSettingsStore } from '@/stores/settings'
172
  import { musicApi, utils } from '@/services/musicApi'
173
  import AlbumCover from '@/components/player/AlbumCover.vue'
 
182
 
183
  const router = useRouter()
184
  const playerStore = usePlayerStore()
185
+ const playQueueStore = usePlayQueueStore()
186
  const favoritesStore = useFavoritesStore()
187
+ const historyStore = useHistoryStore()
188
+ const toastStore = useToastStore()
189
  const settingsStore = useSettingsStore()
190
 
191
  // 响应式数据
 
460
  showPlaylist.value = true
461
  }
462
 
463
+ const handlePlayFromList = async (song, index) => {
464
+ try {
465
+ // SOLID原则:使用playQueueStore管理队列播放逻辑
466
+ const selectedSong = playQueueStore.playAtIndex(index)
467
+ if (selectedSong) {
468
+ await playerStore.playSong(selectedSong, true) // restoreProgress = true
469
+ historyStore.addToHistory(selectedSong)
470
+ toastStore.success(`开始播放 "${selectedSong.name}"`)
471
+ }
472
+ showPlaylist.value = false
473
+ } catch (error) {
474
+ console.error('播放失败:', error)
475
+ toastStore.error('播放失败,请重试')
476
+ }
477
  }
478
 
479
  const handleRemoveFromList = (index) => {
480
+ try {
481
+ const result = playQueueStore.removeFromQueue(index)
482
+ if (result.success) {
483
+ toastStore.success('已从播放队列移除')
484
+ } else {
485
+ toastStore.error(result.message)
486
+ }
487
+ } catch (error) {
488
+ console.error('移除失败:', error)
489
+ toastStore.error('操作失败,请重试')
490
+ }
491
  }
492
 
493
  const toggleFavorite = async () => {
494
  if (!currentSong.value) return
495
 
496
  try {
497
+ // SOLID原则:使用favoritesStore的统一API
498
+ const result = await favoritesStore.toggleFavorite(currentSong.value)
499
+ const message = result ? '已添加到我喜欢的音乐' : '已从我喜欢的音乐中移除'
500
+ toastStore.success(message)
 
501
  } catch (error) {
502
  console.error('收藏操作失败:', error)
503
+ toastStore.error('操作失败,请重试')
504
  }
505
  }
506
 
src/views/HistoryPage.vue ADDED
@@ -0,0 +1,1004 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="history-page">
3
+ <!-- 头部 -->
4
+ <div class="page-header">
5
+ <h1 class="page-title">
6
+ <i class="fas fa-history"></i>
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>
17
+ </button>
18
+ </div>
19
+ </div>
20
+
21
+ <!-- 统计信息 -->
22
+ <div class="stats-grid">
23
+ <div class="stat-card">
24
+ <div class="stat-number">{{ totalPlays }}</div>
25
+ <div class="stat-label">总播放</div>
26
+ </div>
27
+ <div class="stat-card">
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
+ <!-- 筛选和搜索 -->
38
+ <div v-if="!isEmpty" class="filter-section">
39
+ <div class="filter-tabs">
40
+ <button
41
+ class="filter-tab"
42
+ :class="{ active: viewMode === 'recent' }"
43
+ @click="switchView('recent')"
44
+ >
45
+ <i class="fas fa-clock"></i>
46
+ 最近播放
47
+ </button>
48
+ <button
49
+ class="filter-tab"
50
+ :class="{ active: viewMode === 'grouped' }"
51
+ @click="switchView('grouped')"
52
+ >
53
+ <i class="fas fa-calendar"></i>
54
+ 按日期
55
+ </button>
56
+ <button
57
+ class="filter-tab"
58
+ :class="{ active: viewMode === 'top' }"
59
+ @click="switchView('top')"
60
+ >
61
+ <i class="fas fa-fire"></i>
62
+ 最多播放
63
+ </button>
64
+ </div>
65
+
66
+ <div class="search-box">
67
+ <i class="fas fa-search"></i>
68
+ <input
69
+ v-model="searchKeyword"
70
+ type="text"
71
+ placeholder="搜索播放历史..."
72
+ class="search-input"
73
+ />
74
+ <button v-if="searchKeyword" @click="clearSearch" class="clear-btn">
75
+ <i class="fas fa-times"></i>
76
+ </button>
77
+ </div>
78
+ </div>
79
+
80
+ <!-- 内容区域 -->
81
+ <div class="content-area">
82
+ <!-- 空状态 -->
83
+ <div v-if="isEmpty" class="empty-state">
84
+ <i class="fas fa-history"></i>
85
+ <p>还没有播放记录</p>
86
+ <p class="empty-tip">播放过的歌曲会自动记录在这里</p>
87
+ <button class="discover-btn" @click="goToHome">
88
+ <i class="fas fa-search"></i>
89
+ 去发现音乐
90
+ </button>
91
+ </div>
92
+
93
+ <!-- 最近播放 -->
94
+ <div v-else-if="viewMode === 'recent'" class="recent-view">
95
+ <div class="history-list">
96
+ <div
97
+ v-for="(item, index) in displayedHistory"
98
+ :key="`${item.song.id}-${item.timestamp}`"
99
+ class="history-item"
100
+ >
101
+ <div class="item-content" @click="playFromHistory(item.song, index)">
102
+ <div class="song-cover">
103
+ <img
104
+ :src="getSongCover(item.song)"
105
+ :alt="item.song.name"
106
+ @error="handleImageError"
107
+ />
108
+ <div class="play-overlay">
109
+ <i class="fas fa-play"></i>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="song-info">
114
+ <h3 class="song-name">{{ item.song.name }}</h3>
115
+ <p class="song-meta">
116
+ <span class="artist">{{ item.song.artist }}</span>
117
+ <span v-if="item.song.album" class="album"> - {{ item.song.album }}</span>
118
+ </p>
119
+ <p class="play-time">{{ formatPlayTime(item.timestamp) }}</p>
120
+ </div>
121
+ </div>
122
+
123
+ <div class="item-actions">
124
+ <button
125
+ class="action-btn favorite-btn"
126
+ :class="{ active: isFavorite(item.song) }"
127
+ @click="toggleFavorite(item.song)"
128
+ >
129
+ <i class="fas fa-heart"></i>
130
+ </button>
131
+ <button class="action-btn" @click="handleShowMoreActions(item.song, $event)">
132
+ <i class="fas fa-ellipsis-h"></i>
133
+ </button>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <!-- 按日期分组 -->
140
+ <div v-else-if="viewMode === 'grouped'" class="grouped-view">
141
+ <div
142
+ v-for="(group, date) in groupedHistory"
143
+ :key="date"
144
+ class="date-group"
145
+ >
146
+ <div class="group-header">
147
+ <h3 class="group-date">{{ formatDate(date) }}</h3>
148
+ <span class="group-count">{{ group.length }}首</span>
149
+ </div>
150
+
151
+ <div class="group-content">
152
+ <div
153
+ v-for="(item, index) in group"
154
+ :key="`${item.song.id}-${item.timestamp}`"
155
+ class="history-item"
156
+ >
157
+ <div class="item-content" @click="playFromGroup(item.song, group, index)">
158
+ <div class="song-cover">
159
+ <img
160
+ :src="getSongCover(item.song)"
161
+ :alt="item.song.name"
162
+ @error="handleImageError"
163
+ />
164
+ <div class="play-overlay">
165
+ <i class="fas fa-play"></i>
166
+ </div>
167
+ </div>
168
+
169
+ <div class="song-info">
170
+ <h3 class="song-name">{{ item.song.name }}</h3>
171
+ <p class="song-meta">
172
+ <span class="artist">{{ item.song.artist }}</span>
173
+ <span v-if="item.song.album" class="album"> - {{ item.song.album }}</span>
174
+ </p>
175
+ <p class="play-time">{{ formatPlayTime(item.timestamp, true) }}</p>
176
+ </div>
177
+ </div>
178
+
179
+ <div class="item-actions">
180
+ <button
181
+ class="action-btn favorite-btn"
182
+ :class="{ active: isFavorite(item.song) }"
183
+ @click="toggleFavorite(item.song)"
184
+ >
185
+ <i class="fas fa-heart"></i>
186
+ </button>
187
+ <button class="action-btn" @click="showMoreActions(item.song, $event)">
188
+ <i class="fas fa-ellipsis-h"></i>
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ <!-- 最多播放 -->
197
+ <div v-else-if="viewMode === 'top'" class="top-view">
198
+ <div class="top-list">
199
+ <div
200
+ v-for="(item, index) in topPlayedSongs"
201
+ :key="item.songKey"
202
+ class="top-item"
203
+ >
204
+ <div class="rank-badge">{{ index + 1 }}</div>
205
+
206
+ <div class="item-content" @click="playTopSong(item.song)">
207
+ <div class="song-cover">
208
+ <img
209
+ :src="getSongCover(item.song)"
210
+ :alt="item.song.name"
211
+ @error="handleImageError"
212
+ />
213
+ <div class="play-overlay">
214
+ <i class="fas fa-play"></i>
215
+ </div>
216
+ </div>
217
+
218
+ <div class="song-info">
219
+ <h3 class="song-name">{{ item.song.name }}</h3>
220
+ <p class="song-meta">
221
+ <span class="artist">{{ item.song.artist }}</span>
222
+ <span v-if="item.song.album" class="album"> - {{ item.song.album }}</span>
223
+ </p>
224
+ <p class="play-count">播放 {{ item.count }} 次</p>
225
+ </div>
226
+ </div>
227
+
228
+ <div class="item-actions">
229
+ <button
230
+ class="action-btn favorite-btn"
231
+ :class="{ active: isFavorite(item.song) }"
232
+ @click="toggleFavorite(item.song)"
233
+ >
234
+ <i class="fas fa-heart"></i>
235
+ </button>
236
+ <button class="action-btn" @click="handleShowMoreActions(item.song, $event)">
237
+ <i class="fas fa-ellipsis-h"></i>
238
+ </button>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- 更多操作菜单 -->
246
+ <MoreActionsPanel
247
+ v-if="showMoreActions && selectedSong"
248
+ :song="selectedSong"
249
+ @action="handleMoreAction"
250
+ @close="showMoreActions = false"
251
+ />
252
+
253
+ <!-- 确认对话框 -->
254
+ <ConfirmDialog
255
+ ref="confirmDialogRef"
256
+ :title="confirmDialog.title"
257
+ :message="confirmDialog.message"
258
+ :confirm-text="confirmDialog.confirmText"
259
+ :type="confirmDialog.type"
260
+ @confirm="handleConfirm"
261
+ />
262
+ </div>
263
+ </template>
264
+
265
+ <script setup>
266
+ import { ref, computed, onMounted } from 'vue'
267
+ import { useRouter } from 'vue-router'
268
+ import { useHistoryStore } from '@/stores/history'
269
+ import { useFavoritesStore } from '@/stores/favorites'
270
+ import { usePlayerStore } from '@/stores/player'
271
+ import { usePlayQueueStore } from '@/stores/playqueue'
272
+ import { useToastStore } from '@/stores/toast'
273
+ import { imageCacheManager } from '@/utils/imageCache'
274
+ import MoreActionsPanel from '@/components/player/MoreActionsPanel.vue'
275
+ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
276
+
277
+ const router = useRouter()
278
+ const historyStore = useHistoryStore()
279
+ const favoritesStore = useFavoritesStore()
280
+ const playerStore = usePlayerStore()
281
+ 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)
289
+ const moreActionsRef = ref(null)
290
+ const confirmDialogRef = ref(null)
291
+
292
+ // 确认对话框状态
293
+ const confirmDialog = ref({
294
+ title: '',
295
+ message: '',
296
+ confirmText: '确定',
297
+ type: 'warning',
298
+ action: null,
299
+ data: null
300
+ })
301
+
302
+ // 计算属性
303
+ const history = computed(() => historyStore.history)
304
+ const totalPlays = computed(() => historyStore.totalPlays)
305
+ const uniqueSongs = computed(() => historyStore.uniqueSongs)
306
+ const isEmpty = computed(() => historyStore.isEmpty)
307
+
308
+ // 播放统计
309
+ const playStats = computed(() => historyStore.getPlayStats())
310
+
311
+ // 搜索后的历史记录
312
+ const displayedHistory = computed(() => {
313
+ const baseHistory = viewMode.value === 'recent'
314
+ ? historyStore.getRecentSongs(100)
315
+ : history.value
316
+
317
+ if (!searchKeyword.value.trim()) {
318
+ return baseHistory
319
+ }
320
+ return historyStore.searchHistory(searchKeyword.value)
321
+ })
322
+
323
+ // 按日期分组的历史记录
324
+ const groupedHistory = computed(() => {
325
+ return historyStore.getGroupedByDate()
326
+ })
327
+
328
+ // 最多播放的歌曲
329
+ const topPlayedSongs = computed(() => {
330
+ return historyStore.getTopPlayedSongs(50)
331
+ })
332
+
333
+ // 方法
334
+ const formatDuration = (seconds) => {
335
+ if (!seconds) return '0分钟'
336
+ const hours = Math.floor(seconds / 3600)
337
+ const minutes = Math.floor((seconds % 3600) / 60)
338
+ if (hours > 0) {
339
+ return `${hours}小时${minutes}分钟`
340
+ }
341
+ return `${minutes}分钟`
342
+ }
343
+
344
+ const formatPlayTime = (timestamp, showTime = false) => {
345
+ const date = new Date(timestamp)
346
+ const now = new Date()
347
+ const diff = now - date
348
+
349
+ if (showTime) {
350
+ return date.toLocaleTimeString('zh-CN', {
351
+ hour: '2-digit',
352
+ minute: '2-digit'
353
+ })
354
+ }
355
+
356
+ if (diff < 60 * 1000) {
357
+ return '刚刚播放'
358
+ } else if (diff < 60 * 60 * 1000) {
359
+ const minutes = Math.floor(diff / (60 * 1000))
360
+ return `${minutes}分钟前`
361
+ } else if (diff < 24 * 60 * 60 * 1000) {
362
+ const hours = Math.floor(diff / (60 * 60 * 1000))
363
+ return `${hours}小时前`
364
+ } else {
365
+ const days = Math.floor(diff / (24 * 60 * 60 * 1000))
366
+ return `${days}天前`
367
+ }
368
+ }
369
+
370
+ const formatDate = (dateString) => {
371
+ const date = new Date(dateString)
372
+ const today = new Date()
373
+ const yesterday = new Date(today)
374
+ yesterday.setDate(yesterday.getDate() - 1)
375
+
376
+ if (date.toDateString() === today.toDateString()) {
377
+ return '今天'
378
+ } else if (date.toDateString() === yesterday.toDateString()) {
379
+ return '昨天'
380
+ } else {
381
+ return date.toLocaleDateString('zh-CN', {
382
+ month: 'short',
383
+ day: 'numeric',
384
+ weekday: 'short'
385
+ })
386
+ }
387
+ }
388
+
389
+ const getSongCover = (song) => {
390
+ return playerStore.getCachedCover(song) ||
391
+ `https://music-api.gdstudio.xyz/api.php?types=pic&source=${song.source}&id=${song.pic_id || song.id}&size=300`
392
+ }
393
+
394
+ const handleImageError = (event) => {
395
+ event.target.src = imageCacheManager.getDefaultImage()
396
+ }
397
+
398
+ const switchView = (mode) => {
399
+ viewMode.value = mode
400
+ searchKeyword.value = '' // 切换视图时清空搜索
401
+ }
402
+
403
+ const clearSearch = () => {
404
+ searchKeyword.value = ''
405
+ }
406
+
407
+ const goToHome = () => {
408
+ router.push('/home')
409
+ }
410
+
411
+ // 收藏相关
412
+ const isFavorite = (song) => {
413
+ return favoritesStore.isFavorite(song)
414
+ }
415
+
416
+ const toggleFavorite = async (song) => {
417
+ const result = await favoritesStore.toggleFavorite(song)
418
+ const message = result ? '已添加到我喜欢的音乐' : '已从我喜欢的音乐中移除'
419
+ toastStore.success(message)
420
+ }
421
+
422
+ // 播放相关
423
+ const playFromHistory = async (song, index) => {
424
+ const songs = displayedHistory.value.map(item => item.song)
425
+ const result = playQueueStore.setQueue(songs, index)
426
+
427
+ if (result) {
428
+ await playerStore.playSong(song)
429
+ toastStore.success(`开始播放 "${song.name}"`)
430
+ } else {
431
+ toastStore.error('播放失败')
432
+ }
433
+ }
434
+
435
+ const playFromGroup = async (song, group, index) => {
436
+ const songs = group.map(item => item.song)
437
+ const result = playQueueStore.setQueue(songs, index)
438
+
439
+ if (result) {
440
+ await playerStore.playSong(song)
441
+ toastStore.success(`开始播放 "${song.name}"`)
442
+ } else {
443
+ toastStore.error('播放失败')
444
+ }
445
+ }
446
+
447
+ const playTopSong = async (song) => {
448
+ const result = playQueueStore.addToQueue(song, 'end')
449
+
450
+ if (result.success) {
451
+ await playerStore.playSong(song)
452
+ toastStore.success(`开始播放 "${song.name}"`)
453
+ } else {
454
+ toastStore.error('播放失败')
455
+ }
456
+ }
457
+
458
+ // 更多操作
459
+ const handleShowMoreActions = (song, event) => {
460
+ selectedSong.value = song
461
+ showMoreActions.value = true
462
+ }
463
+
464
+ const handleMoreAction = async (action) => {
465
+ if (!selectedSong.value) return
466
+
467
+ const song = selectedSong.value
468
+
469
+ switch (action) {
470
+ case 'favorite':
471
+ await toggleFavorite(song)
472
+ break
473
+
474
+ case 'addToPlaylist':
475
+ // TODO: 实现添加到歌单
476
+ toastStore.info('添加到歌单功能开发中...')
477
+ break
478
+
479
+ case 'download':
480
+ // TODO: 实现下载
481
+ toastStore.info('下载功能开发中...')
482
+ break
483
+ }
484
+
485
+ showMoreActions.value = false
486
+ }
487
+
488
+ // 导出和清空操作
489
+ const showExportOptions = () => {
490
+ const result = historyStore.exportHistory()
491
+ if (result.success) {
492
+ toastStore.success('导出成功')
493
+ } else {
494
+ toastStore.error('导出失败')
495
+ }
496
+ }
497
+
498
+ const confirmClearAll = () => {
499
+ confirmDialog.value = {
500
+ title: '清空播放历史',
501
+ message: '确定要清空所有播放历史吗?此操作不可撤销,包括播放统计数据也会被清空。',
502
+ confirmText: '清空',
503
+ type: 'danger',
504
+ action: 'clear-all',
505
+ data: null
506
+ }
507
+
508
+ confirmDialogRef.value?.show()
509
+ }
510
+
511
+ // 确认对话框处理
512
+ const handleConfirm = async () => {
513
+ const { action } = confirmDialog.value
514
+
515
+ switch (action) {
516
+ case 'clear-all':
517
+ const result = historyStore.clearHistory()
518
+ if (result.success) {
519
+ toastStore.success('播放历史已清空')
520
+ } else {
521
+ toastStore.error('清空失败')
522
+ }
523
+ break
524
+ }
525
+ }
526
+
527
+ // 生命周期
528
+ onMounted(() => {
529
+ // 加载历史数据
530
+ historyStore.loadHistory()
531
+ favoritesStore.loadFavorites()
532
+ })
533
+ </script>
534
+
535
+ <style scoped>
536
+ .history-page {
537
+ height: 100%;
538
+ background: var(--bg-primary);
539
+ overflow-y: auto;
540
+ }
541
+
542
+ .page-header {
543
+ display: flex;
544
+ align-items: center;
545
+ justify-content: space-between;
546
+ padding: 16px;
547
+ border-bottom: 1px solid var(--border-lighter);
548
+ }
549
+
550
+ .page-title {
551
+ display: flex;
552
+ align-items: center;
553
+ gap: 12px;
554
+ font-size: 24px;
555
+ font-weight: 700;
556
+ color: var(--text-primary);
557
+ margin: 0;
558
+ }
559
+
560
+ .page-title i {
561
+ color: var(--accent-blue);
562
+ }
563
+
564
+ .header-actions {
565
+ display: flex;
566
+ gap: 8px;
567
+ }
568
+
569
+ .action-btn {
570
+ display: flex;
571
+ align-items: center;
572
+ gap: 6px;
573
+ padding: 8px 16px;
574
+ border: none;
575
+ background: var(--accent-blue);
576
+ color: white;
577
+ border-radius: 20px;
578
+ font-size: 14px;
579
+ font-weight: 500;
580
+ cursor: pointer;
581
+ transition: var(--transition-fast);
582
+ }
583
+
584
+ .action-btn:hover {
585
+ background: var(--accent-blue-hover);
586
+ transform: translateY(-1px);
587
+ }
588
+
589
+ .action-btn.danger {
590
+ background: #ff4444;
591
+ }
592
+
593
+ .action-btn.danger:hover {
594
+ background: #ff6666;
595
+ }
596
+
597
+ .stats-grid {
598
+ display: grid;
599
+ grid-template-columns: repeat(3, 1fr);
600
+ gap: 12px;
601
+ padding: 16px;
602
+ background: var(--bg-card);
603
+ margin: 16px;
604
+ border-radius: var(--radius-medium);
605
+ border: 1px solid var(--border-light);
606
+ }
607
+
608
+ .stat-card {
609
+ text-align: center;
610
+ padding: 16px 8px;
611
+ }
612
+
613
+ .stat-number {
614
+ font-size: 20px;
615
+ font-weight: 700;
616
+ color: var(--accent-blue);
617
+ margin-bottom: 4px;
618
+ }
619
+
620
+ .stat-label {
621
+ font-size: 12px;
622
+ color: var(--text-secondary);
623
+ }
624
+
625
+ .filter-section {
626
+ padding: 0 16px 16px;
627
+ }
628
+
629
+ .filter-tabs {
630
+ display: flex;
631
+ gap: 8px;
632
+ margin-bottom: 16px;
633
+ overflow-x: auto;
634
+ padding: 4px;
635
+ }
636
+
637
+ .filter-tab {
638
+ display: flex;
639
+ align-items: center;
640
+ gap: 6px;
641
+ padding: 8px 16px;
642
+ border: none;
643
+ background: rgba(255, 255, 255, 0.1);
644
+ color: var(--text-secondary);
645
+ border-radius: 20px;
646
+ font-size: 14px;
647
+ cursor: pointer;
648
+ white-space: nowrap;
649
+ transition: var(--transition-fast);
650
+ }
651
+
652
+ .filter-tab:hover {
653
+ background: rgba(255, 255, 255, 0.2);
654
+ color: var(--text-primary);
655
+ }
656
+
657
+ .filter-tab.active {
658
+ background: var(--accent-blue);
659
+ color: white;
660
+ }
661
+
662
+ .search-box {
663
+ display: flex;
664
+ align-items: center;
665
+ background: var(--bg-card);
666
+ border-radius: 25px;
667
+ padding: 12px 16px;
668
+ border: 1px solid var(--border-light);
669
+ }
670
+
671
+ .search-box i {
672
+ color: var(--text-secondary);
673
+ margin-right: 12px;
674
+ }
675
+
676
+ .search-input {
677
+ flex: 1;
678
+ border: none;
679
+ background: transparent;
680
+ color: var(--text-primary);
681
+ font-size: 14px;
682
+ outline: none;
683
+ }
684
+
685
+ .search-input::placeholder {
686
+ color: var(--text-tertiary);
687
+ }
688
+
689
+ .clear-btn {
690
+ border: none;
691
+ background: transparent;
692
+ color: var(--text-secondary);
693
+ cursor: pointer;
694
+ padding: 4px;
695
+ border-radius: 50%;
696
+ transition: var(--transition-fast);
697
+ }
698
+
699
+ .clear-btn:hover {
700
+ background: rgba(255, 255, 255, 0.1);
701
+ color: var(--text-primary);
702
+ }
703
+
704
+ .content-area {
705
+ flex: 1;
706
+ min-height: 0;
707
+ }
708
+
709
+ .empty-state {
710
+ display: flex;
711
+ flex-direction: column;
712
+ align-items: center;
713
+ justify-content: center;
714
+ padding: 60px 40px;
715
+ text-align: center;
716
+ color: var(--text-tertiary);
717
+ }
718
+
719
+ .empty-state i {
720
+ font-size: 64px;
721
+ margin-bottom: 20px;
722
+ opacity: 0.5;
723
+ }
724
+
725
+ .empty-state p {
726
+ font-size: 16px;
727
+ margin-bottom: 8px;
728
+ }
729
+
730
+ .empty-tip {
731
+ font-size: 14px;
732
+ color: var(--text-secondary);
733
+ margin-bottom: 24px;
734
+ }
735
+
736
+ .discover-btn {
737
+ display: flex;
738
+ align-items: center;
739
+ gap: 8px;
740
+ padding: 12px 24px;
741
+ border: none;
742
+ background: var(--accent-blue);
743
+ color: white;
744
+ border-radius: 25px;
745
+ font-size: 14px;
746
+ font-weight: 500;
747
+ cursor: pointer;
748
+ transition: var(--transition-fast);
749
+ }
750
+
751
+ .discover-btn:hover {
752
+ background: var(--accent-blue-hover);
753
+ transform: scale(1.05);
754
+ }
755
+
756
+ .history-list,
757
+ .top-list {
758
+ padding-bottom: calc(var(--mini-player-height) + var(--tabbar-height) + 20px);
759
+ }
760
+
761
+ .history-item,
762
+ .top-item {
763
+ display: flex;
764
+ align-items: center;
765
+ padding: 12px 16px;
766
+ border-bottom: 1px solid var(--border-lighter);
767
+ background: var(--bg-card);
768
+ transition: var(--transition-fast);
769
+ }
770
+
771
+ .history-item:hover,
772
+ .top-item:hover {
773
+ background: var(--bg-hover);
774
+ }
775
+
776
+ .rank-badge {
777
+ width: 32px;
778
+ height: 32px;
779
+ border-radius: 50%;
780
+ background: var(--accent-gradient);
781
+ color: white;
782
+ display: flex;
783
+ align-items: center;
784
+ justify-content: center;
785
+ font-weight: 700;
786
+ font-size: 14px;
787
+ margin-right: 12px;
788
+ flex-shrink: 0;
789
+ }
790
+
791
+ .item-content {
792
+ display: flex;
793
+ align-items: center;
794
+ flex: 1;
795
+ cursor: pointer;
796
+ gap: 12px;
797
+ }
798
+
799
+ .song-cover {
800
+ position: relative;
801
+ width: 50px;
802
+ height: 50px;
803
+ border-radius: var(--radius-small);
804
+ overflow: hidden;
805
+ flex-shrink: 0;
806
+ }
807
+
808
+ .song-cover img {
809
+ width: 100%;
810
+ height: 100%;
811
+ object-fit: cover;
812
+ }
813
+
814
+ .play-overlay {
815
+ position: absolute;
816
+ top: 0;
817
+ left: 0;
818
+ right: 0;
819
+ bottom: 0;
820
+ background: rgba(0, 0, 0, 0.5);
821
+ display: flex;
822
+ align-items: center;
823
+ justify-content: center;
824
+ opacity: 0;
825
+ transition: var(--transition-fast);
826
+ }
827
+
828
+ .song-cover:hover .play-overlay {
829
+ opacity: 1;
830
+ }
831
+
832
+ .play-overlay i {
833
+ color: white;
834
+ font-size: 16px;
835
+ }
836
+
837
+ .song-info {
838
+ flex: 1;
839
+ min-width: 0;
840
+ }
841
+
842
+ .song-name {
843
+ font-size: 16px;
844
+ font-weight: 600;
845
+ color: var(--text-primary);
846
+ margin: 0 0 4px;
847
+ overflow: hidden;
848
+ text-overflow: ellipsis;
849
+ white-space: nowrap;
850
+ }
851
+
852
+ .song-meta {
853
+ font-size: 13px;
854
+ color: var(--text-secondary);
855
+ margin: 0 0 4px;
856
+ overflow: hidden;
857
+ text-overflow: ellipsis;
858
+ white-space: nowrap;
859
+ }
860
+
861
+ .play-time,
862
+ .play-count {
863
+ font-size: 11px;
864
+ color: var(--text-tertiary);
865
+ margin: 0;
866
+ }
867
+
868
+ .item-actions {
869
+ display: flex;
870
+ gap: 8px;
871
+ flex-shrink: 0;
872
+ }
873
+
874
+ .item-actions .action-btn {
875
+ width: 36px;
876
+ height: 36px;
877
+ padding: 0;
878
+ border-radius: 50%;
879
+ background: rgba(255, 255, 255, 0.1);
880
+ color: var(--text-secondary);
881
+ display: flex;
882
+ align-items: center;
883
+ justify-content: center;
884
+ font-size: 14px;
885
+ }
886
+
887
+ .item-actions .action-btn:hover {
888
+ background: rgba(255, 255, 255, 0.2);
889
+ color: var(--text-primary);
890
+ transform: none;
891
+ }
892
+
893
+ .favorite-btn.active {
894
+ background: rgba(255, 107, 107, 0.2);
895
+ color: var(--accent-red);
896
+ }
897
+
898
+ .favorite-btn.active:hover {
899
+ background: rgba(255, 107, 107, 0.3);
900
+ color: var(--accent-red);
901
+ }
902
+
903
+ .date-group {
904
+ margin-bottom: 24px;
905
+ }
906
+
907
+ .group-header {
908
+ display: flex;
909
+ align-items: center;
910
+ justify-content: space-between;
911
+ padding: 12px 16px;
912
+ background: var(--bg-card);
913
+ border-bottom: 1px solid var(--border-lighter);
914
+ sticky: true;
915
+ top: 0;
916
+ z-index: 10;
917
+ }
918
+
919
+ .group-date {
920
+ font-size: 16px;
921
+ font-weight: 600;
922
+ color: var(--text-primary);
923
+ margin: 0;
924
+ }
925
+
926
+ .group-count {
927
+ font-size: 12px;
928
+ color: var(--text-secondary);
929
+ background: rgba(255, 255, 255, 0.1);
930
+ padding: 2px 8px;
931
+ border-radius: 10px;
932
+ }
933
+
934
+ /* 响应式 */
935
+ @media (max-width: 375px) {
936
+ .page-header {
937
+ padding: 12px;
938
+ }
939
+
940
+ .page-title {
941
+ font-size: 20px;
942
+ }
943
+
944
+ .stats-grid {
945
+ margin: 12px;
946
+ padding: 16px 12px;
947
+ gap: 8px;
948
+ }
949
+
950
+ .stat-card {
951
+ padding: 12px 4px;
952
+ }
953
+
954
+ .stat-number {
955
+ font-size: 18px;
956
+ }
957
+
958
+ .filter-section {
959
+ padding: 0 12px 12px;
960
+ }
961
+
962
+ .history-item,
963
+ .top-item {
964
+ padding: 12px;
965
+ }
966
+
967
+ .song-cover {
968
+ width: 45px;
969
+ height: 45px;
970
+ }
971
+
972
+ .song-name {
973
+ font-size: 15px;
974
+ }
975
+
976
+ .song-meta {
977
+ font-size: 12px;
978
+ }
979
+
980
+ .rank-badge {
981
+ width: 28px;
982
+ height: 28px;
983
+ font-size: 12px;
984
+ }
985
+ }
986
+
987
+ @media (min-width: 768px) {
988
+ .history-page {
989
+ max-width: 1200px;
990
+ margin: 0 auto;
991
+ }
992
+
993
+ .stats-grid {
994
+ max-width: 600px;
995
+ margin: 16px auto;
996
+ }
997
+
998
+ .history-list,
999
+ .top-list {
1000
+ max-width: 800px;
1001
+ margin: 0 auto;
1002
+ }
1003
+ }
1004
+ </style>
src/views/HomePage.vue CHANGED
@@ -23,12 +23,18 @@
23
  import { ref, computed, onMounted } from 'vue'
24
  import { useSearchStore } from '@/stores/search'
25
  import { usePlayerStore } from '@/stores/player'
 
 
 
26
  import { musicApi, utils } from '@/services/musicApi'
27
  import SearchBox from '@/components/search/SearchBox.vue'
28
  import SearchResults from '@/components/search/SearchResults.vue'
29
 
30
  const searchStore = useSearchStore()
31
  const playerStore = usePlayerStore()
 
 
 
32
 
33
  // 响应式数据
34
  const searchError = ref('')
@@ -74,28 +80,30 @@ const retrySearch = () => {
74
 
75
  const handlePlay = async (song, index) => {
76
  try {
77
- // 设置播放列表为当前搜索结果
78
- const currentResults = searchStore.searchResults
79
- if (currentResults.length > 0) {
80
- playerStore.setPlaylist(currentResults, index)
 
 
 
 
 
 
 
 
 
 
 
81
  } else {
82
- // 如果没有搜索结果,至少添加当前歌曲
83
- playerStore.setPlaylist([song], 0)
84
  }
85
-
86
  } catch (error) {
87
  console.error('播放失败:', error)
 
88
  }
89
  }
90
 
91
- const emitPlayEvent = (song) => {
92
- // 这里可以通过事件总线或者其他方式通知App.vue播放歌曲
93
- const event = new CustomEvent('playSong', {
94
- detail: { song }
95
- })
96
- window.dispatchEvent(event)
97
- }
98
-
99
  const getErrorMessage = (error) => {
100
  if (error.message?.includes('Failed to fetch')) {
101
  return '网络连接失败,请检查网络后重试'
 
23
  import { ref, computed, onMounted } from 'vue'
24
  import { useSearchStore } from '@/stores/search'
25
  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()
35
+ const playQueueStore = usePlayQueueStore()
36
+ const historyStore = useHistoryStore()
37
+ const toastStore = useToastStore()
38
 
39
  // 响应式数据
40
  const searchError = ref('')
 
80
 
81
  const handlePlay = async (song, index) => {
82
  try {
83
+ // SOLID原则:单一职责 - playQueueStore负责队列管理,playerStore负责播放控制
84
+ const searchResults = searchStore.searchResults || []
85
+
86
+ // 将搜索结果设置为播放队列,从指定索引开始播放
87
+ const result = playQueueStore.setQueue(searchResults, index)
88
+
89
+ if (result) {
90
+ // 开始播放歌曲
91
+ await playerStore.playSong(song)
92
+
93
+ // 添加到播放历史 - DRY原则:使用统一的历史记录方法
94
+ historyStore.addToHistory(song)
95
+
96
+ // 用户反馈
97
+ toastStore.success(`开始播放 "${song.name}"`)
98
  } else {
99
+ throw new Error('设置播放队列失败')
 
100
  }
 
101
  } catch (error) {
102
  console.error('播放失败:', error)
103
+ toastStore.error('播放失败,请重试')
104
  }
105
  }
106
 
 
 
 
 
 
 
 
 
107
  const getErrorMessage = (error) => {
108
  if (error.message?.includes('Failed to fetch')) {
109
  return '网络连接失败,请检查网络后重试'
src/views/MyMusicPage.vue CHANGED
@@ -6,6 +6,11 @@
6
  <i class="fas fa-heart"></i>
7
  我的音乐
8
  </h1>
 
 
 
 
 
9
  </div>
10
 
11
  <!-- 统计卡片 -->
@@ -19,20 +24,16 @@
19
  <div class="stat-number">{{ historyCount }}</div>
20
  <div class="stat-label">播放历史</div>
21
  </div>
22
-
23
- <div class="stat-card">
24
- <div class="stat-number">{{ totalPlayTime }}</div>
25
- <div class="stat-label">累计时长</div>
26
- </div>
27
  </div>
28
 
29
  <!-- 选项卡 -->
30
  <div class="tabs-container">
31
- <div class="tabs">
32
  <button
33
  class="tab-btn"
34
  :class="{ active: activeTab === 'favorites' }"
35
- @click="activeTab = 'favorites'"
 
36
  >
37
  <i class="fas fa-heart"></i>
38
  <span>我的收藏</span>
@@ -42,10 +43,11 @@
42
  <button
43
  class="tab-btn"
44
  :class="{ active: activeTab === 'history' }"
45
- @click="activeTab = 'history'"
 
46
  >
47
  <i class="fas fa-history"></i>
48
- <span>播放历史</span>
49
  <span class="tab-count">{{ historyCount }}</span>
50
  </button>
51
  </div>
@@ -102,26 +104,45 @@
102
  </div>
103
  </div>
104
  </div>
 
 
 
 
 
 
 
 
 
 
105
  </div>
106
  </template>
107
 
108
  <script setup>
109
- import { ref, computed, onMounted } from 'vue'
110
  import { useRouter } from 'vue-router'
111
  import { useFavoritesStore } from '@/stores/favorites'
112
  import { usePlayerStore } from '@/stores/player'
113
  import { useHistoryStore } from '@/stores/history'
 
 
114
  import { utils } from '@/services/musicApi'
115
  import SongItem from '@/components/search/SongItem.vue'
116
  import FavoritesList from '@/components/favorites/FavoritesList.vue'
 
117
 
118
  const router = useRouter()
119
  const favoritesStore = useFavoritesStore()
120
  const playerStore = usePlayerStore()
121
  const historyStore = useHistoryStore()
 
 
122
 
123
  // 响应式数据
124
  const activeTab = ref('favorites')
 
 
 
 
125
 
126
  // 计算属性
127
  const favorites = computed(() => favoritesStore.favorites)
@@ -132,11 +153,6 @@ const history = computed(() => historyStore.history)
132
  const historyCount = computed(() => history.value.length)
133
  const hasHistory = computed(() => historyCount.value > 0)
134
 
135
- const totalPlayTime = computed(() => {
136
- const totalSeconds = historyStore.totalPlayTime
137
- return utils.formatDuration(totalSeconds)
138
- })
139
-
140
  // 按日期分组的历史记录
141
  const groupedHistory = computed(() => {
142
  const groups = {}
@@ -159,19 +175,61 @@ const groupedHistory = computed(() => {
159
  return sortedGroups
160
  })
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  // 方法
163
- const handlePlayFromFavorites = (song) => {
164
  // 从收藏列表播放
165
  if (Array.isArray(song)) {
166
  // 播放多首歌曲
167
- playerStore.setPlaylist(song)
168
- playerStore.playSong(song[0], 0)
 
 
169
  } else {
170
  // 播放单首歌曲
171
- playerStore.setPlaylist(favorites.value.map(item => item.song))
172
  const playlist = favorites.value.map(item => item.song)
173
  const index = playlist.findIndex(s => s.id === song.id)
174
- playerStore.playSong(song, index)
 
 
 
175
  }
176
 
177
  // 添加到播放历史
@@ -182,52 +240,23 @@ const handlePlayFromFavorites = (song) => {
182
  }
183
  }
184
 
185
- const handleBatchAction = (data) => {
186
- const { action, songs, count } = data
187
-
188
- switch (action) {
189
- case 'play':
190
- console.log('批量播放:', songs)
191
- break
192
- case 'add-to-playlist':
193
- console.log('批量添加到播放列表:', songs)
194
- break
195
- case 'remove':
196
- console.log('批量删除完成,删除了', count, '首歌曲')
197
- break
198
- }
199
- }
200
 
201
- const handleMoreAction = (data) => {
202
- const { action, song } = data
203
-
204
- switch (action) {
205
- case 'play-next':
206
- console.log('下一首播放:', song)
207
- break
208
- case 'add-to-playlist':
209
- console.log('添加到播放列表:', song)
210
- break
211
- case 'copy-link':
212
- console.log('已复制链接:', song)
213
- break
214
- case 'view-details':
215
- console.log('查看详情:', song)
216
- break
217
- }
218
  }
219
 
220
- const clearHistory = async () => {
221
- if (confirm('确定要清空所有播放历史吗?此操作不可撤销。')) {
222
- try {
223
- await historyStore.clearHistory()
224
- } catch (error) {
225
- console.error('清空历史失败:', error)
226
- }
227
  }
228
  }
229
 
230
- const handlePlay = (song, index) => {
231
  let playlist = []
232
 
233
  switch (activeTab.value) {
@@ -238,8 +267,11 @@ const handlePlay = (song, index) => {
238
  break
239
  }
240
 
241
- playerStore.setPlaylist(playlist)
242
- playerStore.playSong(song, playlist.findIndex(s => s.id === song.id))
 
 
 
243
 
244
  // 添加到播放历史
245
  historyStore.addToHistory(song)
@@ -247,10 +279,10 @@ const handlePlay = (song, index) => {
247
 
248
  const handleFavorite = async (song) => {
249
  try {
250
- if (favoritesStore.isFavorite(song.id)) {
251
- await favoritesStore.removeFavorite(song.id)
252
  } else {
253
- await favoritesStore.addFavorite(song)
254
  }
255
  } catch (error) {
256
  console.error('收藏操作失败:', error)
@@ -262,6 +294,41 @@ const handleMore = (song) => {
262
  console.log('更多操作:', song)
263
  }
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  const formatDate = (dateString) => {
266
  const date = new Date(dateString)
267
  const today = new Date()
@@ -324,6 +391,26 @@ onMounted(async () => {
324
  gap: 8px;
325
  }
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  .action-btn {
328
  display: flex;
329
  align-items: center;
@@ -349,7 +436,7 @@ onMounted(async () => {
349
 
350
  .stats-cards {
351
  display: grid;
352
- grid-template-columns: repeat(3, 1fr);
353
  gap: 12px;
354
  padding: 20px 16px;
355
  background: var(--bg-card);
 
6
  <i class="fas fa-heart"></i>
7
  我的音乐
8
  </h1>
9
+ <div class="header-actions">
10
+ <button class="settings-btn" @click="goToSettings">
11
+ <i class="fas fa-cog"></i>
12
+ </button>
13
+ </div>
14
  </div>
15
 
16
  <!-- 统计卡片 -->
 
24
  <div class="stat-number">{{ historyCount }}</div>
25
  <div class="stat-label">播放历史</div>
26
  </div>
 
 
 
 
 
27
  </div>
28
 
29
  <!-- 选项卡 -->
30
  <div class="tabs-container">
31
+ <div class="tabs" ref="tabsRef">
32
  <button
33
  class="tab-btn"
34
  :class="{ active: activeTab === 'favorites' }"
35
+ @click="switchTab('favorites')"
36
+ ref="favoritesTab"
37
  >
38
  <i class="fas fa-heart"></i>
39
  <span>我的收藏</span>
 
43
  <button
44
  class="tab-btn"
45
  :class="{ active: activeTab === 'history' }"
46
+ @click="switchTab('history')"
47
+ ref="historyTab"
48
  >
49
  <i class="fas fa-history"></i>
50
+ <span>历史记录</span>
51
  <span class="tab-count">{{ historyCount }}</span>
52
  </button>
53
  </div>
 
104
  </div>
105
  </div>
106
  </div>
107
+
108
+ <!-- 确认对话框 -->
109
+ <ConfirmDialog
110
+ ref="confirmDialogRef"
111
+ title="清空播放历史"
112
+ message="确定要清空所有播放历史吗?此操作不可撤销。"
113
+ confirm-text="清空"
114
+ type="danger"
115
+ @confirm="confirmClearHistory"
116
+ />
117
  </div>
118
  </template>
119
 
120
  <script setup>
121
+ import { ref, computed, onMounted, nextTick } from 'vue'
122
  import { useRouter } from 'vue-router'
123
  import { useFavoritesStore } from '@/stores/favorites'
124
  import { usePlayerStore } from '@/stores/player'
125
  import { useHistoryStore } from '@/stores/history'
126
+ import { usePlayQueueStore } from '@/stores/playqueue'
127
+ import { useToastStore } from '@/stores/toast'
128
  import { utils } from '@/services/musicApi'
129
  import SongItem from '@/components/search/SongItem.vue'
130
  import FavoritesList from '@/components/favorites/FavoritesList.vue'
131
+ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
132
 
133
  const router = useRouter()
134
  const favoritesStore = useFavoritesStore()
135
  const playerStore = usePlayerStore()
136
  const historyStore = useHistoryStore()
137
+ const playQueueStore = usePlayQueueStore()
138
+ const toastStore = useToastStore()
139
 
140
  // 响应式数据
141
  const activeTab = ref('favorites')
142
+ const tabsRef = ref(null)
143
+ const favoritesTab = ref(null)
144
+ const historyTab = ref(null)
145
+ const confirmDialogRef = ref(null)
146
 
147
  // 计算属性
148
  const favorites = computed(() => favoritesStore.favorites)
 
153
  const historyCount = computed(() => history.value.length)
154
  const hasHistory = computed(() => historyCount.value > 0)
155
 
 
 
 
 
 
156
  // 按日期分组的历史记录
157
  const groupedHistory = computed(() => {
158
  const groups = {}
 
175
  return sortedGroups
176
  })
177
 
178
+ // 选项卡切换和滚动居中
179
+ const switchTab = (tabName) => {
180
+ activeTab.value = tabName
181
+
182
+ // 滚动选中的选项卡到居中位置
183
+ nextTick(() => {
184
+ const tabsContainer = tabsRef.value
185
+ if (!tabsContainer) return
186
+
187
+ let targetTab = null
188
+ switch (tabName) {
189
+ case 'favorites':
190
+ targetTab = favoritesTab.value
191
+ break
192
+ case 'history':
193
+ targetTab = historyTab.value
194
+ break
195
+ }
196
+
197
+ if (targetTab && tabsContainer) {
198
+ const containerRect = tabsContainer.getBoundingClientRect()
199
+ const targetRect = targetTab.getBoundingClientRect()
200
+
201
+ const scrollLeft = targetRect.left - containerRect.left - (containerRect.width - targetRect.width) / 2
202
+
203
+ tabsContainer.scrollTo({
204
+ left: tabsContainer.scrollLeft + scrollLeft,
205
+ behavior: 'smooth'
206
+ })
207
+ }
208
+ })
209
+ }
210
+
211
+ // 跳转到设置页面
212
+ const goToSettings = () => {
213
+ router.push('/settings')
214
+ }
215
+
216
  // 方法
217
+ const handlePlayFromFavorites = async (song) => {
218
  // 从收藏列表播放
219
  if (Array.isArray(song)) {
220
  // 播放多首歌曲
221
+ const result = playQueueStore.setQueue(song, 0)
222
+ if (result && song.length > 0) {
223
+ await playerStore.playSong(song[0])
224
+ }
225
  } else {
226
  // 播放单首歌曲
 
227
  const playlist = favorites.value.map(item => item.song)
228
  const index = playlist.findIndex(s => s.id === song.id)
229
+ const result = playQueueStore.setQueue(playlist, index)
230
+ if (result) {
231
+ await playerStore.playSong(song)
232
+ }
233
  }
234
 
235
  // 添加到播放历史
 
240
  }
241
  }
242
 
243
+ // 播放历史相关方法
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
+ const clearHistory = () => {
246
+ confirmDialogRef.value?.show()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  }
248
 
249
+ const confirmClearHistory = async () => {
250
+ try {
251
+ await historyStore.clearHistory()
252
+ toastStore.success('播放历史已清空')
253
+ } catch (error) {
254
+ console.error('清空历史失败:', error)
255
+ toastStore.error('清空历史失败,请重试')
256
  }
257
  }
258
 
259
+ const handlePlay = async (song, index) => {
260
  let playlist = []
261
 
262
  switch (activeTab.value) {
 
267
  break
268
  }
269
 
270
+ const songIndex = playlist.findIndex(s => s.id === song.id)
271
+ const result = playQueueStore.setQueue(playlist, songIndex >= 0 ? songIndex : index)
272
+ if (result) {
273
+ await playerStore.playSong(song)
274
+ }
275
 
276
  // 添加到播放历史
277
  historyStore.addToHistory(song)
 
279
 
280
  const handleFavorite = async (song) => {
281
  try {
282
+ if (favoritesStore.isFavorite(song)) {
283
+ await favoritesStore.removeFromFavorites(song)
284
  } else {
285
+ await favoritesStore.addToFavorites(song)
286
  }
287
  } catch (error) {
288
  console.error('收藏操作失败:', error)
 
294
  console.log('更多操作:', song)
295
  }
296
 
297
+ const handleBatchAction = (data) => {
298
+ const { action, songs, count } = data
299
+
300
+ switch (action) {
301
+ case 'play':
302
+ toastStore.info(`开始播放选中的 ${songs.length} 首歌曲`)
303
+ break
304
+ case 'add-to-playlist':
305
+ toastStore.info(`批量添加 ${songs.length} 首歌曲到播放列表`)
306
+ break
307
+ case 'remove':
308
+ toastStore.success(`批量删除完成,删除了 ${count} 首歌曲`)
309
+ break
310
+ }
311
+ }
312
+
313
+ const handleMoreAction = (data) => {
314
+ const { action, song } = data
315
+
316
+ switch (action) {
317
+ case 'play-next':
318
+ toastStore.success(`"${song.name}" 已添加到下一首播放`)
319
+ break
320
+ case 'add-to-playlist':
321
+ toastStore.info(`正在添加 "${song.name}" 到播放列表`)
322
+ break
323
+ case 'copy-link':
324
+ toastStore.success(`"${song.name}" 链接已复制`)
325
+ break
326
+ case 'view-details':
327
+ toastStore.info(`正在查看 "${song.name}" 详情`)
328
+ break
329
+ }
330
+ }
331
+
332
  const formatDate = (dateString) => {
333
  const date = new Date(dateString)
334
  const today = new Date()
 
391
  gap: 8px;
392
  }
393
 
394
+ .settings-btn {
395
+ width: 40px;
396
+ height: 40px;
397
+ border: none;
398
+ background: rgba(255, 255, 255, 0.1);
399
+ color: var(--text-secondary);
400
+ border-radius: 50%;
401
+ font-size: 16px;
402
+ cursor: pointer;
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ transition: var(--transition-fast);
407
+ }
408
+
409
+ .settings-btn:hover {
410
+ background: rgba(255, 255, 255, 0.2);
411
+ color: var(--text-primary);
412
+ }
413
+
414
  .action-btn {
415
  display: flex;
416
  align-items: center;
 
436
 
437
  .stats-cards {
438
  display: grid;
439
+ grid-template-columns: repeat(2, 1fr);
440
  gap: 12px;
441
  padding: 20px 16px;
442
  background: var(--bg-card);
src/views/PlayQueuePage.vue ADDED
@@ -0,0 +1,950 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="play-queue-page">
3
+ <!-- 头部 -->
4
+ <div class="page-header">
5
+ <h1 class="page-title">
6
+ <i class="fas fa-list"></i>
7
+ 播放列表
8
+ </h1>
9
+ <div class="header-actions">
10
+ <button v-if="!isEmpty" class="action-btn" @click="shuffleQueue">
11
+ <i class="fas fa-random"></i>
12
+ </button>
13
+ <button v-if="!isEmpty" class="action-btn danger" @click="confirmClearQueue">
14
+ <i class="fas fa-trash"></i>
15
+ </button>
16
+ <button class="action-btn settings-btn" @click="goToSettings">
17
+ <i class="fas fa-cog"></i>
18
+ </button>
19
+ </div>
20
+ </div>
21
+
22
+ <!-- 搜索框 -->
23
+ <div v-if="!isEmpty" class="search-section">
24
+ <div class="search-box">
25
+ <i class="fas fa-search"></i>
26
+ <input
27
+ v-model="searchKeyword"
28
+ type="text"
29
+ placeholder="在播放列表中搜索..."
30
+ class="search-input"
31
+ />
32
+ <button v-if="searchKeyword" @click="clearSearch" class="clear-btn">
33
+ <i class="fas fa-times"></i>
34
+ </button>
35
+ </div>
36
+ </div>
37
+
38
+ <!-- 内容区域 -->
39
+ <div class="content-area">
40
+ <!-- 空状态 -->
41
+ <div v-if="isEmpty" class="empty-state">
42
+ <i class="fas fa-list-music"></i>
43
+ <p>播放列表为空</p>
44
+ <p class="empty-tip">添加一些歌曲到列表开始播放吧</p>
45
+ <button class="discover-btn" @click="goToHome">
46
+ <i class="fas fa-search"></i>
47
+ 去发现音乐
48
+ </button>
49
+ </div>
50
+
51
+ <!-- 队列列表 -->
52
+ <div v-else class="queue-list">
53
+ <div
54
+ v-for="(song, index) in displayedQueue"
55
+ :key="`${song.id}-${index}`"
56
+ class="queue-item"
57
+ :class="{
58
+ current: index === currentIndex && !searchKeyword,
59
+ played: index < currentIndex && !searchKeyword
60
+ }"
61
+ >
62
+ <!-- 拖拽手柄 -->
63
+ <div class="drag-handle" @mousedown="startDrag(index, $event)">
64
+ <i class="fas fa-grip-vertical"></i>
65
+ </div>
66
+
67
+ <!-- 歌曲信息 -->
68
+ <div class="song-content" @click="playAtIndex(index)">
69
+ <div class="song-cover">
70
+ <img
71
+ :src="getSongCover()"
72
+ :alt="song.name"
73
+ :data-song-data="JSON.stringify(song)"
74
+ @error="handleImageError"
75
+ />
76
+ <div class="play-overlay">
77
+ <i class="fas fa-play"></i>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="song-info">
82
+ <h3 class="song-name">{{ song.name }}</h3>
83
+ <p class="song-meta">
84
+ <span class="artist">{{ formatArtist(song.artist) }}</span>
85
+ </p>
86
+ <p v-if="song.duration" class="song-duration">{{ formatTime(song.duration) }}</p>
87
+ </div>
88
+
89
+ <!-- 当前播放指示器 -->
90
+ <div v-if="index === currentIndex && !searchKeyword" class="current-indicator">
91
+ <div class="playing-icon">
92
+ <i class="fas fa-volume-up"></i>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- 操作按钮 -->
98
+ <div class="song-actions">
99
+ <button
100
+ class="action-btn favorite-btn"
101
+ :class="{ active: isFavorite(song) }"
102
+ @click="toggleFavorite(song)"
103
+ >
104
+ <i :class="isFavorite(song) ? 'fas fa-heart' : 'far fa-heart'"></i>
105
+ </button>
106
+ <button class="action-btn" @click="removeFromQueue(index)">
107
+ <i class="fas fa-times"></i>
108
+ </button>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- 确认对话框 -->
115
+ <ConfirmDialog
116
+ ref="confirmDialogRef"
117
+ :title="confirmDialog.title"
118
+ :message="confirmDialog.message"
119
+ :confirm-text="confirmDialog.confirmText"
120
+ :type="confirmDialog.type"
121
+ @confirm="handleConfirm"
122
+ />
123
+ </div>
124
+ </template>
125
+
126
+ <script setup>
127
+ import { ref, computed, onMounted } from 'vue'
128
+ import { useRouter } from 'vue-router'
129
+ import { usePlayQueueStore } from '@/stores/playqueue'
130
+ import { useFavoritesStore } from '@/stores/favorites'
131
+ import { usePlayerStore } from '@/stores/player'
132
+ import { useToastStore } from '@/stores/toast'
133
+ import { useSongCoverLoader } from '@/composables/useSongCoverLoader'
134
+ import { utils } from '@/services/musicApi'
135
+ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
136
+
137
+ const router = useRouter()
138
+ const playQueueStore = usePlayQueueStore()
139
+ const favoritesStore = useFavoritesStore()
140
+ const playerStore = usePlayerStore()
141
+ const toastStore = useToastStore()
142
+ const { getDefaultCover, handleImageError, observeImage } = useSongCoverLoader()
143
+
144
+ // 响应式数据
145
+ const searchKeyword = ref('')
146
+ const confirmDialogRef = ref(null)
147
+ const isDragging = ref(false)
148
+ const dragStartIndex = ref(-1)
149
+
150
+ // 确认对话框状态
151
+ const confirmDialog = ref({
152
+ title: '',
153
+ message: '',
154
+ confirmText: '确定',
155
+ type: 'warning',
156
+ action: null,
157
+ data: null
158
+ })
159
+
160
+ // 计算属性
161
+ const queue = computed(() => playQueueStore.queue)
162
+ const currentIndex = computed(() => playQueueStore.currentIndex)
163
+ const currentSong = computed(() => playQueueStore.currentSong)
164
+ const playMode = computed(() => playQueueStore.playMode)
165
+ const queueLength = computed(() => playQueueStore.queueLength)
166
+ const isEmpty = computed(() => playQueueStore.isEmpty)
167
+ const isPlaying = computed(() => playerStore.isPlaying)
168
+
169
+ // 搜索结果
170
+ const displayedQueue = computed(() => {
171
+ if (!searchKeyword.value.trim()) {
172
+ return queue.value
173
+ }
174
+ return playQueueStore.searchInQueue(searchKeyword.value).map(item => item.song)
175
+ })
176
+
177
+ // 统计信息
178
+ const totalDuration = computed(() => {
179
+ return queue.value.reduce((total, song) => total + (song.duration || 0), 0)
180
+ })
181
+
182
+ const remainingCount = computed(() => {
183
+ return Math.max(0, queueLength.value - currentIndex.value - 1)
184
+ })
185
+
186
+ // 播放模式相关
187
+ const playModeText = computed(() => {
188
+ const modes = {
189
+ 'list': '顺序播放',
190
+ 'random': '随机播放',
191
+ 'single': '单曲循环'
192
+ }
193
+ return modes[playMode.value] || '顺序播放'
194
+ })
195
+
196
+ const playModeIcon = computed(() => {
197
+ const icons = {
198
+ 'list': 'fas fa-list',
199
+ 'random': 'fas fa-random',
200
+ 'single': 'fas fa-sync-alt'
201
+ }
202
+ return icons[playMode.value] || 'fas fa-list'
203
+ })
204
+
205
+ // 方法
206
+ const formatTime = (seconds) => {
207
+ if (!seconds) return '0:00'
208
+ const mins = Math.floor(seconds / 60)
209
+ const secs = Math.floor(seconds % 60)
210
+ return `${mins}:${secs.toString().padStart(2, '0')}`
211
+ }
212
+
213
+ const formatArtist = (artist) => {
214
+ return utils.formatArtist(artist)
215
+ }
216
+
217
+ const formatDuration = (seconds) => {
218
+ if (!seconds) return '0分钟'
219
+ const hours = Math.floor(seconds / 3600)
220
+ const minutes = Math.floor((seconds % 3600) / 60)
221
+ if (hours > 0) {
222
+ return `${hours}小时${minutes}分钟`
223
+ }
224
+ return `${minutes}分钟`
225
+ }
226
+
227
+ // 方法
228
+ // 图片加载相关
229
+ const getSongCover = () => {
230
+ return getDefaultCover()
231
+ }
232
+
233
+ // 初始化懒加载观察
234
+ const initLazyImages = () => {
235
+ const imageElements = document.querySelectorAll('.queue-item .song-cover img')
236
+ imageElements.forEach((img, index) => {
237
+ const songData = img.dataset.songData
238
+ if (songData) {
239
+ try {
240
+ const song = JSON.parse(songData)
241
+ observeImage(img, song)
242
+ } catch (error) {
243
+ console.error('解析歌曲数据失败:', error)
244
+ }
245
+ }
246
+ })
247
+ }
248
+
249
+ const clearSearch = () => {
250
+ searchKeyword.value = ''
251
+ }
252
+
253
+ const goToHome = () => {
254
+ router.push('/home')
255
+ }
256
+
257
+ const goToSettings = () => {
258
+ router.push('/settings')
259
+ }
260
+
261
+ // 收藏相关
262
+ const isFavorite = (song) => {
263
+ return favoritesStore.isFavorite(song)
264
+ }
265
+
266
+ const toggleFavorite = async (song) => {
267
+ const result = await favoritesStore.toggleFavorite(song)
268
+ const message = result ? '已添加到我喜欢的音乐' : '已从我喜欢的音乐中移除'
269
+ toastStore.success(message)
270
+ }
271
+
272
+ // 播放控制
273
+ const playAtIndex = async (index) => {
274
+ if (searchKeyword.value.trim()) {
275
+ // 如果在搜索状态,直接播放歌曲
276
+ const song = displayedQueue.value[index]
277
+ await playerStore.playSong(song)
278
+ toastStore.success(`开始播放 "${song.name}"`)
279
+ return
280
+ }
281
+
282
+ // 正常队列播放
283
+ const song = playQueueStore.playAtIndex(index)
284
+ if (song) {
285
+ await playerStore.playSong(song, true) // restoreProgress = true
286
+ toastStore.success(`开始播放 "${song.name}"`)
287
+ }
288
+ }
289
+
290
+ const togglePlayMode = () => {
291
+ const newMode = playQueueStore.togglePlayMode()
292
+ toastStore.info(`切换到${playModeText.value}`)
293
+ }
294
+
295
+ const shuffleQueue = () => {
296
+ playQueueStore.shuffleQueue()
297
+ toastStore.success('队列已随机排列')
298
+ }
299
+
300
+ const removeFromQueue = (index) => {
301
+ if (searchKeyword.value.trim()) {
302
+ toastStore.warning('搜索状态下无法删除,请清空搜索后操作')
303
+ return
304
+ }
305
+
306
+ const song = queue.value[index]
307
+ const result = playQueueStore.removeFromQueue(index)
308
+
309
+ if (result.success) {
310
+ toastStore.success(`"${song.name}" 已从列表移除`)
311
+ } else {
312
+ toastStore.error(result.message)
313
+ }
314
+ }
315
+
316
+ const confirmClearQueue = () => {
317
+ confirmDialog.value = {
318
+ title: '清空播放列表',
319
+ message: '确定要清空整个播放列表吗?当前播放的歌曲也会停止。',
320
+ confirmText: '清空',
321
+ type: 'warning',
322
+ action: 'clear-queue',
323
+ data: null
324
+ }
325
+
326
+ confirmDialogRef.value?.show()
327
+ }
328
+
329
+ // 确认对话框处理
330
+ const handleConfirm = () => {
331
+ const { action } = confirmDialog.value
332
+
333
+ switch (action) {
334
+ case 'clear-queue':
335
+ playQueueStore.clearQueue()
336
+ playerStore.stop()
337
+ toastStore.success('播放列表已清空')
338
+ break
339
+ }
340
+ }
341
+
342
+ // 拖拽功能(基础实现)
343
+ const startDrag = (index, event) => {
344
+ if (searchKeyword.value.trim()) {
345
+ toastStore.warning('搜索状态下无法拖拽排序')
346
+ return
347
+ }
348
+
349
+ isDragging.value = true
350
+ dragStartIndex.value = index
351
+
352
+ // TODO: 实现完整的拖拽功能
353
+ toastStore.info('拖拽排序功能开发中...')
354
+ }
355
+
356
+ // 生命周期
357
+ onMounted(() => {
358
+ // 加载播放队列
359
+ playQueueStore.loadQueue()
360
+ favoritesStore.loadFavorites()
361
+
362
+ // 初始化懒加载
363
+ setTimeout(() => {
364
+ initLazyImages()
365
+ }, 100)
366
+ })
367
+ </script>
368
+
369
+ <style scoped>
370
+ .play-queue-page {
371
+ height: 100%;
372
+ background: var(--bg-primary);
373
+ overflow-y: auto;
374
+ }
375
+
376
+ .page-header {
377
+ display: flex;
378
+ align-items: center;
379
+ justify-content: space-between;
380
+ padding: 16px;
381
+ border-bottom: 1px solid var(--border-lighter);
382
+ }
383
+
384
+ .page-title {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 12px;
388
+ font-size: 24px;
389
+ font-weight: 700;
390
+ color: var(--text-primary);
391
+ margin: 0;
392
+ }
393
+
394
+ .page-title i {
395
+ color: var(--accent-green);
396
+ }
397
+
398
+ .header-actions {
399
+ display: flex;
400
+ gap: 8px;
401
+ }
402
+
403
+ .action-btn {
404
+ display: flex;
405
+ align-items: center;
406
+ gap: 6px;
407
+ padding: 8px 16px;
408
+ border: none;
409
+ background: var(--accent-green);
410
+ color: white;
411
+ border-radius: 20px;
412
+ font-size: 14px;
413
+ font-weight: 500;
414
+ cursor: pointer;
415
+ transition: var(--transition-fast);
416
+ }
417
+
418
+ .action-btn:hover {
419
+ background: var(--accent-green-hover);
420
+ transform: translateY(-1px);
421
+ }
422
+
423
+ .action-btn.danger {
424
+ background: #ff4444;
425
+ }
426
+
427
+ .action-btn.danger:hover {
428
+ background: #ff6666;
429
+ }
430
+
431
+ .action-btn.settings-btn {
432
+ background: var(--bg-overlay);
433
+ color: var(--text-secondary);
434
+ }
435
+
436
+ .action-btn.settings-btn:hover {
437
+ background: var(--bg-hover);
438
+ color: var(--text-primary);
439
+ }
440
+
441
+ .current-playing {
442
+ display: flex;
443
+ align-items: center;
444
+ justify-content: space-between;
445
+ padding: 20px 16px;
446
+ background: var(--bg-card);
447
+ margin: 16px;
448
+ border-radius: var(--radius-medium);
449
+ border: 1px solid var(--border-light);
450
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
451
+ }
452
+
453
+ .current-info {
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 16px;
457
+ flex: 1;
458
+ min-width: 0;
459
+ }
460
+
461
+ .current-cover {
462
+ position: relative;
463
+ width: 60px;
464
+ height: 60px;
465
+ border-radius: var(--radius-medium);
466
+ overflow: hidden;
467
+ flex-shrink: 0;
468
+ }
469
+
470
+ .current-cover img {
471
+ width: 100%;
472
+ height: 100%;
473
+ object-fit: cover;
474
+ }
475
+
476
+ .playing-indicator {
477
+ position: absolute;
478
+ top: 0;
479
+ left: 0;
480
+ right: 0;
481
+ bottom: 0;
482
+ background: rgba(0, 0, 0, 0.3);
483
+ display: flex;
484
+ align-items: center;
485
+ justify-content: center;
486
+ }
487
+
488
+ .wave-bars {
489
+ display: flex;
490
+ gap: 2px;
491
+ align-items: center;
492
+ }
493
+
494
+ .bar {
495
+ width: 3px;
496
+ height: 12px;
497
+ background: white;
498
+ border-radius: 2px;
499
+ opacity: 0.6;
500
+ }
501
+
502
+ .bar.animate {
503
+ animation: wave 1.2s ease-in-out infinite;
504
+ }
505
+
506
+ .bar:nth-child(1) { animation-delay: 0s; }
507
+ .bar:nth-child(2) { animation-delay: 0.2s; }
508
+ .bar:nth-child(3) { animation-delay: 0.4s; }
509
+
510
+ @keyframes wave {
511
+ 0%, 100% { height: 6px; opacity: 0.6; }
512
+ 50% { height: 16px; opacity: 1; }
513
+ }
514
+
515
+ .current-details {
516
+ flex: 1;
517
+ min-width: 0;
518
+ }
519
+
520
+ .current-name {
521
+ font-size: 18px;
522
+ font-weight: 700;
523
+ color: var(--text-primary);
524
+ margin: 0 0 4px;
525
+ overflow: hidden;
526
+ text-overflow: ellipsis;
527
+ white-space: nowrap;
528
+ }
529
+
530
+ .current-meta {
531
+ font-size: 14px;
532
+ color: var(--text-secondary);
533
+ margin: 0 0 4px;
534
+ overflow: hidden;
535
+ text-overflow: ellipsis;
536
+ white-space: nowrap;
537
+ }
538
+
539
+ .current-status {
540
+ font-size: 12px;
541
+ color: var(--accent-green);
542
+ margin: 0;
543
+ font-weight: 600;
544
+ }
545
+
546
+ .playmode-control {
547
+ flex-shrink: 0;
548
+ }
549
+
550
+ .playmode-btn {
551
+ width: 48px;
552
+ height: 48px;
553
+ border: none;
554
+ background: rgba(255, 255, 255, 0.1);
555
+ color: var(--text-secondary);
556
+ border-radius: 50%;
557
+ font-size: 16px;
558
+ cursor: pointer;
559
+ transition: var(--transition-fast);
560
+ display: flex;
561
+ align-items: center;
562
+ justify-content: center;
563
+ }
564
+
565
+ .playmode-btn:hover {
566
+ background: rgba(255, 255, 255, 0.2);
567
+ color: var(--text-primary);
568
+ }
569
+
570
+ .playmode-btn.random {
571
+ color: var(--accent-orange);
572
+ background: rgba(255, 165, 0, 0.2);
573
+ }
574
+
575
+ .playmode-btn.single {
576
+ color: var(--accent-purple);
577
+ background: rgba(147, 51, 234, 0.2);
578
+ }
579
+
580
+ .queue-stats {
581
+ display: grid;
582
+ grid-template-columns: repeat(3, 1fr);
583
+ gap: 12px;
584
+ padding: 16px;
585
+ background: var(--bg-card);
586
+ margin: 0 16px 16px;
587
+ border-radius: var(--radius-medium);
588
+ border: 1px solid var(--border-light);
589
+ }
590
+
591
+ .stat-item {
592
+ text-align: center;
593
+ padding: 12px 8px;
594
+ }
595
+
596
+ .stat-number {
597
+ font-size: 18px;
598
+ font-weight: 700;
599
+ color: var(--accent-green);
600
+ margin-bottom: 4px;
601
+ }
602
+
603
+ .stat-label {
604
+ font-size: 12px;
605
+ color: var(--text-secondary);
606
+ }
607
+
608
+ .search-section {
609
+ padding: 0 16px 16px;
610
+ }
611
+
612
+ .search-box {
613
+ display: flex;
614
+ align-items: center;
615
+ background: var(--bg-card);
616
+ border-radius: 25px;
617
+ padding: 12px 16px;
618
+ border: 1px solid var(--border-light);
619
+ }
620
+
621
+ .search-box i {
622
+ color: var(--text-secondary);
623
+ margin-right: 12px;
624
+ }
625
+
626
+ .search-input {
627
+ flex: 1;
628
+ border: none;
629
+ background: transparent;
630
+ color: var(--text-primary);
631
+ font-size: 14px;
632
+ outline: none;
633
+ }
634
+
635
+ .search-input::placeholder {
636
+ color: var(--text-tertiary);
637
+ }
638
+
639
+ .clear-btn {
640
+ border: none;
641
+ background: transparent;
642
+ color: var(--text-secondary);
643
+ cursor: pointer;
644
+ padding: 4px;
645
+ border-radius: 50%;
646
+ transition: var(--transition-fast);
647
+ }
648
+
649
+ .clear-btn:hover {
650
+ background: rgba(255, 255, 255, 0.1);
651
+ color: var(--text-primary);
652
+ }
653
+
654
+ .content-area {
655
+ flex: 1;
656
+ min-height: 0;
657
+ }
658
+
659
+ .empty-state {
660
+ display: flex;
661
+ flex-direction: column;
662
+ align-items: center;
663
+ justify-content: center;
664
+ padding: 60px 40px;
665
+ text-align: center;
666
+ color: var(--text-tertiary);
667
+ }
668
+
669
+ .empty-state i {
670
+ font-size: 64px;
671
+ margin-bottom: 20px;
672
+ opacity: 0.5;
673
+ }
674
+
675
+ .empty-state p {
676
+ font-size: 16px;
677
+ margin-bottom: 8px;
678
+ }
679
+
680
+ .empty-tip {
681
+ font-size: 14px;
682
+ color: var(--text-secondary);
683
+ margin-bottom: 24px;
684
+ }
685
+
686
+ .discover-btn {
687
+ display: flex;
688
+ align-items: center;
689
+ gap: 8px;
690
+ padding: 12px 24px;
691
+ border: none;
692
+ background: var(--accent-green);
693
+ color: white;
694
+ border-radius: 25px;
695
+ font-size: 14px;
696
+ font-weight: 500;
697
+ cursor: pointer;
698
+ transition: var(--transition-fast);
699
+ }
700
+
701
+ .discover-btn:hover {
702
+ background: var(--accent-green-hover);
703
+ transform: scale(1.05);
704
+ }
705
+
706
+ .queue-list {
707
+ padding-bottom: calc(var(--mini-player-height) + var(--tabbar-height) + 20px);
708
+ }
709
+
710
+ .queue-item {
711
+ display: flex;
712
+ align-items: center;
713
+ padding: 12px 16px;
714
+ border-bottom: 1px solid var(--border-lighter);
715
+ background: var(--bg-card);
716
+ transition: var(--transition-fast);
717
+ }
718
+
719
+ .queue-item:hover {
720
+ background: var(--bg-hover);
721
+ }
722
+
723
+ .queue-item.current {
724
+ background: rgba(34, 197, 94, 0.1);
725
+ border-left: 4px solid var(--accent-green);
726
+ }
727
+
728
+ .queue-item.played {
729
+ opacity: 0.6;
730
+ }
731
+
732
+ .drag-handle {
733
+ width: 24px;
734
+ height: 24px;
735
+ display: flex;
736
+ align-items: center;
737
+ justify-content: center;
738
+ color: var(--text-tertiary);
739
+ cursor: grab;
740
+ margin-right: 12px;
741
+ flex-shrink: 0;
742
+ }
743
+
744
+ .drag-handle:active {
745
+ cursor: grabbing;
746
+ }
747
+
748
+ .drag-handle:hover {
749
+ color: var(--text-secondary);
750
+ }
751
+
752
+ .song-content {
753
+ display: flex;
754
+ align-items: center;
755
+ flex: 1;
756
+ cursor: pointer;
757
+ gap: 12px;
758
+ min-width: 0;
759
+ }
760
+
761
+ .song-cover {
762
+ position: relative;
763
+ width: 50px;
764
+ height: 50px;
765
+ border-radius: var(--radius-small);
766
+ overflow: hidden;
767
+ flex-shrink: 0;
768
+ }
769
+
770
+ .song-cover img {
771
+ width: 100%;
772
+ height: 100%;
773
+ object-fit: cover;
774
+ }
775
+
776
+ .play-overlay {
777
+ position: absolute;
778
+ top: 0;
779
+ left: 0;
780
+ right: 0;
781
+ bottom: 0;
782
+ background: rgba(0, 0, 0, 0.5);
783
+ display: flex;
784
+ align-items: center;
785
+ justify-content: center;
786
+ opacity: 0;
787
+ transition: var(--transition-fast);
788
+ }
789
+
790
+ .song-cover:hover .play-overlay {
791
+ opacity: 1;
792
+ }
793
+
794
+ .play-overlay i {
795
+ color: white;
796
+ font-size: 16px;
797
+ }
798
+
799
+ .song-info {
800
+ flex: 1;
801
+ min-width: 0;
802
+ }
803
+
804
+ .song-name {
805
+ font-size: 16px;
806
+ font-weight: 600;
807
+ color: var(--text-primary);
808
+ margin: 0 0 4px;
809
+ overflow: hidden;
810
+ text-overflow: ellipsis;
811
+ white-space: nowrap;
812
+ }
813
+
814
+ .song-meta {
815
+ font-size: 13px;
816
+ color: var(--text-secondary);
817
+ margin: 0 0 4px;
818
+ overflow: hidden;
819
+ text-overflow: ellipsis;
820
+ white-space: nowrap;
821
+ }
822
+
823
+ .song-duration {
824
+ font-size: 11px;
825
+ color: var(--text-tertiary);
826
+ margin: 0;
827
+ }
828
+
829
+ .current-indicator {
830
+ display: flex;
831
+ align-items: center;
832
+ margin-left: 12px;
833
+ }
834
+
835
+ .playing-icon {
836
+ color: var(--accent-green);
837
+ font-size: 16px;
838
+ animation: pulse 2s infinite;
839
+ }
840
+
841
+ @keyframes pulse {
842
+ 0%, 100% { opacity: 1; }
843
+ 50% { opacity: 0.5; }
844
+ }
845
+
846
+ .song-actions {
847
+ display: flex;
848
+ gap: 8px;
849
+ flex-shrink: 0;
850
+ }
851
+
852
+ .song-actions .action-btn {
853
+ width: 36px;
854
+ height: 36px;
855
+ padding: 0;
856
+ border-radius: 50%;
857
+ background: rgba(255, 255, 255, 0.1);
858
+ color: var(--text-secondary);
859
+ display: flex;
860
+ align-items: center;
861
+ justify-content: center;
862
+ font-size: 14px;
863
+ }
864
+
865
+ .song-actions .action-btn:hover {
866
+ background: rgba(255, 255, 255, 0.2);
867
+ color: var(--text-primary);
868
+ transform: none;
869
+ }
870
+
871
+ .favorite-btn.active {
872
+ background: rgba(255, 107, 107, 0.2);
873
+ color: var(--accent-red);
874
+ }
875
+
876
+ .favorite-btn.active:hover {
877
+ background: rgba(255, 107, 107, 0.3);
878
+ color: var(--accent-red);
879
+ }
880
+
881
+ /* 响应式 */
882
+ @media (max-width: 375px) {
883
+ .page-header {
884
+ padding: 12px;
885
+ }
886
+
887
+ .page-title {
888
+ font-size: 20px;
889
+ }
890
+
891
+ .current-playing {
892
+ margin: 12px;
893
+ padding: 16px 12px;
894
+ }
895
+
896
+ .current-cover {
897
+ width: 50px;
898
+ height: 50px;
899
+ }
900
+
901
+ .current-name {
902
+ font-size: 16px;
903
+ }
904
+
905
+ .queue-stats {
906
+ margin: 0 12px 12px;
907
+ padding: 16px 12px;
908
+ }
909
+
910
+ .search-section {
911
+ padding: 0 12px 12px;
912
+ }
913
+
914
+ .queue-item {
915
+ padding: 12px;
916
+ }
917
+
918
+ .song-cover {
919
+ width: 45px;
920
+ height: 45px;
921
+ }
922
+
923
+ .song-name {
924
+ font-size: 15px;
925
+ }
926
+
927
+ .song-meta {
928
+ font-size: 12px;
929
+ }
930
+ }
931
+
932
+ @media (min-width: 768px) {
933
+ .play-queue-page {
934
+ max-width: 1200px;
935
+ margin: 0 auto;
936
+ }
937
+
938
+ .current-playing,
939
+ .queue-stats {
940
+ max-width: 800px;
941
+ margin-left: auto;
942
+ margin-right: auto;
943
+ }
944
+
945
+ .queue-list {
946
+ max-width: 800px;
947
+ margin: 0 auto;
948
+ }
949
+ }
950
+ </style>
src/views/PlaylistDetailPage.vue ADDED
@@ -0,0 +1,815 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
37
+ </div>
38
+
39
+ <!-- 播放控制栏 -->
40
+ <div class="play-controls" v-if="playlist?.songs?.length > 0">
41
+ <button class="play-all-btn" @click="playAll">
42
+ <i class="fas fa-play"></i>
43
+ 播放全部
44
+ </button>
45
+
46
+ <button class="shuffle-btn" @click="shufflePlay">
47
+ <i class="fas fa-random"></i>
48
+ 随机播放
49
+ </button>
50
+
51
+ <button
52
+ class="edit-btn"
53
+ :class="{ active: editMode }"
54
+ @click="toggleEditMode"
55
+ >
56
+ <i :class="editMode ? 'fas fa-times' : 'fas fa-edit'"></i>
57
+ {{ editMode ? '取消' : '编辑' }}
58
+ </button>
59
+ </div>
60
+
61
+ <!-- 歌曲列表 -->
62
+ <div class="songs-content">
63
+ <div v-if="!playlist?.songs?.length" class="empty-playlist">
64
+ <i class="fas fa-music"></i>
65
+ <p>播放列表为空</p>
66
+ <p class="empty-tip">添加一些喜欢的歌曲吧</p>
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
+
131
+ <!-- 更多操作菜单 -->
132
+ <div v-if="showActions" class="actions-overlay" @click="hideActions">
133
+ <div class="actions-menu" @click.stop>
134
+ <button @click="editPlaylistInfo">
135
+ <i class="fas fa-edit"></i>
136
+ 编辑信息
137
+ </button>
138
+ <button @click="clearPlaylist" class="danger">
139
+ <i class="fas fa-trash"></i>
140
+ 清空列表
141
+ </button>
142
+ <button v-if="!playlist?.isDefault" @click="deletePlaylist" class="danger">
143
+ <i class="fas fa-trash-alt"></i>
144
+ 删除播放列表
145
+ </button>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- 歌曲操作菜单 -->
150
+ <div v-if="showSongMenu" class="actions-overlay" @click="hideSongActions">
151
+ <div class="actions-menu" @click.stop>
152
+ <button @click="playNext(selectedSong)">
153
+ <i class="fas fa-step-forward"></i>
154
+ 下一首播放
155
+ </button>
156
+ <button @click="addToOtherPlaylist">
157
+ <i class="fas fa-plus"></i>
158
+ 添加到其他播放列表
159
+ </button>
160
+ <button @click="removeSongFromMenu" class="danger">
161
+ <i class="fas fa-minus"></i>
162
+ 从播放列表移除
163
+ </button>
164
+ </div>
165
+ </div>
166
+
167
+ <!-- 确认对话框 -->
168
+ <ConfirmDialog
169
+ ref="confirmDialogRef"
170
+ :title="confirmTitle"
171
+ :message="confirmMessage"
172
+ :confirm-text="confirmButtonText"
173
+ type="danger"
174
+ @confirm="confirmAction"
175
+ />
176
+ </div>
177
+ </template>
178
+
179
+ <script setup>
180
+ import { ref, computed, onMounted, watch } from 'vue'
181
+ import { useRouter, useRoute } from 'vue-router'
182
+ import { usePlaylistStore } from '@/stores/playlist'
183
+ import { usePlayerStore } from '@/stores/player'
184
+ import { useFavoritesStore } from '@/stores/favorites'
185
+ 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()
192
+ const playlistStore = usePlaylistStore()
193
+ const playerStore = usePlayerStore()
194
+ const favoritesStore = useFavoritesStore()
195
+ const playQueueStore = usePlayQueueStore()
196
+ const toastStore = useToastStore()
197
+
198
+ // 响应式数据
199
+ 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 = () => {
219
+ const playlistId = route.params.id
220
+ if (playlistId) {
221
+ const foundPlaylist = playlistStore.getPlaylist(playlistId)
222
+ if (foundPlaylist) {
223
+ playlist.value = foundPlaylist
224
+ } else {
225
+ toastStore.error('播放列表不存在')
226
+ goBack()
227
+ }
228
+ }
229
+ }
230
+
231
+ const goBack = () => {
232
+ router.push('/playlists')
233
+ }
234
+
235
+ 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)
246
+ return date.toLocaleDateString()
247
+ }
248
+
249
+ // 播放控制
250
+ const playAll = async () => {
251
+ if (!playlist.value?.songs?.length) return
252
+
253
+ const result = playQueueStore.setQueue(playlist.value.songs, 0)
254
+ if (result && playlist.value.songs.length > 0) {
255
+ await playerStore.playSong(playlist.value.songs[0])
256
+ toastStore.success('开始播放播放列表')
257
+ }
258
+ }
259
+
260
+ const shufflePlay = async () => {
261
+ if (!playlist.value?.songs?.length) return
262
+
263
+ const shuffled = [...playlist.value.songs].sort(() => Math.random() - 0.5)
264
+ const result = playQueueStore.setQueue(shuffled, 0)
265
+ if (result && shuffled.length > 0) {
266
+ await playerStore.playSong(shuffled[0])
267
+ toastStore.success('开始随机播放')
268
+ }
269
+ }
270
+
271
+ const playSong = async (song, index) => {
272
+ if (!playlist.value?.songs) return
273
+
274
+ const result = playQueueStore.setQueue(playlist.value.songs, index)
275
+ if (result) {
276
+ await playerStore.playSong(song)
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
292
+ if (!editMode.value) {
293
+ hideActions()
294
+ hideSongActions()
295
+ }
296
+ }
297
+
298
+ 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()
311
+ }
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
325
+ showSongMenu.value = true
326
+ }
327
+
328
+ const hideSongActions = () => {
329
+ showSongMenu.value = false
330
+ selectedSong.value = null
331
+ selectedIndex.value = -1
332
+ }
333
+
334
+ const playNext = (song) => {
335
+ playerStore.playNext(song)
336
+ hideSongActions()
337
+ toastStore.success(`"${song.name}" 已添加到下一首播放`)
338
+ }
339
+
340
+ const addToOtherPlaylist = () => {
341
+ // TODO: 实现添加到其他播放列表的功能
342
+ hideSongActions()
343
+ toastStore.info('功能开发中...')
344
+ }
345
+
346
+ const removeSongFromMenu = () => {
347
+ hideSongActions()
348
+ removeSong(selectedIndex.value)
349
+ }
350
+
351
+ const editPlaylistInfo = () => {
352
+ // TODO: 实现编辑播放列表信息的功能
353
+ hideActions()
354
+ toastStore.info('编辑功能开发中...')
355
+ }
356
+
357
+ const clearPlaylist = () => {
358
+ hideActions()
359
+ confirmTitle.value = '清空播放列表'
360
+ confirmMessage.value = `确定要清空"${playlist.value.name}"中的所有歌曲吗?此操作不可撤销。`
361
+ confirmButtonText.value = '清空'
362
+ pendingAction.value = () => {
363
+ const success = playlistStore.clearPlaylist(playlist.value.id)
364
+ if (success) {
365
+ toastStore.success('播放列表已清空')
366
+ } else {
367
+ toastStore.error('清空失败')
368
+ }
369
+ }
370
+ confirmDialogRef.value?.show()
371
+ }
372
+
373
+ const deletePlaylist = () => {
374
+ hideActions()
375
+ confirmTitle.value = '删除播放列表'
376
+ confirmMessage.value = `确定要删除播放列表"${playlist.value.name}"吗?此操作不可撤销。`
377
+ confirmButtonText.value = '删除'
378
+ pendingAction.value = () => {
379
+ const success = playlistStore.deletePlaylist(playlist.value.id)
380
+ if (success) {
381
+ toastStore.success('播放列表已删除')
382
+ goBack()
383
+ } else {
384
+ toastStore.error('删除失败')
385
+ }
386
+ }
387
+ confirmDialogRef.value?.show()
388
+ }
389
+
390
+ const confirmAction = () => {
391
+ if (pendingAction.value) {
392
+ pendingAction.value()
393
+ pendingAction.value = null
394
+ }
395
+ }
396
+
397
+ // 生命周期
398
+ onMounted(() => {
399
+ loadPlaylist()
400
+ })
401
+
402
+ // 监听路由变化
403
+ watch(() => route.params.id, () => {
404
+ loadPlaylist()
405
+ })
406
+ </script>
407
+
408
+ <style scoped>
409
+ .playlist-detail-page {
410
+ height: 100%;
411
+ background: var(--bg-primary);
412
+ overflow-y: auto;
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);
466
+ display: flex;
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 {
496
+ display: flex;
497
+ gap: 16px;
498
+ font-size: 12px;
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,
537
+ .shuffle-btn,
538
+ .edit-btn {
539
+ display: flex;
540
+ align-items: center;
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);
548
+ }
549
+
550
+ .play-all-btn {
551
+ background: var(--accent-red);
552
+ color: white;
553
+ }
554
+
555
+ .play-all-btn:hover {
556
+ background: var(--accent-red-hover);
557
+ }
558
+
559
+ .shuffle-btn,
560
+ .edit-btn {
561
+ background: rgba(255, 255, 255, 0.1);
562
+ color: var(--text-secondary);
563
+ }
564
+
565
+ .shuffle-btn:hover,
566
+ .edit-btn:hover {
567
+ background: rgba(255, 255, 255, 0.2);
568
+ color: var(--text-primary);
569
+ }
570
+
571
+ .edit-btn.active {
572
+ background: var(--accent-red);
573
+ color: white;
574
+ }
575
+
576
+ .songs-content {
577
+ flex: 1;
578
+ min-height: 0;
579
+ }
580
+
581
+ .empty-playlist {
582
+ display: flex;
583
+ flex-direction: column;
584
+ align-items: center;
585
+ justify-content: center;
586
+ padding: 60px 40px;
587
+ text-align: center;
588
+ color: var(--text-tertiary);
589
+ }
590
+
591
+ .empty-playlist i {
592
+ font-size: 64px;
593
+ margin-bottom: 20px;
594
+ opacity: 0.5;
595
+ }
596
+
597
+ .empty-playlist p {
598
+ font-size: 16px;
599
+ margin-bottom: 8px;
600
+ }
601
+
602
+ .empty-tip {
603
+ font-size: 14px;
604
+ color: var(--text-secondary);
605
+ }
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: rgba(255, 255, 255, 0.05);
630
+ }
631
+
632
+ .song-item.active {
633
+ background: rgba(255, 107, 107, 0.1);
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 {
733
+ position: fixed;
734
+ top: 0;
735
+ left: 0;
736
+ right: 0;
737
+ bottom: 0;
738
+ background: rgba(0, 0, 0, 0.6);
739
+ backdrop-filter: blur(4px);
740
+ z-index: 2000;
741
+ display: flex;
742
+ align-items: center;
743
+ justify-content: center;
744
+ }
745
+
746
+ .actions-menu {
747
+ background: var(--bg-card);
748
+ border-radius: 12px;
749
+ padding: 8px 0;
750
+ min-width: 200px;
751
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
752
+ border: 1px solid var(--border-light);
753
+ }
754
+
755
+ .actions-menu button {
756
+ display: flex;
757
+ align-items: center;
758
+ gap: 12px;
759
+ width: 100%;
760
+ padding: 12px 20px;
761
+ border: none;
762
+ background: transparent;
763
+ color: var(--text-primary);
764
+ font-size: 14px;
765
+ cursor: pointer;
766
+ transition: var(--transition-fast);
767
+ }
768
+
769
+ .actions-menu button:hover {
770
+ background: rgba(255, 255, 255, 0.1);
771
+ }
772
+
773
+ .actions-menu button.danger {
774
+ color: #ff4444;
775
+ }
776
+
777
+ .actions-menu button.danger:hover {
778
+ background: rgba(255, 68, 68, 0.1);
779
+ }
780
+
781
+ @keyframes pulse {
782
+ 0%, 100% { opacity: 1; }
783
+ 50% { opacity: 0.5; }
784
+ }
785
+
786
+ /* 响应式 */
787
+ @media (max-width: 375px) {
788
+ .playlist-header {
789
+ padding: 12px;
790
+ margin: 12px;
791
+ }
792
+
793
+ .playlist-cover {
794
+ width: 60px;
795
+ height: 60px;
796
+ }
797
+
798
+ .playlist-name {
799
+ font-size: 18px;
800
+ }
801
+
802
+ .play-controls {
803
+ padding: 12px;
804
+ margin: 0 12px 12px;
805
+ }
806
+
807
+ .songs-list {
808
+ margin: 0 12px;
809
+ }
810
+
811
+ .song-item {
812
+ padding: 10px 12px;
813
+ }
814
+ }
815
+ </style>
src/views/PlaylistsPage.vue ADDED
@@ -0,0 +1,632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <template>
2
+ <div class="playlists-page">
3
+ <!-- 头部 -->
4
+ <div class="page-header">
5
+ <h1 class="page-title">
6
+ <i class="fas fa-music"></i>
7
+ 我的歌单
8
+ </h1>
9
+ <div class="header-actions">
10
+ <button class="action-btn create-playlist" @click="openCreatePlaylistDialog">
11
+ <i class="fas fa-plus"></i>
12
+ <span>新建歌单</span>
13
+ </button>
14
+ </div>
15
+ </div>
16
+
17
+ <!-- 我的歌单 -->
18
+ <div class="playlists-content">
19
+ <div v-if="customPlaylists.length > 0" class="playlists-grid">
20
+ <div
21
+ v-for="playlist in customPlaylists"
22
+ :key="playlist.id"
23
+ class="playlist-card"
24
+ @click="openPlaylist(playlist)"
25
+ >
26
+ <div class="playlist-cover">
27
+ <img
28
+ v-if="playlist.cover"
29
+ :src="playlist.cover"
30
+ :alt="playlist.name"
31
+ @error="handleImageError"
32
+ />
33
+ <div v-else class="default-cover">
34
+ <i class="fas fa-music"></i>
35
+ </div>
36
+ <div class="play-overlay">
37
+ <button class="play-btn" @click.stop="playPlaylist(playlist)">
38
+ <i class="fas fa-play"></i>
39
+ </button>
40
+ </div>
41
+ </div>
42
+ <div class="playlist-info">
43
+ <h3 class="playlist-name">{{ playlist.name }}</h3>
44
+ <p class="playlist-count">{{ playlist.songs.length }}首歌曲</p>
45
+ <p class="playlist-updated">{{ formatDate(playlist.updatedAt) }}</p>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <!-- 空状态 -->
51
+ <div v-else class="empty-state">
52
+ <i class="fas fa-music"></i>
53
+ <p>还没有创建歌单</p>
54
+ <p class="empty-tip">创建专属的音乐收藏</p>
55
+ <button class="create-playlist-btn" @click="openCreatePlaylistDialog">
56
+ <i class="fas fa-plus"></i>
57
+ <span>创建第一个歌单</span>
58
+ </button>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- 新建歌单对话框 -->
63
+ <div v-if="showCreatePlaylist" class="create-playlist-overlay" @click="closeCreatePlaylist">
64
+ <div class="create-playlist-dialog" @click.stop>
65
+ <div class="dialog-header">
66
+ <h3>新建歌单</h3>
67
+ <button class="close-btn" @click="closeCreatePlaylist">
68
+ <i class="fas fa-times"></i>
69
+ </button>
70
+ </div>
71
+
72
+ <div class="dialog-body">
73
+ <div class="form-group">
74
+ <label>歌单名称</label>
75
+ <input
76
+ type="text"
77
+ v-model="newPlaylistName"
78
+ placeholder="请输入歌单名称"
79
+ maxlength="50"
80
+ ref="playlistNameInput"
81
+ />
82
+ </div>
83
+
84
+ <div class="form-group">
85
+ <label>描述 (可选)</label>
86
+ <textarea
87
+ v-model="newPlaylistDescription"
88
+ placeholder="请输入歌单描述"
89
+ maxlength="200"
90
+ rows="3"
91
+ ></textarea>
92
+ </div>
93
+ </div>
94
+
95
+ <div class="dialog-footer">
96
+ <button class="btn btn-cancel" @click="closeCreatePlaylist">取消</button>
97
+ <button class="btn btn-create" @click="createNewPlaylist" :disabled="!newPlaylistName.trim()">创建</button>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </template>
103
+
104
+ <script setup>
105
+ import { ref, computed, onMounted, nextTick } from 'vue'
106
+ import { useRouter } from 'vue-router'
107
+ import { usePlayerStore } from '@/stores/player'
108
+ import { usePlaylistStore } from '@/stores/playlist'
109
+ import { usePlayQueueStore } from '@/stores/playqueue'
110
+ import { useToastStore } from '@/stores/toast'
111
+ import { utils } from '@/services/musicApi'
112
+
113
+ const router = useRouter()
114
+ const playerStore = usePlayerStore()
115
+ const playlistStore = usePlaylistStore()
116
+ const playQueueStore = usePlayQueueStore()
117
+ const toastStore = useToastStore()
118
+
119
+ // 响应式数据
120
+ const showCreatePlaylist = ref(false)
121
+ const newPlaylistName = ref('')
122
+ const newPlaylistDescription = ref('')
123
+ const playlistNameInput = ref(null)
124
+
125
+ // 计算属性
126
+ const customPlaylists = computed(() => playlistStore.playlists || [])
127
+
128
+ // 方法
129
+ const playPlaylist = async (playlist) => {
130
+ if (playlist.songs.length === 0) return
131
+
132
+ // 用歌单替换播放队列并开始播放
133
+ const result = playQueueStore.setQueue(playlist.songs, 0)
134
+ if (result && playlist.songs.length > 0) {
135
+ await playerStore.playSong(playlist.songs[0])
136
+ }
137
+ }
138
+
139
+ const openPlaylist = (playlist) => {
140
+ // 跳转到歌单详情页
141
+ router.push(`/playlists/${playlist.id}`)
142
+ }
143
+
144
+ const handleImageError = (event) => {
145
+ // 图片加载失败时的处理
146
+ event.target.style.display = 'none'
147
+ }
148
+
149
+ // 新建歌单相关方法
150
+ const openCreatePlaylistDialog = () => {
151
+ showCreatePlaylist.value = true
152
+ nextTick(() => {
153
+ if (playlistNameInput.value) {
154
+ playlistNameInput.value.focus()
155
+ }
156
+ })
157
+ }
158
+
159
+ const closeCreatePlaylist = () => {
160
+ showCreatePlaylist.value = false
161
+ newPlaylistName.value = ''
162
+ newPlaylistDescription.value = ''
163
+ }
164
+
165
+ const createNewPlaylist = () => {
166
+ if (!newPlaylistName.value.trim()) return
167
+
168
+ try {
169
+ const newPlaylist = playlistStore.createPlaylist(
170
+ newPlaylistName.value.trim(),
171
+ newPlaylistDescription.value.trim()
172
+ )
173
+
174
+ console.log('创建歌单成功:', newPlaylist)
175
+ closeCreatePlaylist()
176
+
177
+ toastStore.success(`歌单 "${newPlaylist.name}" 创建成功!`)
178
+ } catch (error) {
179
+ console.error('创建歌单失败:', error)
180
+ toastStore.error('创建歌单失败,请重试')
181
+ }
182
+ }
183
+
184
+ const formatDate = (timestamp) => {
185
+ const date = new Date(timestamp)
186
+ const now = new Date()
187
+ const diff = now - date
188
+
189
+ if (diff < 3600000) { // 1小时内
190
+ return '刚刚更新'
191
+ } else if (diff < 86400000) { // 24小时内
192
+ return '今天'
193
+ } else if (diff < 2592000000) { // 30天内
194
+ return `${Math.floor(diff / 86400000)}天前`
195
+ } else {
196
+ return utils.formatDate ? utils.formatDate(date) : date.toLocaleDateString()
197
+ }
198
+ }
199
+
200
+ // 生命周期
201
+ onMounted(async () => {
202
+ try {
203
+ playlistStore.loadFromStorage()
204
+ } catch (error) {
205
+ console.error('加载歌单失败:', error)
206
+ }
207
+ })
208
+ </script>
209
+
210
+ <style scoped>
211
+ .playlists-page {
212
+ height: 100%;
213
+ background: var(--bg-primary);
214
+ overflow-y: auto;
215
+ padding-bottom: calc(var(--tabbar-height) + 20px);
216
+ }
217
+
218
+ .page-header {
219
+ display: flex;
220
+ align-items: center;
221
+ justify-content: space-between;
222
+ padding: 16px;
223
+ border-bottom: 1px solid var(--border-strong);
224
+ background: var(--bg-card);
225
+ margin: 0 16px;
226
+ border-radius: var(--radius-small) var(--radius-small) 0 0;
227
+ border: 1px solid var(--border-light);
228
+ border-bottom: 1px solid var(--border-strong);
229
+ }
230
+
231
+ .page-title {
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 12px;
235
+ font-size: 24px;
236
+ font-weight: 700;
237
+ color: var(--text-primary);
238
+ margin: 0;
239
+ }
240
+
241
+ .page-title i {
242
+ color: var(--accent-red);
243
+ }
244
+
245
+ .header-actions {
246
+ display: flex;
247
+ gap: 8px;
248
+ }
249
+
250
+ .action-btn {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 6px;
254
+ padding: 8px 16px;
255
+ border: none;
256
+ background: var(--accent-red);
257
+ color: white;
258
+ border-radius: 20px;
259
+ font-size: 14px;
260
+ cursor: pointer;
261
+ transition: var(--transition-fast);
262
+ }
263
+
264
+ .action-btn:hover:not(:disabled) {
265
+ background: var(--accent-red-hover);
266
+ }
267
+
268
+ .playlists-content {
269
+ flex: 1;
270
+ min-height: 0;
271
+ }
272
+
273
+ /* 歌单样式 */
274
+ .playlists-grid {
275
+ display: grid;
276
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
277
+ gap: 16px;
278
+ padding: 16px;
279
+ }
280
+
281
+ .playlist-card {
282
+ background: var(--bg-card);
283
+ border-radius: var(--radius-small);
284
+ overflow: hidden;
285
+ border: 1px solid var(--border-light);
286
+ cursor: pointer;
287
+ transition: var(--transition-fast);
288
+ }
289
+
290
+ .playlist-card:hover {
291
+ transform: translateY(-2px);
292
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
293
+ border-color: var(--accent-red);
294
+ }
295
+
296
+ .playlist-cover {
297
+ position: relative;
298
+ width: 100%;
299
+ height: 160px;
300
+ background: var(--bg-secondary);
301
+ overflow: hidden;
302
+ }
303
+
304
+ .playlist-cover img {
305
+ width: 100%;
306
+ height: 100%;
307
+ object-fit: cover;
308
+ }
309
+
310
+ .default-cover {
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: center;
314
+ width: 100%;
315
+ height: 100%;
316
+ background: linear-gradient(135deg, var(--accent-red), #ff8a8a);
317
+ color: white;
318
+ font-size: 48px;
319
+ }
320
+
321
+ .play-overlay {
322
+ position: absolute;
323
+ top: 0;
324
+ left: 0;
325
+ right: 0;
326
+ bottom: 0;
327
+ background: rgba(0, 0, 0, 0.5);
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ opacity: 0;
332
+ transition: var(--transition-fast);
333
+ }
334
+
335
+ .playlist-card:hover .play-overlay {
336
+ opacity: 1;
337
+ }
338
+
339
+ .play-btn {
340
+ width: 50px;
341
+ height: 50px;
342
+ border: none;
343
+ border-radius: 50%;
344
+ background: var(--accent-red);
345
+ color: white;
346
+ font-size: 18px;
347
+ cursor: pointer;
348
+ transition: var(--transition-fast);
349
+ }
350
+
351
+ .play-btn:hover {
352
+ background: var(--accent-red-hover);
353
+ transform: scale(1.1);
354
+ }
355
+
356
+ .playlist-info {
357
+ padding: 12px;
358
+ }
359
+
360
+ .playlist-name {
361
+ font-size: 14px;
362
+ font-weight: 600;
363
+ color: var(--text-primary);
364
+ margin: 0 0 4px;
365
+ overflow: hidden;
366
+ text-overflow: ellipsis;
367
+ white-space: nowrap;
368
+ }
369
+
370
+ .playlist-count {
371
+ font-size: 12px;
372
+ color: var(--text-secondary);
373
+ margin: 0 0 4px;
374
+ }
375
+
376
+ .playlist-updated {
377
+ font-size: 11px;
378
+ color: var(--text-tertiary);
379
+ margin: 0;
380
+ }
381
+
382
+ .empty-state {
383
+ display: flex;
384
+ flex-direction: column;
385
+ align-items: center;
386
+ justify-content: center;
387
+ padding: 60px 40px;
388
+ text-align: center;
389
+ color: var(--text-tertiary);
390
+ }
391
+
392
+ .empty-state i {
393
+ font-size: 64px;
394
+ margin-bottom: 20px;
395
+ opacity: 0.5;
396
+ }
397
+
398
+ .empty-state p {
399
+ font-size: 16px;
400
+ margin-bottom: 8px;
401
+ }
402
+
403
+ .empty-tip {
404
+ font-size: 14px;
405
+ color: var(--text-secondary);
406
+ margin-bottom: 24px;
407
+ }
408
+
409
+ .create-playlist-btn {
410
+ display: flex;
411
+ align-items: center;
412
+ gap: 8px;
413
+ padding: 12px 24px;
414
+ border: none;
415
+ background: var(--accent-red);
416
+ color: white;
417
+ border-radius: 20px;
418
+ font-size: 14px;
419
+ cursor: pointer;
420
+ transition: var(--transition-fast);
421
+ }
422
+
423
+ .create-playlist-btn:hover {
424
+ background: var(--accent-red-hover);
425
+ }
426
+
427
+ /* 新建歌单对话框样式 */
428
+ .create-playlist-overlay {
429
+ position: fixed;
430
+ top: 0;
431
+ left: 0;
432
+ right: 0;
433
+ bottom: 0;
434
+ background: rgba(0, 0, 0, 0.6);
435
+ backdrop-filter: blur(4px);
436
+ display: flex;
437
+ align-items: center;
438
+ justify-content: center;
439
+ z-index: 2000;
440
+ animation: fadeIn 0.3s ease;
441
+ }
442
+
443
+ .create-playlist-dialog {
444
+ width: 90%;
445
+ max-width: 480px;
446
+ background: var(--bg-card);
447
+ border-radius: 16px;
448
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
449
+ border: 1px solid var(--border-light);
450
+ animation: slideIn 0.3s ease;
451
+ }
452
+
453
+ .dialog-header {
454
+ display: flex;
455
+ align-items: center;
456
+ justify-content: space-between;
457
+ padding: 20px 24px;
458
+ border-bottom: 1px solid var(--border-lighter);
459
+ }
460
+
461
+ .dialog-header h3 {
462
+ font-size: 18px;
463
+ font-weight: 600;
464
+ color: var(--text-primary);
465
+ margin: 0;
466
+ }
467
+
468
+ .close-btn {
469
+ width: 32px;
470
+ height: 32px;
471
+ border: none;
472
+ background: rgba(255, 255, 255, 0.1);
473
+ border-radius: 50%;
474
+ color: var(--text-secondary);
475
+ cursor: pointer;
476
+ display: flex;
477
+ align-items: center;
478
+ justify-content: center;
479
+ font-size: 14px;
480
+ transition: var(--transition-fast);
481
+ }
482
+
483
+ .close-btn:hover {
484
+ background: rgba(255, 255, 255, 0.2);
485
+ color: var(--text-primary);
486
+ }
487
+
488
+ .dialog-body {
489
+ padding: 24px;
490
+ }
491
+
492
+ .form-group {
493
+ margin-bottom: 20px;
494
+ }
495
+
496
+ .form-group:last-child {
497
+ margin-bottom: 0;
498
+ }
499
+
500
+ .form-group label {
501
+ display: block;
502
+ font-size: 14px;
503
+ font-weight: 500;
504
+ color: var(--text-primary);
505
+ margin-bottom: 8px;
506
+ }
507
+
508
+ .form-group input,
509
+ .form-group textarea {
510
+ width: 100%;
511
+ padding: 12px 16px;
512
+ border: 2px solid var(--border-card);
513
+ border-radius: 8px;
514
+ background: rgba(255, 255, 255, 0.05);
515
+ color: var(--text-primary);
516
+ font-size: 14px;
517
+ transition: var(--transition-fast);
518
+ resize: vertical;
519
+ font-family: inherit;
520
+ box-sizing: border-box;
521
+ }
522
+
523
+ .form-group input:focus,
524
+ .form-group textarea:focus {
525
+ outline: none;
526
+ border-color: var(--accent-red);
527
+ background: rgba(255, 255, 255, 0.08);
528
+ }
529
+
530
+ .form-group input::placeholder,
531
+ .form-group textarea::placeholder {
532
+ color: var(--text-tertiary);
533
+ }
534
+
535
+ .dialog-footer {
536
+ display: flex;
537
+ align-items: center;
538
+ justify-content: flex-end;
539
+ gap: 12px;
540
+ padding: 20px 24px;
541
+ border-top: 1px solid var(--border-lighter);
542
+ }
543
+
544
+ .btn {
545
+ padding: 10px 20px;
546
+ border-radius: 8px;
547
+ font-size: 14px;
548
+ font-weight: 500;
549
+ cursor: pointer;
550
+ transition: var(--transition-fast);
551
+ border: none;
552
+ }
553
+
554
+ .btn-cancel {
555
+ background: rgba(255, 255, 255, 0.1);
556
+ color: var(--text-secondary);
557
+ }
558
+
559
+ .btn-cancel:hover {
560
+ background: rgba(255, 255, 255, 0.2);
561
+ color: var(--text-primary);
562
+ }
563
+
564
+ .btn-create {
565
+ background: var(--accent-red);
566
+ color: white;
567
+ }
568
+
569
+ .btn-create:hover:not(:disabled) {
570
+ background: var(--accent-red-hover);
571
+ }
572
+
573
+ .btn-create:disabled {
574
+ background: rgba(255, 255, 255, 0.1);
575
+ color: var(--text-tertiary);
576
+ cursor: not-allowed;
577
+ }
578
+
579
+ @keyframes fadeIn {
580
+ from { opacity: 0; }
581
+ to { opacity: 1; }
582
+ }
583
+
584
+ @keyframes slideIn {
585
+ from {
586
+ opacity: 0;
587
+ transform: translateY(-20px) scale(0.95);
588
+ }
589
+ to {
590
+ opacity: 1;
591
+ transform: translateY(0) scale(1);
592
+ }
593
+ }
594
+
595
+ /* 响应式 */
596
+ @media (max-width: 375px) {
597
+ .page-header {
598
+ padding: 12px;
599
+ margin: 0 12px;
600
+ }
601
+
602
+ .page-title {
603
+ font-size: 20px;
604
+ }
605
+
606
+ .playlists-grid {
607
+ padding: 12px;
608
+ gap: 12px;
609
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
610
+ }
611
+
612
+ .empty-state {
613
+ padding: 40px 20px;
614
+ }
615
+
616
+ .empty-state i {
617
+ font-size: 48px;
618
+ }
619
+ }
620
+
621
+ @media (min-width: 768px) {
622
+ .playlists-page {
623
+ max-width: 1200px;
624
+ margin: 0 auto;
625
+ }
626
+
627
+ .playlists-grid {
628
+ padding: 24px;
629
+ gap: 20px;
630
+ }
631
+ }
632
+ </style>
src/views/SettingsPage.vue CHANGED
@@ -308,13 +308,50 @@
308
  </div>
309
  </div>
310
 
311
- <!-- 存储管理 -->
312
  <div class="settings-section">
313
  <h2 class="section-title">
314
  <i class="fas fa-database"></i>
315
- 存储管理
316
  </h2>
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  <div class="setting-item">
319
  <div class="setting-label">
320
  <span>清除搜索历史</span>
@@ -347,6 +384,17 @@
347
  清除收藏数据
348
  </button>
349
  </div>
 
 
 
 
 
 
 
 
 
 
 
350
  </div>
351
 
352
  <!-- 关于信息 -->
@@ -366,20 +414,21 @@
366
  <p>基于 Vue 3 构建的渐进式音乐应用</p>
367
  </div>
368
  </div>
369
-
370
- <div class="setting-item">
371
- <div class="setting-label">
372
- <span>检查更新</span>
373
- <p class="setting-desc">检查是否有新版本可用</p>
374
- </div>
375
- <button class="action-button" @click="checkUpdate" :disabled="checkingUpdate">
376
- <i :class="checkingUpdate ? 'fas fa-spinner fa-spin' : 'fas fa-sync'"></i>
377
- {{ checkingUpdate ? '检查中...' : '检查更新' }}
378
- </button>
379
- </div>
380
  </div>
381
  </div>
382
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  <!-- 移动端选择器弹窗 -->
384
  <div v-if="showSelector" class="mobile-selector-overlay" @click="closeSelector">
385
  <div class="mobile-selector-content" @click.stop>
@@ -409,13 +458,16 @@
409
 
410
  <script setup>
411
  import { ref, computed, onMounted, onUnmounted } from 'vue'
 
412
  import { useSettingsStore } from '@/stores/settings'
413
  import { useFavoritesStore } from '@/stores/favorites'
414
  import { useHistoryStore } from '@/stores/history'
415
  import { useSearchStore } from '@/stores/search'
416
  import { useToastStore } from '@/stores/toast'
417
  import { MUSIC_SOURCES, QUALITY_OPTIONS } from '@/services/musicApi'
 
418
 
 
419
  const settingsStore = useSettingsStore()
420
  const favoritesStore = useFavoritesStore()
421
  const historyStore = useHistoryStore()
@@ -431,6 +483,18 @@ const selectorOptions = ref([])
431
  const selectorTitle = ref('')
432
  const selectorCurrentValue = ref('')
433
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  // 计算属性
435
  const settings = computed(() => settingsStore.settings)
436
  const appVersion = computed(() => '1.0.0')
@@ -475,6 +539,19 @@ const themeColors = [
475
  ]
476
 
477
  // 方法
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  const updateSetting = (key) => {
479
  settingsStore.updateSetting(key, settings.value[key])
480
  }
@@ -492,57 +569,133 @@ const updateAccentColor = (color) => {
492
  }
493
 
494
  const clearSearchHistory = async () => {
495
- if (confirm('确定要清除所有搜索历史吗?')) {
496
- try {
497
- searchStore.clearHistory()
498
- toastStore.success('搜索历史已清除')
499
- } catch (error) {
500
- console.error('清除搜索历史失败:', error)
501
- toastStore.error('清除失败')
 
 
 
 
 
502
  }
503
- }
504
  }
505
 
506
  const clearPlayHistory = async () => {
507
- if (confirm('确定要清除所有播放历史吗?')) {
508
- try {
509
- await historyStore.clearHistory()
510
- toastStore.success('播放历史已清除')
511
- } catch (error) {
512
- console.error('清除播放历史失败:', error)
513
- toastStore.error('清除失败')
514
- }
515
- }
516
- }
517
-
518
- const clearFavorites = async () => {
519
- if (confirm('确定要清除所有收藏歌曲吗?此操作不可撤销!')) {
520
- if (confirm('最后确认:真的要删除所有收藏吗?')) {
521
  try {
522
- await favoritesStore.clearAll()
523
- toastStore.success('收藏数据已清除')
524
  } catch (error) {
525
- console.error('清除收藏失败:', error)
526
  toastStore.error('清除失败')
527
  }
528
  }
529
- }
530
  }
531
 
532
- const checkUpdate = async () => {
533
- checkingUpdate.value = true
534
-
535
- try {
536
- // 在实际应用中,这里应该检查服务器是否有新版本
537
- await new Promise(resolve => setTimeout(resolve, 2000)) // 模拟网络请求
538
-
539
- toastStore.success('当前已是最新版本')
540
- } catch (error) {
541
- console.error('检查更新失败:', error)
542
- toastStore.error('检查更新失败')
543
- } finally {
544
- checkingUpdate.value = false
545
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  }
547
 
548
  // 响应式处理
@@ -607,6 +760,19 @@ const getThemeText = (value) => {
607
  return option ? option.label : '白色炫彩'
608
  }
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  // 生命周期
611
  onMounted(() => {
612
  settingsStore.loadSettings()
@@ -1070,6 +1236,14 @@ input:checked + .slider:before {
1070
  background: rgba(255, 68, 68, 0.3);
1071
  }
1072
 
 
 
 
 
 
 
 
 
1073
  /* 关于信息 */
1074
  .about-info {
1075
  display: flex;
 
308
  </div>
309
  </div>
310
 
311
+ <!-- 数据管理和快速入口 -->
312
  <div class="settings-section">
313
  <h2 class="section-title">
314
  <i class="fas fa-database"></i>
315
+ 数据管理
316
  </h2>
317
 
318
+ <!-- 新架构:快速入口 -->
319
+ <div class="setting-item">
320
+ <div class="setting-label">
321
+ <span>我喜欢的音乐</span>
322
+ <p class="setting-desc">查看和管理收藏的歌曲</p>
323
+ </div>
324
+ <button class="action-button" @click="goToFavorites">
325
+ <i class="fas fa-heart"></i>
326
+ 查看收藏
327
+ </button>
328
+ </div>
329
+
330
+ <div class="setting-item">
331
+ <div class="setting-label">
332
+ <span>播放历史</span>
333
+ <p class="setting-desc">查看播放记录和统计信息</p>
334
+ </div>
335
+ <button class="action-button" @click="goToHistory">
336
+ <i class="fas fa-history"></i>
337
+ 查看历史
338
+ </button>
339
+ </div>
340
+
341
+ <div class="setting-item">
342
+ <div class="setting-label">
343
+ <span>播放队列</span>
344
+ <p class="setting-desc">管理当前播放队列</p>
345
+ </div>
346
+ <button class="action-button" @click="goToPlayQueue">
347
+ <i class="fas fa-list-music"></i>
348
+ 查看队列
349
+ </button>
350
+ </div>
351
+
352
+ <!-- 分隔线 -->
353
+ <div class="setting-divider"></div>
354
+
355
  <div class="setting-item">
356
  <div class="setting-label">
357
  <span>清除搜索历史</span>
 
384
  清除收藏数据
385
  </button>
386
  </div>
387
+
388
+ <div class="setting-item">
389
+ <div class="setting-label">
390
+ <span>清除全部数据</span>
391
+ <p class="setting-desc danger-text">删除所有数据,包括播放历史、收藏、播放列表等</p>
392
+ </div>
393
+ <button class="action-button danger" @click="clearAllData">
394
+ <i class="fas fa-exclamation-triangle"></i>
395
+ 清除全部数据
396
+ </button>
397
+ </div>
398
  </div>
399
 
400
  <!-- 关于信息 -->
 
414
  <p>基于 Vue 3 构建的渐进式音乐应用</p>
415
  </div>
416
  </div>
 
 
 
 
 
 
 
 
 
 
 
417
  </div>
418
  </div>
419
 
420
+ <!-- 确认对话框 -->
421
+ <ConfirmDialog
422
+ ref="confirmDialog"
423
+ :title="confirmConfig.title"
424
+ :message="confirmConfig.message"
425
+ :type="confirmConfig.type"
426
+ :confirm-text="confirmConfig.confirmText"
427
+ :cancel-text="confirmConfig.cancelText"
428
+ @confirm="confirmConfig.onConfirm"
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>
 
458
 
459
  <script setup>
460
  import { ref, computed, onMounted, onUnmounted } from 'vue'
461
+ import { useRouter } from 'vue-router'
462
  import { useSettingsStore } from '@/stores/settings'
463
  import { useFavoritesStore } from '@/stores/favorites'
464
  import { useHistoryStore } from '@/stores/history'
465
  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()
472
  const favoritesStore = useFavoritesStore()
473
  const historyStore = useHistoryStore()
 
483
  const selectorTitle = ref('')
484
  const selectorCurrentValue = ref('')
485
 
486
+ // 确认对话框相关
487
+ const confirmDialog = ref(null)
488
+ const confirmConfig = ref({
489
+ title: '确认操作',
490
+ message: '',
491
+ type: 'normal',
492
+ confirmText: '确定',
493
+ cancelText: '取消',
494
+ onConfirm: () => {},
495
+ onCancel: () => {}
496
+ })
497
+
498
  // 计算属性
499
  const settings = computed(() => settingsStore.settings)
500
  const appVersion = computed(() => '1.0.0')
 
539
  ]
540
 
541
  // 方法
542
+ const showConfirm = (config) => {
543
+ confirmConfig.value = {
544
+ title: config.title || '确认操作',
545
+ message: config.message,
546
+ type: config.type || 'normal',
547
+ confirmText: config.confirmText || '确定',
548
+ cancelText: config.cancelText || '取消',
549
+ onConfirm: config.onConfirm || (() => {}),
550
+ onCancel: config.onCancel || (() => {})
551
+ }
552
+ confirmDialog.value?.show()
553
+ }
554
+
555
  const updateSetting = (key) => {
556
  settingsStore.updateSetting(key, settings.value[key])
557
  }
 
569
  }
570
 
571
  const clearSearchHistory = async () => {
572
+ showConfirm({
573
+ title: '清除搜索历史',
574
+ message: '确定要清除所有搜索历史吗?',
575
+ type: 'normal',
576
+ onConfirm: async () => {
577
+ try {
578
+ searchStore.clearHistory()
579
+ toastStore.success('搜索历史已清除')
580
+ } catch (error) {
581
+ console.error('清除搜索历史失败:', error)
582
+ toastStore.error('清除失败')
583
+ }
584
  }
585
+ })
586
  }
587
 
588
  const clearPlayHistory = async () => {
589
+ showConfirm({
590
+ title: '清除播放历史',
591
+ message: '确定要清除所有播放历史吗?',
592
+ type: 'normal',
593
+ onConfirm: async () => {
 
 
 
 
 
 
 
 
 
594
  try {
595
+ await historyStore.clearHistory()
596
+ toastStore.success('播放历史已清除')
597
  } catch (error) {
598
+ console.error('清除播放历史失败:', error)
599
  toastStore.error('清除失败')
600
  }
601
  }
602
+ })
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
  // 响应式处理
 
760
  return option ? option.label : '白色炫彩'
761
  }
762
 
763
+ // 快速入口导航方法
764
+ const goToFavorites = () => {
765
+ router.push('/favorites')
766
+ }
767
+
768
+ const goToHistory = () => {
769
+ router.push('/history')
770
+ }
771
+
772
+ const goToPlayQueue = () => {
773
+ router.push('/play-queue')
774
+ }
775
+
776
  // 生命周期
777
  onMounted(() => {
778
  settingsStore.loadSettings()
 
1236
  background: rgba(255, 68, 68, 0.3);
1237
  }
1238
 
1239
+ /* 设置项分隔线 */
1240
+ .setting-divider {
1241
+ height: 1px;
1242
+ background: var(--border-lighter);
1243
+ margin: 8px 16px;
1244
+ opacity: 0.5;
1245
+ }
1246
+
1247
  /* 关于信息 */
1248
  .about-info {
1249
  display: flex;
网易云音乐功能架构研究报告.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 网易云音乐功能架构研究报告
2
+
3
+ ## 1. 核心概念区分
4
+
5
+ ### 歌单 (Playlist)
6
+ - **定义**: 用户主动创建或收藏的音乐集合
7
+ - **特点**:
8
+ - 可以自定义命名
9
+ - 可以添加/删除歌曲
10
+ - 可以分享给其他用户
11
+ - 可以设置封面和描述
12
+ - **分类**:
13
+ - 创建的歌单 (用户自己创建)
14
+ - 收藏的歌单 (收藏别人的歌单)
15
+
16
+ ### 播放列表 (Play Queue/Now Playing)
17
+ - **定义**: 当前播放器中的歌曲队列
18
+ - **特点**:
19
+ - 临时性的,播放完就清空
20
+ - 可以来自任何歌单或搜索结果
21
+ - 支持添加下一首播放、播放全部等操作
22
+ - 不保存为永久收藏
23
+
24
+ ### "我喜欢的音乐"
25
+ - **定义**: 系统默认的特殊歌单
26
+ - **特点**:
27
+ - 系统预设,不可删除
28
+ - 通过"红心"按钮添加
29
+ - 代表用户最喜爱的歌曲集合
30
+ - 一般在界面上有特殊位置显示
31
+
32
+ ### 播放历史记录
33
+ - **定义**: 用户播放过的歌曲记录
34
+ - **特点**:
35
+ - 自动记录,无需手动添加
36
+ - 按时间倒序排列
37
+ - 可以从历史中重新播放
38
+ - 一般有数量限制(如最近1000首)
39
+
40
+ ## 2. 用户操作场景分析
41
+
42
+ ### 在搜索页面
43
+ - **红心按钮**: 添加到"我喜欢的音乐"
44
+ - **播放按钮**: 添加到播放队列并开始播放
45
+ - **通常不提供**: 直接添加到自定义歌单的选项
46
+
47
+ ### 在收藏页面 ("我喜欢的音乐")
48
+ - **播放**: 加入播放队列
49
+ - **更多操作菜单**:
50
+ - 添加到歌单
51
+ - 从喜欢中移除
52
+ - 下一首播放
53
+ - 分享、评论等
54
+
55
+ ### 在历史记录页面
56
+ - **重新播放**: 加入播放队列
57
+ - **更多操作菜单**:
58
+ - 添加到我喜欢的音乐
59
+ - 添加到歌单
60
+ - 下一首播放
61
+
62
+ ### 在全屏播放页面
63
+ - **当前播放歌曲的操作**:
64
+ - 红心 (添加到我喜欢的音乐)
65
+ - 添加到歌单
66
+ - 分享、评论等
67
+
68
+ ## 3. 业务逻辑总结
69
+
70
+ ### 播放逻辑
71
+ 1. **单首播放**: 播放队列 = [这首歌]
72
+ 2. **歌单播放**: 播放队列 = 整个歌单的歌曲列表
73
+ 3. **下一首播放**: 插入到当前播放位置的下一位
74
+
75
+ ### 收藏逻辑
76
+ 1. **红心**: 固定添加到"我喜欢的音乐"
77
+ 2. **添加到歌单**: 用户选择目标歌单,手动添加
78
+
79
+ ### 界面逻辑
80
+ 1. **搜索页面**: 功能简洁,主要是播放和收藏
81
+ 2. **收藏/历史页面**: 提供更多操作选项
82
+ 3. **播放页面**: 针对当前歌曲的完整操作集
83
+
84
+ ---
85
+
86
+ 1. 重写4个页面:歌单页面、播放列表页面、我喜欢的音乐页面、播放历史记录页面
87
+ 2. 删了重来:清理干净原来的逻辑,避免混乱
88
+ 3. 统一存储key:修改设置页面,保证用到的key统一
89
+ 4. 搜索和full页面小改动:
90
+ - 可以添加到播放列表或指定歌单
91
+ - 搜索不要点了播放才能添加到播放列表
92
+ - 防止重复添加
93
+
94
+ 让我先确认一下架构分离:
95
+
96
+ 4个独立概念:
97
+ 1. 歌单 - 用户创建的音乐收藏集合
98
+ 2. 播放列表 - 当前播放器的临时队列
99
+ 3. 我喜欢的音乐 - 特殊的收藏歌单
100
+ 4. 播放历史记录 - 自动记录的播放历史
101
+
102
+ 请问我理解对了吗?我是否应该:
103
+ 1. 先清理并重新设计这4个Store
104
+ 2. 然后重写对应的4个页面组件
105
+ 3. 统一存储key
106
+ 4. 最后小幅修改搜索和播放页面
需求文档.md DELETED
@@ -1,393 +0,0 @@
1
- # Vue3 PWA手机音乐软件需求文档
2
-
3
- ## 📱 页面结构需求(参考网易云音乐iOS端)
4
-
5
- ### 主要页面结构
6
- **底部导航栏(TabBar):**
7
- - 首页(搜索和推荐)
8
- - 我的音乐(收藏歌曲)
9
- - 设置
10
-
11
- **页面层级:**
12
- - 底部导航栏(固定)
13
- - 底部播放条(固定,在导航栏上方)
14
- - 主内容区域(可滚动)
15
- - 全屏播放器(覆盖整个屏幕)
16
-
17
- **响应式布局:**
18
- - 手机端:底部导航+单栏内容
19
- - 平板端:侧边导航+两栏布局
20
- - 桌面端:侧边导航+三栏布局
21
-
22
- ## 🏠 首页需求
23
-
24
- **顶部区域:**
25
- - 云音乐Logo
26
- - 搜索框(点击进入搜索页面)
27
-
28
- **主要内容:**
29
- - 搜索结果展示区域
30
- - 搜索历史
31
- - 热门推荐(可选)
32
-
33
- **搜索功能:**
34
- - 点击搜索框展开搜索界面
35
- - 音乐源选择(底部弹窗)
36
- - 瀑布流搜索结果
37
- - 实时搜索建议
38
-
39
- ## 🎵 我的音乐页面需求
40
-
41
- **页面内容:**
42
- - 我喜欢的音乐(收藏歌曲列表)
43
- - 最近播放历史
44
- - 播放统计信息
45
-
46
- **收藏歌曲功能:**
47
- - 显示收藏的所有歌曲
48
- - 支持搜索收藏内容
49
- - 支持批量操作
50
- - 显示收藏时间
51
-
52
- **播放历史:**
53
- - 最近播放的歌曲列表
54
- - 播放次数统计
55
- - 清除历史记录功能
56
-
57
- ## ⚙️ 设置页面需求
58
-
59
- **设置分组:**
60
-
61
- **播放设置:**
62
- - 默认音质:标准128K/较高192K/高音质320K/无损FLAC/Hi-Res(默认:320K)
63
- - 默认播放模式:列表循环/随机播放/单曲循环(默认:列表循环)
64
- - 记住播放进度:开启/关闭(默认:开启)
65
-
66
- **搜索设置:**
67
- - 默认音乐源:网易云音乐(默认)
68
- - 搜索历史:保存/不保存(默认:保存)
69
-
70
- **存储管理:**
71
- - 清除搜索历史
72
- - 清除播放历史
73
- - 清除收藏数据
74
-
75
- **应用信息:**
76
- - 版本号
77
- - 关于应用
78
-
79
- ## 🔍 搜索功能详细需求
80
-
81
- **搜索界面:**
82
- - 返回按钮
83
- - 搜索输入框
84
- - 音乐源选择按钮(显示当前选择的源)
85
- - 搜索历史列表(可清除)
86
- - 热门搜索词(可选)
87
-
88
- **音乐源选择弹窗:**
89
- - 底部弹窗形式
90
- - 显示所有13个音乐源
91
- - 当前选择源高亮显示
92
- - 选择后自动关闭弹窗
93
-
94
- **搜索结果:**
95
- - 瀑布流加载(首次20条,滚动到15条时加载下页)
96
- - 歌曲信息:序号、歌名、歌手专辑、时长
97
- - 右侧爱心收藏按钮
98
- - 当前播放歌曲高亮显示
99
-
100
- ## 🎤 底部播放条需求
101
-
102
- **显示位置:**
103
- - 位于底部导航栏正上方
104
- - 固定显示,不随页面滚动
105
-
106
- **显示条件:**
107
- - 有歌曲播放或暂停时显示
108
- - 页面刷新后根据本地存储恢复
109
-
110
- **界面元素:**
111
- - 左侧:小尺寸专辑封面
112
- - 中间:歌曲名和歌手名(单行显示,超出滚动)
113
- - 右侧:播放/暂停按钮
114
-
115
- **交互功能:**
116
- - 点击整个播放条打开全屏播放器
117
- - 点击播放按钮控制播放状态
118
- - 左右滑动切换歌曲
119
- - 上滑手势展开全屏播放器
120
-
121
- ## 🎼 全屏播放器需求
122
-
123
- **网易云风格界面:**
124
- - 专辑封面作为模糊背景
125
- - 顶部状态栏适配
126
- - 返回按钮(左上角)
127
- - 更多操作按钮(右上角)
128
-
129
- **主要内容区域:**
130
- - 大尺寸圆形专辑封面(居中)
131
- - 播放时封面旋转动画
132
- - 歌曲信息(歌名、歌手、专辑)
133
- - 收藏按钮(爱心图标)
134
-
135
- **播放控制:**
136
- - 播放模式按钮(列表循环/随机/单曲循环)
137
- - 上一首按钮
138
- - 播放/暂停按钮(大尺寸,居中)
139
- - 下一首按钮
140
- - 播放列表按钮
141
-
142
- **进度和音质:**
143
- - 播放进度条(可拖拽)
144
- - 当前时间/总时长显示
145
- - 音质显示和选择
146
-
147
- **歌词区域:**
148
- - 底部歌词滚动显示
149
- - 当前歌词高亮
150
- - 支持点击歌词跳转
151
- - 可展开全屏歌词模式
152
-
153
- **关闭方式:**
154
- - 点击返回按钮
155
- - 下拉手势
156
- - 返回键(Android)
157
-
158
- ## 📊 音乐平台优先级
159
-
160
- ### 平台列表(按稳定性和资源丰富度排序)
161
- 1. **netease** - 网易云音乐(默认)
162
- 2. **tencent** - QQ音乐
163
- 3. **kugou** - 酷狗音乐
164
- 4. **kuwo** - 酷我音乐
165
- 5. **migu** - 咪咕音乐
166
- 6. **spotify** - Spotify
167
- 7. **apple** - Apple Music
168
- 8. **ytmusic** - YouTube Music
169
- 9. **joox** - JOOX
170
- 10. **tidal** - TIDAL
171
- 11. **deezer** - Deezer
172
- 12. **qobuz** - Qobuz
173
- 13. **ximalaya** - 喜马拉雅FM
174
-
175
- ### 平台选择逻辑
176
- - 默认使用网易云音乐
177
- - 用户可在设置中修改默认源
178
- - 搜索无结果时提示切换到其他平台
179
- - 记住用户最后使用的音乐源
180
-
181
- ## 🎨 网易云音乐UI风格规范
182
-
183
- ### iOS端特色设计
184
- - 底部安全区域适配
185
- - 半透明毛玻璃效果
186
- - 深色模式适配
187
- - 圆角卡片设计
188
- - 红色强调色系统
189
-
190
- ### 色彩系统
191
- ```css
192
- /* 背景色 */
193
- --bg-primary: #0c0c0c; /* 主背景 */
194
- --bg-card: rgba(255,255,255,0.05); /* 卡片背景 */
195
- --bg-overlay: rgba(0,0,0,0.8); /* 遮罩背景 */
196
-
197
- /* 强调色 */
198
- --accent-red: #ff6b6b; /* 网易红 */
199
- --accent-red-hover: #ff5252; /* 悬浮态 */
200
-
201
- /* 文字颜色 */
202
- --text-primary: #ffffff; /* 主要文字 */
203
- --text-secondary: rgba(255,255,255,0.7); /* 次要文字 */
204
- --text-disabled: rgba(255,255,255,0.4); /* 禁用文字 */
205
- ```
206
-
207
- ### 组件规范
208
- - 底部导航高度:49px + 安全区域
209
- - 播放条高度:64px
210
- - 列表项高度:64px
211
- - 按钮最小触摸区域:44x44px
212
- - 统一圆角:12px(小元素)、20px(卡片)
213
-
214
- ## 🧩 组件化架构
215
-
216
- ### 页面组件
217
- ```
218
- src/views/
219
- ├── HomePage.vue # 首页(搜索页面)
220
- ├── MyMusicPage.vue # 我的音乐页面
221
- ├── SettingsPage.vue # 设置页面
222
- └── FullPlayerPage.vue # 全屏播放器页面
223
- ```
224
-
225
- ### 布局组件
226
- ```
227
- src/components/layout/
228
- ├── AppTabBar.vue # 底部导航栏
229
- ├── MiniPlayer.vue # 底部播放条
230
- ├── SearchHeader.vue # 搜索头部
231
- └── SafeArea.vue # 安全区域包装
232
- ```
233
-
234
- ### 搜索组件
235
- ```
236
- src/components/search/
237
- ├── SearchBox.vue # 搜索输入框
238
- ├── SourceSelector.vue # 音乐源选择弹窗
239
- ├── SearchResults.vue # 搜索结果列表
240
- ├── SongItem.vue # 歌曲列表项
241
- ├── SearchHistory.vue # 搜索历史
242
- └── InfiniteScroll.vue # 无限滚动
243
- ```
244
-
245
- ### 播放器组件
246
- ```
247
- src/components/player/
248
- ├── AlbumCover.vue # 专辑封面
249
- ├── SongInfo.vue # 歌曲信息
250
- ├── PlayControls.vue # 播放控制
251
- ├── ProgressBar.vue # 进度条
252
- ├── LyricsView.vue # 歌词显示
253
- └── PlayModeToggle.vue # 播放模式切换
254
- ```
255
-
256
- ### 收藏组件
257
- ```
258
- src/components/favorites/
259
- ├── FavoritesList.vue # 收藏列表
260
- ├── FavoriteItem.vue # 收藏项
261
- └── FavoriteButton.vue # 收藏按钮
262
- ```
263
-
264
- ### 通用组件
265
- ```
266
- src/components/common/
267
- ├── Loading.vue # 加载状态
268
- ├── Empty.vue # 空状态
269
- ├── Toast.vue # 提示信息
270
- ├── Modal.vue # 模态弹窗
271
- ├── BottomSheet.vue # 底部弹窗
272
- └── Icon.vue # 图标组件
273
- ```
274
-
275
- ## 💾 数据持久化需求
276
-
277
- ### LocalStorage存储
278
- **播放器状态:**
279
- - 当前播放歌曲信息
280
- - 播放进度位置
281
- - 音量设置
282
- - 播放模式
283
-
284
- **用户数据:**
285
- - 收藏歌曲列表
286
- - 播放历史记录
287
- - 搜索历史
288
- - 用户设置项
289
-
290
- **设置数据:**
291
- - 默认音质选择
292
- - 默认音乐源
293
- - 其他用户偏好设置
294
-
295
- ### 恢复机制
296
- - 应用启动时恢复播放状态
297
- - 页面刷新后继续播放
298
- - 保持用户设置和偏好
299
- - 离线时显示本地数据
300
-
301
- ---
302
-
303
- ## 📡 API接口文档
304
-
305
- ### 搜索音乐接口
306
- ```
307
- GET https://music-api.gdstudio.xyz/api.php
308
- 参数:
309
- - types: search
310
- - source: 音乐源(netease/tencent/kugou等)
311
- - name: 搜索关键词(需URL编码)
312
- - count: 返回数量(默认20,固定值)
313
- - pages: 页码(从1开始,用于瀑布流)
314
-
315
- 响应:
316
- [
317
- {
318
- "id": "歌曲唯一ID",
319
- "name": "歌曲名称",
320
- "artist": "歌手名(字符串或数组)",
321
- "album": "专辑名称",
322
- "pic_id": "专辑封面ID",
323
- "lyric_id": "歌词ID",
324
- "source": "音乐源标识"
325
- }
326
- ]
327
- ```
328
-
329
- ### 获取播放地址接口
330
- ```
331
- GET https://music-api.gdstudio.xyz/api.php
332
- 参数:
333
- - types: url
334
- - source: 音乐源标识
335
- - id: 歌曲ID
336
- - br: 音质(128/192/320/740/999)
337
-
338
- 响应:
339
- {
340
- "url": "音频文件直链",
341
- "br": "实际返回音质",
342
- "size": "文件大小(KB)"
343
- }
344
- ```
345
-
346
- ### 获取歌词接口
347
- ```
348
- GET https://music-api.gdstudio.xyz/api.php
349
- 参数:
350
- - types: lyric
351
- - source: 音乐源标识
352
- - id: 歌词ID(通常与歌曲ID相同)
353
-
354
- 响应:
355
- {
356
- "lyric": "LRC格式原文歌词",
357
- "tlyric": "LRC格式翻译歌词(可能为空)"
358
- }
359
- ```
360
-
361
- ### 获取专辑封面接口
362
- ```
363
- GET https://music-api.gdstudio.xyz/api.php
364
- 参数:
365
- - types: pic
366
- - source: 音乐源标识
367
- - id: 专辑封面ID
368
- - size: 图片尺寸(300小图/500大图)
369
-
370
- 响应:
371
- {
372
- "url": "图片文件直链"
373
- }
374
- ```
375
-
376
- ### 支持的音乐源列表
377
- ```javascript
378
- const MUSIC_SOURCES = [
379
- { code: 'netease', name: '网易云音乐', priority: 1 },
380
- { code: 'tencent', name: 'QQ音乐', priority: 2 },
381
- { code: 'kugou', name: '酷狗音乐', priority: 3 },
382
- { code: 'kuwo', name: '酷我音乐', priority: 4 },
383
- { code: 'migu', name: '咪咕音乐', priority: 5 },
384
- { code: 'spotify', name: 'Spotify', priority: 6 },
385
- { code: 'apple', name: 'Apple Music', priority: 7 },
386
- { code: 'ytmusic', name: 'YouTube Music', priority: 8 },
387
- { code: 'joox', name: 'JOOX', priority: 9 },
388
- { code: 'tidal', name: 'TIDAL', priority: 10 },
389
- { code: 'deezer', name: 'Deezer', priority: 11 },
390
- { code: 'qobuz', name: 'Qobuz', priority: 12 },
391
- { code: 'ximalaya', name: '喜马拉雅FM', priority: 13 }
392
- ]
393
- ```