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

feat(components): 优化多个组件的样式和功能

Browse files

- 调整了多个组件的背景色、文字颜色等样式
- 优化了部分组件的布局结构
- 改进了搜索历史、播放列表等组件的交互逻辑
- 统一了部分UI元素的样式

index.html CHANGED
@@ -8,6 +8,7 @@
8
  <!-- PWA相关 -->
9
  <link rel="icon" href="/favicon.svg">
10
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.svg">
 
11
  <meta name="apple-mobile-web-app-capable" content="yes">
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
13
  <meta name="apple-mobile-web-app-title" content="云音乐">
 
8
  <!-- PWA相关 -->
9
  <link rel="icon" href="/favicon.svg">
10
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.svg">
11
+ <meta name="mobile-web-app-capable" content="yes">
12
  <meta name="apple-mobile-web-app-capable" content="yes">
13
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
14
  <meta name="apple-mobile-web-app-title" content="云音乐">
src/components/favorites/FavoriteItem.vue CHANGED
@@ -10,7 +10,7 @@
10
  <!-- 专辑封面 -->
11
  <div class="album-cover">
12
  <img
13
- :src="albumCoverUrl"
14
  :alt="song.album"
15
  @error="handleImageError"
16
  loading="lazy"
@@ -76,7 +76,7 @@
76
  </template>
77
 
78
  <script setup>
79
- import { ref, computed } from 'vue'
80
  import { usePlayerStore } from '@/stores/player'
81
  import { useHistoryStore } from '@/stores/history'
82
  import FavoriteButton from './FavoriteButton.vue'
@@ -122,12 +122,25 @@ const historyStore = useHistoryStore()
122
  const showMenu = ref(false)
123
  const showPlaylistSelector = ref(false)
124
 
125
- // 专辑封面URL
126
- const albumCoverUrl = computed(() => {
 
127
  if (props.song.pic_id) {
128
- return `https://music-api.gdstudio.xyz/api.php?types=pic&source=${props.song.source}&id=${props.song.pic_id}&size=300`
 
 
 
 
 
 
 
 
129
  }
130
- return '/default-album.png'
 
 
 
 
131
  })
132
 
133
  // 是否是当前播放歌曲
@@ -285,17 +298,17 @@ document.addEventListener('click', () => {
285
  }
286
 
287
  .favorite-item:hover {
288
- background: rgba(255, 255, 255, 0.05);
289
  border-bottom: 1px solid var(--border-light);
290
  }
291
 
292
  .favorite-item.playing {
293
- background: rgba(255, 107, 107, 0.1);
294
  border-bottom: 1px solid var(--accent-red);
295
  }
296
 
297
  .favorite-item.selected {
298
- background: rgba(255, 255, 255, 0.1);
299
  border-bottom: 1px solid var(--border-light);
300
  }
301
 
 
10
  <!-- 专辑封面 -->
11
  <div class="album-cover">
12
  <img
13
+ :src="actualCoverUrl"
14
  :alt="song.album"
15
  @error="handleImageError"
16
  loading="lazy"
 
76
  </template>
77
 
78
  <script setup>
79
+ import { ref, computed, onMounted } from 'vue'
80
  import { usePlayerStore } from '@/stores/player'
81
  import { useHistoryStore } from '@/stores/history'
82
  import FavoriteButton from './FavoriteButton.vue'
 
122
  const showMenu = ref(false)
123
  const showPlaylistSelector = ref(false)
124
 
125
+ // 异步获取封面URL
126
+ const actualCoverUrl = ref('/default-album.png')
127
+ const loadCoverUrl = async () => {
128
  if (props.song.pic_id) {
129
+ try {
130
+ const { getCachedMusicPicUrlWithDelay } = await import('@/utils/musicPicCache.js')
131
+ const url = await getCachedMusicPicUrlWithDelay(props.song.source, props.song.pic_id, 300)
132
+ if (url) {
133
+ actualCoverUrl.value = url
134
+ }
135
+ } catch (error) {
136
+ console.error('加载封面失败:', error)
137
+ }
138
  }
139
+ }
140
+
141
+ // 组件挂载时加载封面
142
+ onMounted(() => {
143
+ loadCoverUrl()
144
  })
145
 
146
  // 是否是当前播放歌曲
 
298
  }
299
 
300
  .favorite-item:hover {
301
+ background: var(--bg-gradient-1);
302
  border-bottom: 1px solid var(--border-light);
303
  }
304
 
305
  .favorite-item.playing {
306
+ background: var(--bg-gradient-1);
307
  border-bottom: 1px solid var(--accent-red);
308
  }
309
 
310
  .favorite-item.selected {
311
+ background: var(--bg-gradient-1);
312
  border-bottom: 1px solid var(--border-light);
313
  }
314
 
src/components/favorites/FavoritesList.vue CHANGED
@@ -10,15 +10,15 @@
10
  </h2>
11
  <p class="list-subtitle">{{ totalDuration }} • {{ lastUpdateText }}</p>
12
  </div>
13
-
14
  <div class="header-actions">
15
  <button class="play-all-btn" @click="playAll" :disabled="favoritesList.length === 0">
16
  <i class="fas fa-play"></i>
17
  播放全部
18
  </button>
19
-
20
- <button
21
- class="batch-btn"
22
  :class="{ 'active': batchMode }"
23
  @click="toggleBatchMode"
24
  >
@@ -27,51 +27,51 @@
27
  </button>
28
  </div>
29
  </div>
30
-
31
  <!-- 搜索栏 -->
32
  <div class="search-bar" v-if="showSearch && favoritesList.length > 0">
33
  <div class="search-input-wrapper">
34
  <i class="fas fa-search"></i>
35
- <input
36
- type="text"
37
  v-model="searchQuery"
38
  placeholder="搜索收藏的歌曲..."
39
  class="search-input"
40
  @input="handleSearch"
41
  >
42
- <button
43
- v-if="searchQuery"
44
  class="clear-search-btn"
45
  @click="clearSearch"
46
  >
47
  <i class="fas fa-times"></i>
48
  </button>
49
  </div>
50
-
51
  <!-- 搜索过滤器 -->
52
  <div class="search-filters" v-if="searchQuery">
53
- <button
54
  class="filter-btn"
55
  :class="{ 'active': searchFilter === 'all' }"
56
  @click="setSearchFilter('all')"
57
  >
58
  全部
59
  </button>
60
- <button
61
  class="filter-btn"
62
  :class="{ 'active': searchFilter === 'name' }"
63
  @click="setSearchFilter('name')"
64
  >
65
  歌名
66
  </button>
67
- <button
68
  class="filter-btn"
69
  :class="{ 'active': searchFilter === 'artist' }"
70
  @click="setSearchFilter('artist')"
71
  >
72
  歌手
73
  </button>
74
- <button
75
  class="filter-btn"
76
  :class="{ 'active': searchFilter === 'album' }"
77
  @click="setSearchFilter('album')"
@@ -80,7 +80,7 @@
80
  </button>
81
  </div>
82
  </div>
83
-
84
  <!-- 批量操作栏 -->
85
  <div class="batch-actions" v-if="batchMode && selectedItems.length > 0">
86
  <div class="batch-info">
@@ -92,7 +92,7 @@
92
  <button @click="batchRemove" class="danger">删除选中</button>
93
  </div>
94
  </div>
95
-
96
  <!-- 排序选项 -->
97
  <div class="sort-options" v-if="favoritesList.length > 0">
98
  <label class="sort-label">排序方式:</label>
@@ -102,7 +102,7 @@
102
  <option value="artist">歌手</option>
103
  <option value="playCount">播放次数</option>
104
  </select>
105
- <button
106
  class="sort-order-btn"
107
  @click="toggleSortOrder"
108
  :title="sortOrder === 'desc' ? '降序' : '升序'"
@@ -110,34 +110,34 @@
110
  <i :class="sortOrder === 'desc' ? 'fas fa-sort-amount-down' : 'fas fa-sort-amount-up'"></i>
111
  </button>
112
  </div>
113
-
114
  <!-- 歌曲列表 -->
115
  <div class="favorites-content">
116
  <div v-if="loading" class="loading-container">
117
  <Loading text="加载收藏列表..." />
118
  </div>
119
-
120
  <div v-else-if="filteredList.length === 0 && !loading" class="empty-container">
121
- <Empty
122
  v-if="favoritesList.length === 0"
123
  icon="fas fa-heart"
124
  title="还没有收藏歌曲"
125
  subtitle="在搜索结果中点击爱心收藏喜欢的歌曲"
126
  />
127
- <Empty
128
  v-else
129
  icon="fas fa-search"
130
  title="没有找到相关歌曲"
131
  :subtitle="'没有找到包含「' + searchQuery + '」的歌曲'"
132
  />
133
  </div>
134
-
135
  <div v-else class="favorites-items">
136
  <!-- 批量选择模式 -->
137
  <div v-if="batchMode" class="batch-select-all">
138
  <label class="checkbox-wrapper">
139
- <input
140
- type="checkbox"
141
  :checked="isAllSelected"
142
  :indeterminate="isIndeterminate"
143
  @change="toggleSelectAll"
@@ -145,10 +145,10 @@
145
  <span class="checkbox-text">全选 ({{ selectedItems.length }}/{{ filteredList.length }})</span>
146
  </label>
147
  </div>
148
-
149
  <!-- 歌曲项列表 -->
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"
@@ -156,16 +156,16 @@
156
  >
157
  <!-- 批量选择复选框 -->
158
  <label v-if="batchMode" class="item-checkbox">
159
- <input
160
  type="checkbox"
161
  :value="item.song.id"
162
  v-model="selectedItems"
163
  @change="handleItemSelect"
164
  >
165
  </label>
166
-
167
  <!-- 歌曲项 -->
168
- <FavoriteItem
169
  :song="item.song"
170
  :favorite-time="item.favoriteTime"
171
  :play-count="getPlayCount(item.song.id)"
@@ -176,7 +176,7 @@
176
  />
177
  </div>
178
  </div>
179
-
180
  <!-- 加载更多 -->
181
  <div class="load-more" v-if="hasMore">
182
  <button class="load-more-btn" @click="loadMore" :disabled="loadingMore">
@@ -187,7 +187,7 @@
187
  </div>
188
  </div>
189
  </div>
190
-
191
  <!-- 确认对话框 -->
192
  <ConfirmDialog
193
  ref="confirmDialogRef"
@@ -216,13 +216,13 @@ const props = defineProps({
216
  type: Boolean,
217
  default: true
218
  },
219
-
220
  // 初始显示数量
221
  initialCount: {
222
  type: Number,
223
  default: 20
224
  },
225
-
226
  // 每次加载数量
227
  loadCount: {
228
  type: Number,
@@ -255,7 +255,7 @@ const favoritesList = computed(() => {
255
 
256
  const filteredList = computed(() => {
257
  let result = [...favoritesList.value]
258
-
259
  // 搜索过滤
260
  if (searchQuery.value) {
261
  const query = searchQuery.value.toLowerCase()
@@ -264,7 +264,7 @@ const filteredList = computed(() => {
264
  const name = (song.name || '').toLowerCase()
265
  const artist = formatArtist(song.artist).toLowerCase()
266
  const album = (song.album || '').toLowerCase()
267
-
268
  switch (searchFilter.value) {
269
  case 'name':
270
  return name.includes(query)
@@ -277,11 +277,11 @@ const filteredList = computed(() => {
277
  }
278
  })
279
  }
280
-
281
  // 排序
282
  result.sort((a, b) => {
283
  let valueA, valueB
284
-
285
  switch (sortBy.value) {
286
  case 'name':
287
  valueA = a.song.name || ''
@@ -299,14 +299,14 @@ const filteredList = computed(() => {
299
  valueA = a.favoriteTime
300
  valueB = b.favoriteTime
301
  }
302
-
303
  if (sortOrder.value === 'desc') {
304
  return valueB > valueA ? 1 : valueB < valueA ? -1 : 0
305
  } else {
306
  return valueA > valueB ? 1 : valueA < valueB ? -1 : 0
307
  }
308
  })
309
-
310
  return result
311
  })
312
 
@@ -325,11 +325,11 @@ const totalDuration = computed(() => {
325
 
326
  const lastUpdateText = computed(() => {
327
  if (favoritesList.value.length === 0) return ''
328
-
329
  const latest = Math.max(...favoritesList.value.map(item => item.favoriteTime))
330
  const now = Date.now()
331
  const diff = now - latest
332
-
333
  if (diff < 3600000) {
334
  return '最近更新'
335
  } else if (diff < 86400000) {
@@ -384,7 +384,7 @@ const toggleSortOrder = () => {
384
 
385
  const loadMore = async () => {
386
  loadingMore.value = true
387
-
388
  setTimeout(() => {
389
  displayCount.value += props.loadCount
390
  loadingMore.value = false
@@ -393,7 +393,7 @@ const loadMore = async () => {
393
 
394
  const playAll = () => {
395
  if (filteredList.value.length === 0) return
396
-
397
  const songs = filteredList.value.map(item => item.song)
398
  playerStore.playPlaylist(songs)
399
  emit('play', songs)
@@ -420,7 +420,7 @@ const batchPlay = () => {
420
  const songs = filteredList.value
421
  .filter(item => selectedItems.value.includes(item.song.id))
422
  .map(item => item.song)
423
-
424
  if (songs.length > 0) {
425
  playerStore.playPlaylist(songs)
426
  emit('batch-action', { action: 'play', songs })
@@ -431,7 +431,7 @@ const batchAddToPlaylist = () => {
431
  const songs = filteredList.value
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
  }
@@ -484,7 +484,7 @@ onMounted(async () => {
484
  watch(favoritesList, () => {
485
  if (batchMode.value) {
486
  // 过滤掉不存在的选中项
487
- selectedItems.value = selectedItems.value.filter(id =>
488
  favoritesList.value.some(item => item.song.id === id)
489
  )
490
  }
@@ -604,7 +604,7 @@ watch(favoritesList, () => {
604
  width: 100%;
605
  height: 40px;
606
  border: none;
607
- background: rgba(255, 255, 255, 0.05);
608
  border-radius: 20px;
609
  padding: 0 40px;
610
  color: var(--text-primary);
@@ -654,7 +654,7 @@ watch(favoritesList, () => {
654
  }
655
 
656
  .filter-btn:hover {
657
- background: rgba(255, 255, 255, 0.05);
658
  }
659
 
660
  .filter-btn.active {
@@ -668,7 +668,7 @@ watch(favoritesList, () => {
668
  align-items: center;
669
  justify-content: space-between;
670
  padding: 12px 16px;
671
- background: rgba(255, 107, 107, 0.1);
672
  border-bottom: 1px solid var(--border-light);
673
  margin: 0 16px;
674
  border-left: 1px solid var(--border-light);
@@ -729,7 +729,7 @@ watch(favoritesList, () => {
729
 
730
  .sort-select {
731
  border: none;
732
- background: rgba(255, 255, 255, 0.05);
733
  color: var(--text-primary);
734
  border-radius: 6px;
735
  padding: 4px 8px;
@@ -749,7 +749,7 @@ watch(favoritesList, () => {
749
  }
750
 
751
  .sort-order-btn:hover {
752
- background: rgba(255, 255, 255, 0.1);
753
  color: var(--text-primary);
754
  }
755
 
@@ -808,7 +808,7 @@ watch(favoritesList, () => {
808
  gap: 8px;
809
  padding: 10px 20px;
810
  border: none;
811
- background: rgba(255, 255, 255, 0.05);
812
  color: var(--text-secondary);
813
  border-radius: 20px;
814
  font-size: 12px;
@@ -835,17 +835,17 @@ watch(favoritesList, () => {
835
  padding: 16px 12px;
836
  margin: 0 12px;
837
  }
838
-
839
  .header-actions {
840
  width: 100%;
841
  justify-content: flex-end;
842
  }
843
-
844
  .search-bar {
845
  padding: 12px;
846
  margin: 0 12px;
847
  }
848
-
849
  .batch-actions {
850
  flex-direction: column;
851
  align-items: flex-start;
@@ -853,18 +853,18 @@ watch(favoritesList, () => {
853
  padding: 12px;
854
  margin: 0 12px;
855
  }
856
-
857
  .batch-buttons {
858
  width: 100%;
859
  justify-content: flex-end;
860
  }
861
-
862
  .sort-options {
863
  margin: 0 12px;
864
  }
865
-
866
  .batch-select-all {
867
  margin: 0 12px;
868
  }
869
  }
870
- </style>
 
10
  </h2>
11
  <p class="list-subtitle">{{ totalDuration }} • {{ lastUpdateText }}</p>
12
  </div>
13
+
14
  <div class="header-actions">
15
  <button class="play-all-btn" @click="playAll" :disabled="favoritesList.length === 0">
16
  <i class="fas fa-play"></i>
17
  播放全部
18
  </button>
19
+
20
+ <button
21
+ class="batch-btn"
22
  :class="{ 'active': batchMode }"
23
  @click="toggleBatchMode"
24
  >
 
27
  </button>
28
  </div>
29
  </div>
30
+
31
  <!-- 搜索栏 -->
32
  <div class="search-bar" v-if="showSearch && favoritesList.length > 0">
33
  <div class="search-input-wrapper">
34
  <i class="fas fa-search"></i>
35
+ <input
36
+ type="text"
37
  v-model="searchQuery"
38
  placeholder="搜索收藏的歌曲..."
39
  class="search-input"
40
  @input="handleSearch"
41
  >
42
+ <button
43
+ v-if="searchQuery"
44
  class="clear-search-btn"
45
  @click="clearSearch"
46
  >
47
  <i class="fas fa-times"></i>
48
  </button>
49
  </div>
50
+
51
  <!-- 搜索过滤器 -->
52
  <div class="search-filters" v-if="searchQuery">
53
+ <button
54
  class="filter-btn"
55
  :class="{ 'active': searchFilter === 'all' }"
56
  @click="setSearchFilter('all')"
57
  >
58
  全部
59
  </button>
60
+ <button
61
  class="filter-btn"
62
  :class="{ 'active': searchFilter === 'name' }"
63
  @click="setSearchFilter('name')"
64
  >
65
  歌名
66
  </button>
67
+ <button
68
  class="filter-btn"
69
  :class="{ 'active': searchFilter === 'artist' }"
70
  @click="setSearchFilter('artist')"
71
  >
72
  歌手
73
  </button>
74
+ <button
75
  class="filter-btn"
76
  :class="{ 'active': searchFilter === 'album' }"
77
  @click="setSearchFilter('album')"
 
80
  </button>
81
  </div>
82
  </div>
83
+
84
  <!-- 批量操作栏 -->
85
  <div class="batch-actions" v-if="batchMode && selectedItems.length > 0">
86
  <div class="batch-info">
 
92
  <button @click="batchRemove" class="danger">删除选中</button>
93
  </div>
94
  </div>
95
+
96
  <!-- 排序选项 -->
97
  <div class="sort-options" v-if="favoritesList.length > 0">
98
  <label class="sort-label">排序方式:</label>
 
102
  <option value="artist">歌手</option>
103
  <option value="playCount">播放次数</option>
104
  </select>
105
+ <button
106
  class="sort-order-btn"
107
  @click="toggleSortOrder"
108
  :title="sortOrder === 'desc' ? '降序' : '升序'"
 
110
  <i :class="sortOrder === 'desc' ? 'fas fa-sort-amount-down' : 'fas fa-sort-amount-up'"></i>
111
  </button>
112
  </div>
113
+
114
  <!-- 歌曲列表 -->
115
  <div class="favorites-content">
116
  <div v-if="loading" class="loading-container">
117
  <Loading text="加载收藏列表..." />
118
  </div>
119
+
120
  <div v-else-if="filteredList.length === 0 && !loading" class="empty-container">
121
+ <Empty
122
  v-if="favoritesList.length === 0"
123
  icon="fas fa-heart"
124
  title="还没有收藏歌曲"
125
  subtitle="在搜索结果中点击爱心收藏喜欢的歌曲"
126
  />
127
+ <Empty
128
  v-else
129
  icon="fas fa-search"
130
  title="没有找到相关歌曲"
131
  :subtitle="'没有找到包含「' + searchQuery + '」的歌曲'"
132
  />
133
  </div>
134
+
135
  <div v-else class="favorites-items">
136
  <!-- 批量选择模式 -->
137
  <div v-if="batchMode" class="batch-select-all">
138
  <label class="checkbox-wrapper">
139
+ <input
140
+ type="checkbox"
141
  :checked="isAllSelected"
142
  :indeterminate="isIndeterminate"
143
  @change="toggleSelectAll"
 
145
  <span class="checkbox-text">全选 ({{ selectedItems.length }}/{{ filteredList.length }})</span>
146
  </label>
147
  </div>
148
+
149
  <!-- 歌曲项列表 -->
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"
 
156
  >
157
  <!-- 批量选择复选框 -->
158
  <label v-if="batchMode" class="item-checkbox">
159
+ <input
160
  type="checkbox"
161
  :value="item.song.id"
162
  v-model="selectedItems"
163
  @change="handleItemSelect"
164
  >
165
  </label>
166
+
167
  <!-- 歌曲项 -->
168
+ <FavoriteItem
169
  :song="item.song"
170
  :favorite-time="item.favoriteTime"
171
  :play-count="getPlayCount(item.song.id)"
 
176
  />
177
  </div>
178
  </div>
179
+
180
  <!-- 加载更多 -->
181
  <div class="load-more" v-if="hasMore">
182
  <button class="load-more-btn" @click="loadMore" :disabled="loadingMore">
 
187
  </div>
188
  </div>
189
  </div>
190
+
191
  <!-- 确认对话框 -->
192
  <ConfirmDialog
193
  ref="confirmDialogRef"
 
216
  type: Boolean,
217
  default: true
218
  },
219
+
220
  // 初始显示数量
221
  initialCount: {
222
  type: Number,
223
  default: 20
224
  },
225
+
226
  // 每次加载数量
227
  loadCount: {
228
  type: Number,
 
255
 
256
  const filteredList = computed(() => {
257
  let result = [...favoritesList.value]
258
+
259
  // 搜索过滤
260
  if (searchQuery.value) {
261
  const query = searchQuery.value.toLowerCase()
 
264
  const name = (song.name || '').toLowerCase()
265
  const artist = formatArtist(song.artist).toLowerCase()
266
  const album = (song.album || '').toLowerCase()
267
+
268
  switch (searchFilter.value) {
269
  case 'name':
270
  return name.includes(query)
 
277
  }
278
  })
279
  }
280
+
281
  // 排序
282
  result.sort((a, b) => {
283
  let valueA, valueB
284
+
285
  switch (sortBy.value) {
286
  case 'name':
287
  valueA = a.song.name || ''
 
299
  valueA = a.favoriteTime
300
  valueB = b.favoriteTime
301
  }
302
+
303
  if (sortOrder.value === 'desc') {
304
  return valueB > valueA ? 1 : valueB < valueA ? -1 : 0
305
  } else {
306
  return valueA > valueB ? 1 : valueA < valueB ? -1 : 0
307
  }
308
  })
309
+
310
  return result
311
  })
312
 
 
325
 
326
  const lastUpdateText = computed(() => {
327
  if (favoritesList.value.length === 0) return ''
328
+
329
  const latest = Math.max(...favoritesList.value.map(item => item.favoriteTime))
330
  const now = Date.now()
331
  const diff = now - latest
332
+
333
  if (diff < 3600000) {
334
  return '最近更新'
335
  } else if (diff < 86400000) {
 
384
 
385
  const loadMore = async () => {
386
  loadingMore.value = true
387
+
388
  setTimeout(() => {
389
  displayCount.value += props.loadCount
390
  loadingMore.value = false
 
393
 
394
  const playAll = () => {
395
  if (filteredList.value.length === 0) return
396
+
397
  const songs = filteredList.value.map(item => item.song)
398
  playerStore.playPlaylist(songs)
399
  emit('play', songs)
 
420
  const songs = filteredList.value
421
  .filter(item => selectedItems.value.includes(item.song.id))
422
  .map(item => item.song)
423
+
424
  if (songs.length > 0) {
425
  playerStore.playPlaylist(songs)
426
  emit('batch-action', { action: 'play', songs })
 
431
  const songs = filteredList.value
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
  }
 
484
  watch(favoritesList, () => {
485
  if (batchMode.value) {
486
  // 过滤掉不存在的选中项
487
+ selectedItems.value = selectedItems.value.filter(id =>
488
  favoritesList.value.some(item => item.song.id === id)
489
  )
490
  }
 
604
  width: 100%;
605
  height: 40px;
606
  border: none;
607
+ background: var(--overlay-lighter);
608
  border-radius: 20px;
609
  padding: 0 40px;
610
  color: var(--text-primary);
 
654
  }
655
 
656
  .filter-btn:hover {
657
+ background: var(--overlay-lighter);
658
  }
659
 
660
  .filter-btn.active {
 
668
  align-items: center;
669
  justify-content: space-between;
670
  padding: 12px 16px;
671
+ background: var(--bg-gradient-3);
672
  border-bottom: 1px solid var(--border-light);
673
  margin: 0 16px;
674
  border-left: 1px solid var(--border-light);
 
729
 
730
  .sort-select {
731
  border: none;
732
+ background: var(--overlay-lighter);
733
  color: var(--text-primary);
734
  border-radius: 6px;
735
  padding: 4px 8px;
 
749
  }
750
 
751
  .sort-order-btn:hover {
752
+ background: var(--overlay-light);
753
  color: var(--text-primary);
754
  }
755
 
 
808
  gap: 8px;
809
  padding: 10px 20px;
810
  border: none;
811
+ background: var(--overlay-lighter);
812
  color: var(--text-secondary);
813
  border-radius: 20px;
814
  font-size: 12px;
 
835
  padding: 16px 12px;
836
  margin: 0 12px;
837
  }
838
+
839
  .header-actions {
840
  width: 100%;
841
  justify-content: flex-end;
842
  }
843
+
844
  .search-bar {
845
  padding: 12px;
846
  margin: 0 12px;
847
  }
848
+
849
  .batch-actions {
850
  flex-direction: column;
851
  align-items: flex-start;
 
853
  padding: 12px;
854
  margin: 0 12px;
855
  }
856
+
857
  .batch-buttons {
858
  width: 100%;
859
  justify-content: flex-end;
860
  }
861
+
862
  .sort-options {
863
  margin: 0 12px;
864
  }
865
+
866
  .batch-select-all {
867
  margin: 0 12px;
868
  }
869
  }
870
+ </style>
src/components/layout/SearchHeader.vue CHANGED
@@ -454,7 +454,7 @@ defineExpose({
454
  gap: 4px;
455
  padding: 8px 12px;
456
  border: none;
457
- background: rgba(255, 255, 255, 0.05);
458
  color: var(--text-secondary);
459
  border-radius: 16px;
460
  font-size: 12px;
@@ -465,7 +465,7 @@ defineExpose({
465
  }
466
 
467
  .source-btn:hover {
468
- background: rgba(255, 255, 255, 0.1);
469
  color: var(--text-primary);
470
  }
471
 
@@ -506,7 +506,7 @@ defineExpose({
506
 
507
  .suggestion-item:hover,
508
  .suggestion-item.active {
509
- background: rgba(255, 255, 255, 0.05);
510
  }
511
 
512
  .suggestion-item i:first-child {
@@ -620,5 +620,4 @@ defineExpose({
620
  to {
621
  transform: translateY(-50%) rotate(360deg);
622
  }
623
- }
624
- </style>
 
454
  gap: 4px;
455
  padding: 8px 12px;
456
  border: none;
457
+ background: var(--overlay-light);
458
  color: var(--text-secondary);
459
  border-radius: 16px;
460
  font-size: 12px;
 
465
  }
466
 
467
  .source-btn:hover {
468
+ background: var(--overlay-light);
469
  color: var(--text-primary);
470
  }
471
 
 
506
 
507
  .suggestion-item:hover,
508
  .suggestion-item.active {
509
+ background: var(--overlay-light);
510
  }
511
 
512
  .suggestion-item i:first-child {
 
620
  to {
621
  transform: translateY(-50%) rotate(360deg);
622
  }
623
+ }
 
src/components/player/MoreActionsPanel.vue CHANGED
@@ -3,49 +3,49 @@
3
  <div class="more-actions-panel" @click.stop>
4
  <!-- 歌曲信息 -->
5
  <div v-if="song" class="song-info">
6
- <img
7
- :src="song.cover || defaultCover"
8
  :alt="song.name"
9
  class="song-cover"
10
  />
11
-
12
  <div class="song-details">
13
  <div class="song-name">{{ song.name }}</div>
14
  <div class="song-artist">{{ song.artist }}</div>
15
  <div class="song-album" v-if="song.album">{{ song.album }}</div>
16
  </div>
17
  </div>
18
-
19
  <!-- 操作按钮列表 -->
20
  <div class="actions-list">
21
- <button
22
- class="action-item"
23
  @click="handleAction('favorite')"
24
  :class="{ active: isFavorite }"
25
  >
26
  <i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
27
  <span>{{ isFavorite ? '取消收藏' : '添加到我的收藏' }}</span>
28
  </button>
29
-
30
  <button class="action-item" @click="handleAction('addToPlaylist')">
31
  <i class="fas fa-plus"></i>
32
- <span>添加到播放列表</span>
33
  </button>
34
-
35
  <button class="action-item" @click="handleAction('download')">
36
  <i class="fas fa-download"></i>
37
  <span>下载到本地</span>
38
  </button>
39
  </div>
40
-
41
  <!-- 取消按钮 -->
42
  <button class="cancel-btn" @click="$emit('close')">
43
  取消
44
  </button>
45
  </div>
46
-
47
  <!-- 播放列表选择对话框 -->
48
- <PlaylistSelector
49
  :show="showPlaylistSelector"
50
  :song="song"
51
  @close="closePlaylistSelector"
@@ -83,7 +83,7 @@ const defaultCover = computed(() => {
83
  // 方法
84
  const handleAction = async (action) => {
85
  if (!props.song && action !== 'close') return
86
-
87
  switch (action) {
88
  case 'favorite':
89
  try {
@@ -96,12 +96,12 @@ const handleAction = async (action) => {
96
  console.error('收藏操作失败:', error)
97
  }
98
  break
99
-
100
  case 'addToPlaylist':
101
  // 打开播放列表选择器
102
  showPlaylistSelector.value = true
103
  break
104
-
105
  case 'download':
106
  // 实现下载功能
107
  try {
@@ -119,7 +119,7 @@ const handleAction = async (action) => {
119
  }
120
  break
121
  }
122
-
123
  emit('action', action)
124
  }
125
 
@@ -128,9 +128,9 @@ const closePlaylistSelector = () => {
128
  showPlaylistSelector.value = false
129
  }
130
 
131
- // 处理添加到播放列表成功
132
  const handleAddedToPlaylist = (data) => {
133
- console.log('歌曲已添加到播放列表:', data.message)
134
  // 关闭当前面板
135
  emit('close')
136
  }
@@ -228,7 +228,7 @@ const handleAddedToPlaylist = (data) => {
228
  }
229
 
230
  .action-item:hover {
231
- background: rgba(255, 255, 255, 0.05);
232
  }
233
 
234
  .action-item:active {
@@ -287,35 +287,35 @@ const handleAddedToPlaylist = (data) => {
287
  .song-info {
288
  padding: 20px 16px 16px;
289
  }
290
-
291
  .song-cover {
292
  width: 56px;
293
  height: 56px;
294
  border-radius: 10px;
295
  }
296
-
297
  .song-name {
298
  font-size: 16px;
299
  }
300
-
301
  .song-artist {
302
  font-size: 13px;
303
  }
304
-
305
  .song-album {
306
  font-size: 11px;
307
  }
308
-
309
  .action-item {
310
  padding: 14px 16px;
311
  font-size: 15px;
312
  }
313
-
314
  .action-item i {
315
  width: 18px;
316
  font-size: 15px;
317
  }
318
-
319
  .cancel-btn {
320
  width: calc(100% - 32px);
321
  margin: 16px 16px 0;
@@ -330,26 +330,26 @@ const handleAddedToPlaylist = (data) => {
330
  margin: 0 auto 0;
331
  border-radius: 16px;
332
  }
333
-
334
  .more-actions-overlay {
335
  align-items: center;
336
  padding: 20px;
337
  }
338
-
339
  .song-info {
340
  padding: 28px 24px 24px;
341
  }
342
-
343
  .song-cover {
344
  width: 72px;
345
  height: 72px;
346
  border-radius: 14px;
347
  }
348
-
349
  .action-item {
350
  padding: 18px 24px;
351
  }
352
-
353
  .cancel-btn {
354
  width: calc(100% - 48px);
355
  margin: 20px 24px 0;
@@ -385,4 +385,4 @@ const handleAddedToPlaylist = (data) => {
385
  transform: scale(0.98);
386
  }
387
  }
388
- </style>
 
3
  <div class="more-actions-panel" @click.stop>
4
  <!-- 歌曲信息 -->
5
  <div v-if="song" class="song-info">
6
+ <img
7
+ :src="song.cover || defaultCover"
8
  :alt="song.name"
9
  class="song-cover"
10
  />
11
+
12
  <div class="song-details">
13
  <div class="song-name">{{ song.name }}</div>
14
  <div class="song-artist">{{ song.artist }}</div>
15
  <div class="song-album" v-if="song.album">{{ song.album }}</div>
16
  </div>
17
  </div>
18
+
19
  <!-- 操作按钮列表 -->
20
  <div class="actions-list">
21
+ <button
22
+ class="action-item"
23
  @click="handleAction('favorite')"
24
  :class="{ active: isFavorite }"
25
  >
26
  <i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
27
  <span>{{ isFavorite ? '取消收藏' : '添加到我的收藏' }}</span>
28
  </button>
29
+
30
  <button class="action-item" @click="handleAction('addToPlaylist')">
31
  <i class="fas fa-plus"></i>
32
+ <span>添加到歌单</span>
33
  </button>
34
+
35
  <button class="action-item" @click="handleAction('download')">
36
  <i class="fas fa-download"></i>
37
  <span>下载到本地</span>
38
  </button>
39
  </div>
40
+
41
  <!-- 取消按钮 -->
42
  <button class="cancel-btn" @click="$emit('close')">
43
  取消
44
  </button>
45
  </div>
46
+
47
  <!-- 播放列表选择对话框 -->
48
+ <PlaylistSelector
49
  :show="showPlaylistSelector"
50
  :song="song"
51
  @close="closePlaylistSelector"
 
83
  // 方法
84
  const handleAction = async (action) => {
85
  if (!props.song && action !== 'close') return
86
+
87
  switch (action) {
88
  case 'favorite':
89
  try {
 
96
  console.error('收藏操作失败:', error)
97
  }
98
  break
99
+
100
  case 'addToPlaylist':
101
  // 打开播放列表选择器
102
  showPlaylistSelector.value = true
103
  break
104
+
105
  case 'download':
106
  // 实现下载功能
107
  try {
 
119
  }
120
  break
121
  }
122
+
123
  emit('action', action)
124
  }
125
 
 
128
  showPlaylistSelector.value = false
129
  }
130
 
131
+ // 处理添加到歌单成功
132
  const handleAddedToPlaylist = (data) => {
133
+ console.log('歌曲已添加到歌单:', data.message)
134
  // 关闭当前面板
135
  emit('close')
136
  }
 
228
  }
229
 
230
  .action-item:hover {
231
+ background: var(--overlay-lighter);
232
  }
233
 
234
  .action-item:active {
 
287
  .song-info {
288
  padding: 20px 16px 16px;
289
  }
290
+
291
  .song-cover {
292
  width: 56px;
293
  height: 56px;
294
  border-radius: 10px;
295
  }
296
+
297
  .song-name {
298
  font-size: 16px;
299
  }
300
+
301
  .song-artist {
302
  font-size: 13px;
303
  }
304
+
305
  .song-album {
306
  font-size: 11px;
307
  }
308
+
309
  .action-item {
310
  padding: 14px 16px;
311
  font-size: 15px;
312
  }
313
+
314
  .action-item i {
315
  width: 18px;
316
  font-size: 15px;
317
  }
318
+
319
  .cancel-btn {
320
  width: calc(100% - 32px);
321
  margin: 16px 16px 0;
 
330
  margin: 0 auto 0;
331
  border-radius: 16px;
332
  }
333
+
334
  .more-actions-overlay {
335
  align-items: center;
336
  padding: 20px;
337
  }
338
+
339
  .song-info {
340
  padding: 28px 24px 24px;
341
  }
342
+
343
  .song-cover {
344
  width: 72px;
345
  height: 72px;
346
  border-radius: 14px;
347
  }
348
+
349
  .action-item {
350
  padding: 18px 24px;
351
  }
352
+
353
  .cancel-btn {
354
  width: calc(100% - 48px);
355
  margin: 20px 24px 0;
 
385
  transform: scale(0.98);
386
  }
387
  }
388
+ </style>
src/components/player/PlayControls.vue CHANGED
@@ -41,7 +41,7 @@
41
  <button
42
  class="control-btn playlist-btn"
43
  @click="$emit('showPlaylist')"
44
- title="播放队列"
45
  >
46
  <i class="fas fa-list"></i>
47
  <span v-if="playlistCount > 0" class="playlist-count">{{ playlistCount }}</span>
 
41
  <button
42
  class="control-btn playlist-btn"
43
  @click="$emit('showPlaylist')"
44
+ title="播放列表"
45
  >
46
  <i class="fas fa-list"></i>
47
  <span v-if="playlistCount > 0" class="playlist-count">{{ playlistCount }}</span>
src/components/player/PlaylistPanel.vue CHANGED
@@ -4,7 +4,7 @@
4
  <!-- 头部 -->
5
  <div class="panel-header">
6
  <div class="panel-title">
7
- <h3>播放队列</h3>
8
  <span class="song-count">{{ playlist.length }}首歌曲</span>
9
  </div>
10
 
@@ -23,37 +23,12 @@
23
  </div>
24
  </div>
25
 
26
- <!-- 当前播放 -->
27
- <div v-if="currentSong" class="current-playing">
28
- <div class="section-title">
29
- <i class="fas fa-play"></i>
30
- <span>正在播放</span>
31
- </div>
32
-
33
- <div class="current-song">
34
- <img
35
- :src="currentSong.cover || defaultCover"
36
- :alt="currentSong.name"
37
- class="song-cover"
38
- />
39
-
40
- <div class="song-details">
41
- <div class="song-name">{{ currentSong.name }}</div>
42
- <div class="song-artist">{{ formatArtist(currentSong.artist) }}</div>
43
- </div>
44
-
45
- <div class="song-status">
46
- <i v-if="isPlaying" class="fas fa-volume-up playing-icon"></i>
47
- <i v-else class="fas fa-pause"></i>
48
- </div>
49
- </div>
50
- </div>
51
 
52
  <!-- 播放列表 -->
53
  <div class="playlist-content">
54
  <div v-if="!playlist.length" class="empty-playlist">
55
  <i class="fas fa-music"></i>
56
- <p>播放队列为空</p>
57
  <button class="add-music-btn" @click="$emit('close')">
58
  去添加音乐
59
  </button>
@@ -65,52 +40,17 @@
65
  <span>下一首播放</span>
66
  </div>
67
 
68
- <div
69
- v-for="(song, index) in playlist"
70
- :key="`${song.id}-${index}`"
71
- class="playlist-item"
72
- :class="{
73
- active: index === currentIndex,
74
- playing: index === currentIndex && isPlaying
75
- }"
76
- @click="handleSongClick(song, index)"
77
- >
78
- <div class="item-index">
79
- <span v-if="index !== currentIndex">{{ index + 1 }}</span>
80
- <i v-else-if="isPlaying" class="fas fa-volume-up playing-icon"></i>
81
- <i v-else class="fas fa-pause"></i>
82
- </div>
83
-
84
- <img
85
- :src="song.cover || defaultCover"
86
- :alt="song.name"
87
- class="item-cover"
88
- @error="handleImageError"
89
- />
90
-
91
- <div class="item-details">
92
- <div class="item-name">{{ song.name }}</div>
93
- <div class="item-artist">{{ formatArtist(song.artist) }}</div>
94
- </div>
95
-
96
- <div class="item-actions">
97
- <button
98
- class="item-action-btn"
99
- @click.stop="toggleFavorite(song)"
100
- :class="{ active: isFavorite(song.id) }"
101
- >
102
- <i :class="isFavorite(song.id) ? 'fas fa-heart' : 'far fa-heart'"></i>
103
- </button>
104
-
105
- <button
106
- class="item-action-btn"
107
- @click.stop="handleRemove(index)"
108
- :disabled="playlist.length <= 1"
109
- >
110
- <i class="fas fa-times"></i>
111
- </button>
112
- </div>
113
- </div>
114
  </div>
115
  </div>
116
  </div>
@@ -118,8 +58,8 @@
118
  <!-- 确认对话框 -->
119
  <ConfirmDialog
120
  ref="confirmDialog"
121
- title="清空播放队列"
122
- message="确定要清空播放队列吗?"
123
  confirm-text="清空"
124
  cancel-text="取消"
125
  type="danger"
@@ -136,6 +76,7 @@ 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
 
140
  const emit = defineEmits(['close', 'play', 'remove'])
141
 
@@ -145,11 +86,11 @@ const playQueueStore = usePlayQueueStore()
145
  const confirmDialog = ref(null)
146
 
147
  // 计算属性
148
- const playlist = computed(() => playerStore.playlist)
149
- const currentSong = computed(() => playerStore.currentSong)
150
- const currentIndex = computed(() => playerStore.currentIndex)
151
  const isPlaying = computed(() => playerStore.isPlaying)
152
- const playMode = computed(() => playerStore.playMode)
153
 
154
  const nextSongs = computed(() => {
155
  if (currentIndex.value < 0) return playlist.value
@@ -180,10 +121,6 @@ const playModeText = computed(() => {
180
  }
181
  })
182
 
183
- const defaultCover = computed(() => {
184
- return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA2NCA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiIHJ4PSI4Ii8+CjxwYXRoIGQ9Ik0zMiAyMkwzNiAzMkgzNFYzOEgzMFYzMkgyOEwzMiAyMloiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4zKSIvPgo8L3N2Zz4K'
185
- })
186
-
187
  // 方法
188
  const handleSongClick = (song, index) => {
189
  emit('play', song, index)
@@ -194,7 +131,7 @@ const handleRemove = (index) => {
194
  }
195
 
196
  const togglePlayMode = () => {
197
- playerStore.togglePlayMode()
198
  }
199
 
200
  const clearPlaylist = () => {
@@ -226,10 +163,6 @@ const isFavorite = (songId) => {
226
  return favoritesStore.isFavorite(songId)
227
  }
228
 
229
- const handleImageError = (event) => {
230
- event.target.src = defaultCover.value
231
- }
232
-
233
  const formatArtist = (artist) => {
234
  return utils.formatArtist(artist)
235
  }
@@ -314,76 +247,6 @@ const formatArtist = (artist) => {
314
  cursor: not-allowed;
315
  }
316
 
317
- .current-playing {
318
- padding: 16px 20px;
319
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
320
- flex-shrink: 0;
321
- }
322
-
323
- .section-title {
324
- display: flex;
325
- align-items: center;
326
- gap: 8px;
327
- font-size: 14px;
328
- font-weight: 500;
329
- color: var(--text-secondary);
330
- margin-bottom: 12px;
331
- }
332
-
333
- .section-title i {
334
- font-size: 12px;
335
- }
336
-
337
- .current-song {
338
- display: flex;
339
- align-items: center;
340
- gap: 12px;
341
- }
342
-
343
- .song-cover,
344
- .item-cover {
345
- width: 48px;
346
- height: 48px;
347
- border-radius: 8px;
348
- object-fit: cover;
349
- flex-shrink: 0;
350
- }
351
-
352
- .song-details,
353
- .item-details {
354
- flex: 1;
355
- min-width: 0;
356
- }
357
-
358
- .song-name,
359
- .item-name {
360
- font-size: 16px;
361
- font-weight: 500;
362
- color: var(--text-primary);
363
- margin-bottom: 4px;
364
- white-space: nowrap;
365
- overflow: hidden;
366
- text-overflow: ellipsis;
367
- }
368
-
369
- .song-artist,
370
- .item-artist {
371
- font-size: 12px;
372
- color: var(--text-secondary);
373
- white-space: nowrap;
374
- overflow: hidden;
375
- text-overflow: ellipsis;
376
- }
377
-
378
- .song-status {
379
- flex-shrink: 0;
380
- }
381
-
382
- .playing-icon {
383
- color: var(--accent-red);
384
- animation: pulse 1.5s ease-in-out infinite;
385
- }
386
-
387
  .playlist-content {
388
  flex: 1;
389
  overflow-y: auto;
@@ -408,7 +271,7 @@ const formatArtist = (artist) => {
408
 
409
  .empty-playlist p {
410
  font-size: 16px;
411
- margin-bottom: 20px;
412
  }
413
 
414
  .add-music-btn {
@@ -437,78 +300,6 @@ const formatArtist = (artist) => {
437
  margin-bottom: 16px;
438
  }
439
 
440
- .playlist-item {
441
- display: flex;
442
- align-items: center;
443
- gap: 12px;
444
- padding: 8px 20px;
445
- cursor: pointer;
446
- transition: var(--transition-fast);
447
- position: relative;
448
- }
449
-
450
- .playlist-item:hover {
451
- background: rgba(255, 255, 255, 0.05);
452
- }
453
-
454
- .playlist-item.active {
455
- background: rgba(255, 107, 107, 0.1);
456
- }
457
-
458
- .playlist-item.active .item-name {
459
- color: var(--accent-red);
460
- }
461
-
462
- .item-index {
463
- width: 24px;
464
- text-align: center;
465
- font-size: 12px;
466
- color: var(--text-tertiary);
467
- flex-shrink: 0;
468
- }
469
-
470
- .item-actions {
471
- display: flex;
472
- align-items: center;
473
- gap: 4px;
474
- flex-shrink: 0;
475
- opacity: 0;
476
- transition: var(--transition-fast);
477
- }
478
-
479
- .playlist-item:hover .item-actions {
480
- opacity: 1;
481
- }
482
-
483
- .item-action-btn {
484
- width: 32px;
485
- height: 32px;
486
- border: none;
487
- background: rgba(255, 255, 255, 0.1);
488
- color: var(--text-tertiary);
489
- border-radius: 50%;
490
- display: flex;
491
- align-items: center;
492
- justify-content: center;
493
- cursor: pointer;
494
- transition: var(--transition-fast);
495
- font-size: 12px;
496
- }
497
-
498
- .item-action-btn:hover:not(:disabled) {
499
- background: rgba(255, 255, 255, 0.2);
500
- color: var(--text-primary);
501
- }
502
-
503
- .item-action-btn:disabled {
504
- opacity: 0.3;
505
- cursor: not-allowed;
506
- }
507
-
508
- .item-action-btn.active {
509
- color: var(--accent-red);
510
- }
511
-
512
  /* 响应式 */
513
  @media (max-width: 375px) {
514
  .playlist-panel {
@@ -526,21 +317,6 @@ const formatArtist = (artist) => {
526
  .current-playing {
527
  padding: 12px 16px;
528
  }
529
-
530
- .song-cover,
531
- .item-cover {
532
- width: 40px;
533
- height: 40px;
534
- }
535
-
536
- .song-name,
537
- .item-name {
538
- font-size: 14px;
539
- }
540
-
541
- .playlist-item {
542
- padding: 6px 16px;
543
- }
544
  }
545
 
546
  @media (min-width: 768px) {
@@ -581,6 +357,6 @@ const formatArtist = (artist) => {
581
  }
582
 
583
  .playlist-content::-webkit-scrollbar-thumb:hover {
584
- background: rgba(255, 255, 255, 0.3);
585
  }
586
- </style>
 
4
  <!-- 头部 -->
5
  <div class="panel-header">
6
  <div class="panel-title">
7
+ <h3>播放列表</h3>
8
  <span class="song-count">{{ playlist.length }}首歌曲</span>
9
  </div>
10
 
 
23
  </div>
24
  </div>
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  <!-- 播放列表 -->
28
  <div class="playlist-content">
29
  <div v-if="!playlist.length" class="empty-playlist">
30
  <i class="fas fa-music"></i>
31
+ <p>播放列表为空</p>
32
  <button class="add-music-btn" @click="$emit('close')">
33
  去添加音乐
34
  </button>
 
40
  <span>下一首播放</span>
41
  </div>
42
 
43
+ <SongItem
44
+ v-for="(song, index) in playlist"
45
+ :key="`${song.id}-${index}`"
46
+ :song="song"
47
+ :index="index"
48
+ :cover="song.cover"
49
+ :show-remove="true"
50
+ :show-actions="false"
51
+ @play="handleSongClick"
52
+ @remove="handleRemove"
53
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  </div>
55
  </div>
56
  </div>
 
58
  <!-- 确认对话框 -->
59
  <ConfirmDialog
60
  ref="confirmDialog"
61
+ title="清空播放列表"
62
+ message="确定要清空播放列表吗?"
63
  confirm-text="清空"
64
  cancel-text="取消"
65
  type="danger"
 
76
  import { usePlayQueueStore } from '@/stores/playqueue'
77
  import { utils } from '@/services/musicApi'
78
  import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
79
+ import SongItem from '@/components/search/SongItem.vue'
80
 
81
  const emit = defineEmits(['close', 'play', 'remove'])
82
 
 
86
  const confirmDialog = ref(null)
87
 
88
  // 计算属性
89
+ const playlist = computed(() => playQueueStore.queue)
90
+ const currentSong = computed(() => playQueueStore.currentSong)
91
+ const currentIndex = computed(() => playQueueStore.currentIndex)
92
  const isPlaying = computed(() => playerStore.isPlaying)
93
+ const playMode = computed(() => playQueueStore.playMode)
94
 
95
  const nextSongs = computed(() => {
96
  if (currentIndex.value < 0) return playlist.value
 
121
  }
122
  })
123
 
 
 
 
 
124
  // 方法
125
  const handleSongClick = (song, index) => {
126
  emit('play', song, index)
 
131
  }
132
 
133
  const togglePlayMode = () => {
134
+ playQueueStore.togglePlayMode()
135
  }
136
 
137
  const clearPlaylist = () => {
 
163
  return favoritesStore.isFavorite(songId)
164
  }
165
 
 
 
 
 
166
  const formatArtist = (artist) => {
167
  return utils.formatArtist(artist)
168
  }
 
247
  cursor: not-allowed;
248
  }
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  .playlist-content {
251
  flex: 1;
252
  overflow-y: auto;
 
271
 
272
  .empty-playlist p {
273
  font-size: 16px;
274
+ margin-bottom: 20px;
275
  }
276
 
277
  .add-music-btn {
 
300
  margin-bottom: 16px;
301
  }
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  /* 响应式 */
304
  @media (max-width: 375px) {
305
  .playlist-panel {
 
317
  .current-playing {
318
  padding: 12px 16px;
319
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  }
321
 
322
  @media (min-width: 768px) {
 
357
  }
358
 
359
  .playlist-content::-webkit-scrollbar-thumb:hover {
360
+ background: rgba(255, 255, 255, 0.3);
361
  }
362
+ </style>
src/components/playlist/PlaylistSelector.vue CHANGED
@@ -2,15 +2,15 @@
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"
@@ -18,28 +18,28 @@
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>
@@ -47,20 +47,20 @@
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()">
@@ -96,7 +96,7 @@ 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
  })
@@ -104,13 +104,13 @@ const playlists = computed(() => {
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()
@@ -142,19 +142,19 @@ const cancelCreate = () => {
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 {
@@ -259,7 +259,7 @@ const handleClose = () => {
259
  }
260
 
261
  .playlist-item:hover:not(.disabled) {
262
- background: rgba(255, 255, 255, 0.05);
263
  border-color: var(--accent-red);
264
  }
265
 
@@ -348,7 +348,7 @@ const handleClose = () => {
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 {
@@ -374,7 +374,7 @@ const handleClose = () => {
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);
@@ -440,11 +440,11 @@ const handleClose = () => {
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
  }
@@ -456,26 +456,26 @@ const handleClose = () => {
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>
 
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"
 
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>
 
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()">
 
96
  const newPlaylistName = ref('')
97
  const nameInput = ref(null)
98
 
99
+ // 获取所有播放列表(排除默认播放列表,因为那只是当前播放列表)
100
  const playlists = computed(() => {
101
  return playlistStore.playlists.filter(p => !p.isDefault)
102
  })
 
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()
 
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 {
 
259
  }
260
 
261
  .playlist-item:hover:not(.disabled) {
262
+ background: var(--overlay-lighter);
263
  border-color: var(--accent-red);
264
  }
265
 
 
348
  .create-new-btn:hover {
349
  border-color: var(--accent-red);
350
  color: var(--accent-red);
351
+ background: var(--overlay-lighter);
352
  }
353
 
354
  .create-form {
 
374
  padding: 12px 16px;
375
  border: 2px solid var(--border-card);
376
  border-radius: 8px;
377
+ background: var(--overlay-lighter);
378
  color: var(--text-primary);
379
  font-size: 14px;
380
  transition: var(--transition-fast);
 
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
  }
 
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/SearchBox.vue CHANGED
@@ -229,18 +229,12 @@ watch(() => searchStore.keyword, (newKeyword) => {
229
  }
230
 
231
  .search-history {
232
- position: absolute;
233
- top: 100%;
234
- left: 16px;
235
- right: 16px;
236
- background: var(--bg-card);
237
- backdrop-filter: blur(20px);
238
- border: 1px solid var(--border-strong);
239
- border-radius: var(--radius-small);
240
- box-shadow: var(--shadow-card);
241
- z-index: 100;
242
- max-height: 300px;
243
- overflow-y: auto;
244
  }
245
 
246
  .history-header {
@@ -249,7 +243,7 @@ watch(() => searchStore.keyword, (newKeyword) => {
249
  align-items: center;
250
  padding: 12px 16px;
251
  border-bottom: 1px solid var(--border-strong);
252
- background: rgba(255, 255, 255, 0.05);
253
  }
254
 
255
  .history-title {
 
229
  }
230
 
231
  .search-history {
232
+ display: flex;
233
+ justify-content: space-between;
234
+ align-items: center;
235
+ padding: 12px 16px;
236
+ border-bottom: 1px solid var(--border-strong);
237
+ background: var(--overlay-lighter);
 
 
 
 
 
 
238
  }
239
 
240
  .history-header {
 
243
  align-items: center;
244
  padding: 12px 16px;
245
  border-bottom: 1px solid var(--border-strong);
246
+ background: var(--overlay-lighter);
247
  }
248
 
249
  .history-title {
src/components/search/SearchHistory.vue CHANGED
@@ -257,7 +257,7 @@ const getSourceName = (source) => {
257
  }
258
 
259
  .history-item:hover {
260
- background: rgba(255, 255, 255, 0.05);
261
  }
262
 
263
  .history-icon {
@@ -338,12 +338,12 @@ const getSourceName = (source) => {
338
  color: var(--text-secondary);
339
  font-size: 12px;
340
  transition: var(--transition-fast);
341
- border-top: 1px solid rgba(255, 255, 255, 0.05);
342
  }
343
 
344
  .expand-btn:hover {
345
  color: var(--text-primary);
346
- background: rgba(255, 255, 255, 0.05);
347
  }
348
 
349
  /* 响应式 */
 
257
  }
258
 
259
  .history-item:hover {
260
+ background: var(--overlay-lighter);
261
  }
262
 
263
  .history-icon {
 
338
  color: var(--text-secondary);
339
  font-size: 12px;
340
  transition: var(--transition-fast);
341
+ border-top: 1px solid var(--overlay-lighter);
342
  }
343
 
344
  .expand-btn:hover {
345
  color: var(--text-primary);
346
+ background: var(--overlay-lighter);
347
  }
348
 
349
  /* 响应式 */
src/components/search/SearchResults.vue CHANGED
@@ -35,14 +35,16 @@
35
  <span class="results-count">找到 {{ results.length }} 首歌曲</span>
36
  </div>
37
 
38
- <div class="songs-container" ref="songsContainer">
39
  <SongItem
40
- v-for="(song, index) in results"
41
- :key="`${song.source}-${song.id}`"
42
- :song="song"
43
- :index="index"
44
- @play="handlePlay"
45
- />
 
 
46
 
47
  <!-- 加载更多 -->
48
  <div v-if="hasMore" class="load-more" ref="loadMoreRef">
@@ -83,7 +85,7 @@
83
  </template>
84
 
85
  <script setup>
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'
@@ -111,7 +113,7 @@ const historyStore = useHistoryStore()
111
  const toastStore = useToastStore()
112
 
113
  // 响应式数据
114
- const songsContainer = ref(null)
115
  const loadMoreRef = ref(null)
116
  const observer = ref(null)
117
 
@@ -130,7 +132,7 @@ const loadingMore = computed(() => loading.value && results.value.length > 0)
130
  // 方法
131
  const handlePlay = async (song, index) => {
132
  try {
133
- // SOLID原则:使用playQueueStore管理播放队列
134
  const result = playQueueStore.setQueue(results.value, index)
135
 
136
  if (result) {
@@ -146,7 +148,7 @@ const handlePlay = async (song, index) => {
146
  // 通知父组件
147
  emit('play', song, index)
148
  } else {
149
- throw new Error('设置播放队列失败')
150
  }
151
  } catch (error) {
152
  console.error('播放失败:', error)
@@ -154,13 +156,52 @@ const handlePlay = async (song, index) => {
154
  }
155
  }
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  // 设置智能滚动加载 - 可视区域超过一半时加载下一页
158
  const setupInfiniteScroll = () => {
159
- if (!songsContainer.value) return
160
 
161
  // 监听滚动事件
162
  const handleScroll = () => {
163
- const container = songsContainer.value
164
  const containerHeight = container.clientHeight
165
  const scrollTop = container.scrollTop
166
  const scrollHeight = container.scrollHeight
@@ -186,12 +227,12 @@ const setupInfiniteScroll = () => {
186
  scrollTimer = setTimeout(handleScroll, 50)
187
  }
188
 
189
- songsContainer.value.addEventListener('scroll', debouncedScroll, { passive: true })
190
 
191
  // 清理函数
192
  return () => {
193
- if (songsContainer.value) {
194
- songsContainer.value.removeEventListener('scroll', debouncedScroll)
195
  }
196
  if (scrollTimer) clearTimeout(scrollTimer)
197
  }
@@ -200,7 +241,7 @@ const setupInfiniteScroll = () => {
200
  let cleanupScrollListener = null
201
 
202
  // 监听歌曲容器元素
203
- watch(songsContainer, (newContainer) => {
204
  // 清理之前的监听器
205
  if (cleanupScrollListener) {
206
  cleanupScrollListener()
@@ -214,7 +255,7 @@ watch(songsContainer, (newContainer) => {
214
 
215
  // 监听搜索结果变化,重新设置滚动监听
216
  watch(results, () => {
217
- if (songsContainer.value && results.value.length > 0) {
218
  // 延迟设置,确保DOM已更新
219
  setTimeout(() => {
220
  if (cleanupScrollListener) {
@@ -226,7 +267,7 @@ watch(results, () => {
226
  })
227
 
228
  onMounted(() => {
229
- if (songsContainer.value) {
230
  cleanupScrollListener = setupInfiniteScroll()
231
  }
232
  })
@@ -382,13 +423,7 @@ onUnmounted(() => {
382
  font-weight: 500;
383
  }
384
 
385
- .songs-container {
386
- padding: 0 4px;
387
- background: var(--bg-card);
388
- border-radius: 0 0 var(--radius-small) var(--radius-small);
389
- margin: 0 16px 16px;
390
- border: 1px solid var(--border-light);
391
- border-top: none;
392
  max-height: 70vh;
393
  overflow-y: auto;
394
  }
@@ -440,9 +475,5 @@ onUnmounted(() => {
440
  .results-header {
441
  padding: 12px 16px 8px;
442
  }
443
-
444
- .songs-container {
445
- margin: 0 12px 12px;
446
- }
447
  }
448
  </style>
 
35
  <span class="results-count">找到 {{ results.length }} 首歌曲</span>
36
  </div>
37
 
38
+ <div class="results-list-container" ref="resultsContainer">
39
  <SongItem
40
+ v-for="(song, index) in results"
41
+ :key="`${song.source}-${song.id}`"
42
+ :song="song"
43
+ :index="index"
44
+ :cover="song.cover"
45
+ @play="handlePlay"
46
+ @showMoreActions="handleShowMoreActions"
47
+ />
48
 
49
  <!-- 加载更多 -->
50
  <div v-if="hasMore" class="load-more" ref="loadMoreRef">
 
85
  </template>
86
 
87
  <script setup>
88
+ import { ref, computed, onMounted, onUnmounted, watch, defineAsyncComponent, createApp } from 'vue'
89
  import { useSearchStore } from '@/stores/search'
90
  import { usePlayerStore } from '@/stores/player'
91
  import { usePlayQueueStore } from '@/stores/playqueue'
 
113
  const toastStore = useToastStore()
114
 
115
  // 响应式数据
116
+ const resultsContainer = ref(null)
117
  const loadMoreRef = ref(null)
118
  const observer = ref(null)
119
 
 
132
  // 方法
133
  const handlePlay = async (song, index) => {
134
  try {
135
+ // SOLID原则:使用playQueueStore管理播放列表
136
  const result = playQueueStore.setQueue(results.value, index)
137
 
138
  if (result) {
 
148
  // 通知父组件
149
  emit('play', song, index)
150
  } else {
151
+ throw new Error('设置播放列表失败')
152
  }
153
  } catch (error) {
154
  console.error('播放失败:', error)
 
156
  }
157
  }
158
 
159
+ // 更多操作处理
160
+ const handleShowMoreActions = (song, event) => {
161
+ // 创建并显示更多操作面板
162
+ const MoreActionsPanel = defineAsyncComponent(() => import('@/components/player/MoreActionsPanel.vue'))
163
+
164
+ // 创建容器元素
165
+ const container = document.createElement('div')
166
+ document.body.appendChild(container)
167
+
168
+ // 创建Vue应用实例
169
+ const app = createApp({
170
+ template: `
171
+ <MoreActionsPanel
172
+ :song="song"
173
+ @close="handleClose"
174
+ @action="handleAction"
175
+ />
176
+ `,
177
+ components: { MoreActionsPanel },
178
+ setup() {
179
+ const handleClose = () => {
180
+ app.unmount()
181
+ document.body.removeChild(container)
182
+ }
183
+
184
+ const handleAction = (action) => {
185
+ console.log('执行操作:', action, song)
186
+ // 可以在这里处理具体的操作
187
+ handleClose()
188
+ }
189
+
190
+ return { song, handleClose, handleAction }
191
+ }
192
+ })
193
+
194
+ // 挂载应用
195
+ app.mount(container)
196
+ }
197
+
198
  // 设置智能滚动加载 - 可视区域超过一半时加载下一页
199
  const setupInfiniteScroll = () => {
200
+ if (!resultsContainer.value) return
201
 
202
  // 监听滚动事件
203
  const handleScroll = () => {
204
+ const container = resultsContainer.value
205
  const containerHeight = container.clientHeight
206
  const scrollTop = container.scrollTop
207
  const scrollHeight = container.scrollHeight
 
227
  scrollTimer = setTimeout(handleScroll, 50)
228
  }
229
 
230
+ resultsContainer.value.addEventListener('scroll', debouncedScroll, { passive: true })
231
 
232
  // 清理函数
233
  return () => {
234
+ if (resultsContainer.value) {
235
+ resultsContainer.value.removeEventListener('scroll', debouncedScroll)
236
  }
237
  if (scrollTimer) clearTimeout(scrollTimer)
238
  }
 
241
  let cleanupScrollListener = null
242
 
243
  // 监听歌曲容器元素
244
+ watch(resultsContainer, (newContainer) => {
245
  // 清理之前的监听器
246
  if (cleanupScrollListener) {
247
  cleanupScrollListener()
 
255
 
256
  // 监听搜索结果变化,重新设置滚动监听
257
  watch(results, () => {
258
+ if (resultsContainer.value && results.value.length > 0) {
259
  // 延迟设置,确保DOM已更新
260
  setTimeout(() => {
261
  if (cleanupScrollListener) {
 
267
  })
268
 
269
  onMounted(() => {
270
+ if (resultsContainer.value) {
271
  cleanupScrollListener = setupInfiniteScroll()
272
  }
273
  })
 
423
  font-weight: 500;
424
  }
425
 
426
+ .results-list-container {
 
 
 
 
 
 
427
  max-height: 70vh;
428
  overflow-y: auto;
429
  }
 
475
  .results-header {
476
  padding: 12px 16px 8px;
477
  }
 
 
 
 
478
  }
479
  </style>
src/components/search/SongItem.vue CHANGED
@@ -1,58 +1,92 @@
1
  <template>
2
- <div class="song-item" :class="{ active: isActive }" @click="handleClick">
3
- <div class="song-index">{{ String(index + 1).padStart(2, '0') }}</div>
4
-
5
- <div class="song-info">
6
- <div class="song-name">{{ song.name }}</div>
7
- <div class="song-artist">
8
- {{ formatArtist(song.artist) }} · {{ song.album }}
9
- </div>
10
- </div>
11
-
12
- <div class="song-actions">
13
- <button
14
- class="action-btn favorite-btn"
15
- :class="{ active: isFavorite }"
16
- @click.stop="toggleFavorite"
17
- :title="isFavorite ? '取消收藏' : '收藏'"
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: {
@@ -62,29 +96,75 @@ const props = defineProps({
62
  index: {
63
  type: Number,
64
  required: true
65
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  })
67
 
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(() => {
79
- const current = playerStore.currentSong
80
- return current && current.id === props.song.id && current.source === props.song.source
81
- })
82
-
83
  const isFavorite = computed(() => {
84
  return favoritesStore.isFavorite(props.song)
85
  })
86
 
 
 
 
 
 
 
 
 
87
  // 方法
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  const formatArtist = (artist) => {
89
  return utils.formatArtist(artist)
90
  }
@@ -93,246 +173,295 @@ const handleClick = () => {
93
  emit('play', props.song, props.index)
94
  }
95
 
96
- const toggleFavorite = () => {
97
- favoritesStore.toggleFavorite(props.song)
98
-
 
 
99
  // 触觉反馈
100
  if (navigator.vibrate) {
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>
155
- .song-item {
156
  display: flex;
157
  align-items: center;
158
- padding: 12px 16px;
159
- background: transparent;
160
- border-radius: var(--radius-small);
161
  cursor: pointer;
162
- transition: var(--transition-fast);
163
- position: relative;
164
- min-height: var(--list-item-height);
165
- border-bottom: 1px solid var(--border-lighter);
166
  }
167
 
168
- .song-item:hover {
169
- background: rgba(255, 255, 255, 0.05);
170
- border-bottom: 1px solid var(--border-light);
171
  }
172
 
173
- .song-item.active {
174
- background: rgba(255, 107, 107, 0.1);
175
- color: var(--accent-red);
176
- border-bottom: 1px solid var(--accent-red);
 
 
 
177
  }
178
 
179
- .song-item.active::before {
 
 
 
 
 
180
  content: '';
181
  position: absolute;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  left: 0;
183
- top: 50%;
184
- transform: translateY(-50%);
185
- width: 3px;
186
- height: 24px;
187
- background: var(--accent-red);
188
- border-radius: 2px;
 
 
189
  }
190
 
191
- .song-index {
192
- width: 32px;
193
- height: 32px;
194
- border-radius: 50%;
195
- background: rgba(255, 255, 255, 0.1);
 
 
 
 
 
 
 
 
 
 
 
 
196
  display: flex;
197
  align-items: center;
198
  justify-content: center;
199
- font-size: 12px;
200
- font-weight: 600;
201
- color: var(--text-secondary);
202
- margin-right: 12px;
203
- flex-shrink: 0;
204
  }
205
 
206
- .song-item.active .song-index {
207
- background: var(--accent-red);
208
  color: white;
 
209
  }
210
 
211
- .song-info {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  flex: 1;
213
  min-width: 0;
214
- margin-right: 12px;
215
  }
216
 
217
  .song-name {
218
- font-size: 15px;
219
- font-weight: 500;
220
  color: var(--text-primary);
221
- margin-bottom: 4px;
222
- white-space: nowrap;
223
  overflow: hidden;
224
  text-overflow: ellipsis;
 
225
  }
226
 
227
- .song-item.active .song-name {
228
- color: var(--accent-red);
229
- }
230
-
231
- .song-artist {
232
  font-size: 13px;
233
  color: var(--text-secondary);
234
- white-space: nowrap;
235
  overflow: hidden;
236
  text-overflow: ellipsis;
 
 
 
 
 
 
237
  }
238
 
239
  .song-actions {
240
  display: flex;
241
- gap: 4px;
242
- flex-shrink: 0;
243
  }
244
 
245
- .action-btn {
246
- width: 32px;
247
- height: 32px;
 
248
  border-radius: 50%;
249
- background: transparent;
250
- border: none;
251
- color: var(--text-disabled);
252
- cursor: pointer;
253
  display: flex;
254
  align-items: center;
255
  justify-content: center;
256
  font-size: 14px;
 
 
257
  transition: var(--transition-fast);
258
  }
259
 
260
- .action-btn:hover {
261
- background: rgba(255, 255, 255, 0.1);
262
- color: var(--text-secondary);
263
  }
264
 
265
  .favorite-btn.active {
 
266
  color: var(--accent-red);
267
  }
268
 
269
  .favorite-btn.active:hover {
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 {
314
- padding: 10px 12px;
315
  }
316
-
317
- .song-index {
318
- width: 28px;
319
- height: 28px;
320
- font-size: 11px;
321
- margin-right: 10px;
322
  }
323
-
324
- .action-btn {
325
- width: 28px;
326
- height: 28px;
327
- font-size: 12px;
328
  }
329
-
330
- .song-actions {
331
- gap: 2px;
332
  }
333
-
334
- .song-item:last-child {
335
- border-bottom: none;
 
 
336
  }
337
  }
338
- </style>
 
1
  <template>
2
+ <div class="favorite-item" :class="{
3
+ selected: isSelected,
4
+ playing: isCurrentSong
5
+ }">
6
+ <!-- 批量选择复选框 -->
7
+ <div v-if="showBatchActions" class="item-checkbox">
8
+ <label class="checkbox-wrapper">
9
+ <input
10
+ type="checkbox"
11
+ :checked="isSelected"
12
+ @change="$emit('toggleSelection', song)"
13
+ />
14
+ <span class="checkmark"></span>
15
+ </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  </div>
17
+
18
+ <!-- 歌曲信息 -->
19
+ <div class="song-info" @click="handleClick">
20
+ <div class="song-cover">
21
+ <img
22
+ :src="cover || getSongCover()"
23
+ :alt="song.name"
24
+ :data-song-data="JSON.stringify(song)"
25
+ @error="handleImageError"
26
+ />
27
+ <div class="play-overlay">
28
+ <i class="fas fa-play"></i>
29
+ </div>
30
+ <!-- 播放状态指示器 -->
31
+ <div v-if="isCurrentSong" class="playing-indicator">
32
+ <div v-if="isPlaying" class="sound-waves">
33
+ <div class="sound-bar"></div>
34
+ <div class="sound-bar"></div>
35
+ <div class="sound-bar"></div>
36
+ </div>
37
+ <i v-else class="fas fa-pause"></i>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="song-details">
42
+ <h3 class="song-name">{{ song.name }}</h3>
43
+ <p class="song-meta">
44
+ <span class="artist">{{ formatArtist(song.artist) }}</span>
45
+ </p>
46
+ </div>
47
  </div>
48
+
49
+ <!-- 操作按钮 -->
50
+ <div v-if="!showBatchActions && showActions" class="song-actions">
51
+ <button
52
+ class="action-btn favorite-btn"
53
+ :class="{ active: isFavorite }"
54
+ @click="toggleFavorite"
55
+ :title="isFavorite ? '取消收藏' : '收藏'"
56
+ >
57
+ <i :class="isFavorite ? 'fas fa-heart' : 'far fa-heart'"></i>
58
+ </button>
59
+
60
+ <!-- 播放列表场景:显示移除按钮 -->
61
+ <button
62
+ v-if="showRemove"
63
+ class="action-btn remove-btn"
64
+ @click="$emit('remove', index)"
65
+ title="从列表移除"
66
+ >
67
+ <i class="fas fa-times"></i>
68
+ </button>
69
+
70
+ <!-- 其他场景:显示更多操作 -->
71
+ <button
72
+ v-else
73
+ class="action-btn more-btn"
74
+ @click="handleShowMoreActions"
75
+ title="更多操作"
76
+ >
77
+ <i class="fas fa-ellipsis-h"></i>
78
+ </button>
79
+ </div>
80
  </div>
81
  </template>
82
 
83
  <script setup>
84
+ import { computed, onMounted } from 'vue'
 
 
85
  import { useFavoritesStore } from '@/stores/favorites'
86
  import { useToastStore } from '@/stores/toast'
87
+ import { usePlayerStore } from '@/stores/player'
88
+ import { useSongCoverLoader } from '@/composables/useSongCoverLoader'
89
  import { utils } from '@/services/musicApi'
 
90
 
91
  const props = defineProps({
92
  song: {
 
96
  index: {
97
  type: Number,
98
  required: true
99
+ },
100
+ // 是否显示批量选择
101
+ showBatchActions: {
102
+ type: Boolean,
103
+ default: false
104
+ },
105
+ // 是否被选中(批量选择用)
106
+ isSelected: {
107
+ type: Boolean,
108
+ default: false
109
+ },
110
+ // 是否显示移除按钮(播放列表用)
111
+ showRemove: {
112
+ type: Boolean,
113
+ default: false
114
+ },
115
+ // 是否显示操作按钮区域
116
+ showActions: {
117
+ type: Boolean,
118
+ default: true
119
+ },
120
+ // 歌曲封面URL
121
+ cover: {
122
+ type: String,
123
+ default: ''
124
+ }
125
  })
126
 
127
+ const emit = defineEmits(['play', 'remove', 'toggleSelection', 'showMoreActions'])
128
 
 
 
129
  const favoritesStore = useFavoritesStore()
130
  const toastStore = useToastStore()
131
+ const playerStore = usePlayerStore()
132
+ const { getDefaultCover, handleImageError, observeImage } = useSongCoverLoader()
133
 
134
  // 计算属性
 
 
 
 
 
135
  const isFavorite = computed(() => {
136
  return favoritesStore.isFavorite(props.song)
137
  })
138
 
139
+ const isCurrentSong = computed(() => {
140
+ return playerStore.currentSong?.id === props.song.id
141
+ })
142
+
143
+ const isPlaying = computed(() => {
144
+ return isCurrentSong.value && playerStore.isPlaying
145
+ })
146
+
147
  // 方法
148
+ // 图片加载相关
149
+ const getSongCover = () => {
150
+ return getDefaultCover()
151
+ }
152
+
153
+ // 初始化懒加载观察
154
+ const initLazyImages = () => {
155
+ const imageElements = document.querySelectorAll('.favorite-item .song-cover img')
156
+ imageElements.forEach((img, index) => {
157
+ const songData = img.dataset.songData
158
+ if (songData) {
159
+ try {
160
+ const song = JSON.parse(songData)
161
+ observeImage(img, song)
162
+ } catch (error) {
163
+ console.error('解析歌曲数据失败:', error)
164
+ }
165
+ }
166
+ })
167
+ }
168
  const formatArtist = (artist) => {
169
  return utils.formatArtist(artist)
170
  }
 
173
  emit('play', props.song, props.index)
174
  }
175
 
176
+ const toggleFavorite = async () => {
177
+ const result = await favoritesStore.toggleFavorite(props.song)
178
+ const message = result ? '已添加到我喜欢的音乐' : '已从我喜欢的音乐中移除'
179
+ toastStore.success(message)
180
+
181
  // 触觉反馈
182
  if (navigator.vibrate) {
183
  navigator.vibrate(50)
184
  }
185
  }
186
 
187
+ const handleShowMoreActions = (event) => {
188
+ emit('showMoreActions', props.song, event)
 
189
  }
190
 
191
+ // 生命周期
192
+ onMounted(() => {
193
+ // 初始化懒加载
194
+ setTimeout(() => {
195
+ initLazyImages()
196
+ }, 100)
197
+ })
198
+ </script>
199
+
200
+ <style scoped>
201
+ .favorite-item {
202
+ display: flex;
203
+ align-items: center;
204
+ padding: 12px 16px;
205
+ border-bottom: 1px solid var(--border-lighter);
206
+ background: var(--bg-card);
207
+ transition: var(--transition-fast);
208
  }
209
 
210
+ .favorite-item:hover {
211
+ background: var(--bg-gradient-1);
 
 
212
  }
213
 
214
+ .favorite-item.selected {
215
+ background: var(--bg-gradient-1);
216
+ border-left: 4px solid var(--accent-red);
217
  }
218
 
219
+ .favorite-item.playing {
220
+ background: var(--bg-gradient-2);
221
+ border-left: 4px solid var(--accent-red);
222
  }
223
 
224
+ .favorite-item.playing.selected {
225
+ background: var(--bg-gradient-3);
226
+ border-left: 4px solid var(--accent-red);
 
 
 
 
 
 
227
  }
228
 
229
+ .item-checkbox {
230
+ margin-right: 12px;
231
+ }
 
 
232
 
233
+ .checkbox-wrapper {
 
234
  display: flex;
235
  align-items: center;
236
+ gap: 8px;
 
 
237
  cursor: pointer;
 
 
 
 
238
  }
239
 
240
+ .checkbox-wrapper input {
241
+ display: none;
 
242
  }
243
 
244
+ .checkmark {
245
+ width: 18px;
246
+ height: 18px;
247
+ border: 2px solid var(--border-strong);
248
+ border-radius: 4px;
249
+ position: relative;
250
+ transition: var(--transition-fast);
251
  }
252
 
253
+ .checkbox-wrapper input:checked + .checkmark {
254
+ background: var(--accent-red);
255
+ border-color: var(--accent-red);
256
+ }
257
+
258
+ .checkbox-wrapper input:checked + .checkmark::after {
259
  content: '';
260
  position: absolute;
261
+ left: 5px;
262
+ top: 2px;
263
+ width: 4px;
264
+ height: 8px;
265
+ border: solid white;
266
+ border-width: 0 2px 2px 0;
267
+ transform: rotate(45deg);
268
+ }
269
+
270
+ .song-info {
271
+ display: flex;
272
+ align-items: center;
273
+ flex: 1;
274
+ cursor: pointer;
275
+ gap: 12px;
276
+ min-width: 0;
277
+ }
278
+
279
+ .song-cover {
280
+ position: relative;
281
+ width: 50px;
282
+ height: 50px;
283
+ border-radius: var(--radius-small);
284
+ overflow: hidden;
285
+ }
286
+
287
+ .song-cover img {
288
+ width: 100%;
289
+ height: 100%;
290
+ object-fit: cover;
291
+ }
292
+
293
+ .play-overlay {
294
+ position: absolute;
295
+ top: 0;
296
  left: 0;
297
+ right: 0;
298
+ bottom: 0;
299
+ background: rgba(0, 0, 0, 0.5);
300
+ display: flex;
301
+ align-items: center;
302
+ justify-content: center;
303
+ opacity: 0;
304
+ transition: var(--transition-fast);
305
  }
306
 
307
+ .song-cover:hover .play-overlay {
308
+ opacity: 1;
309
+ }
310
+
311
+ .play-overlay i {
312
+ color: white;
313
+ font-size: 16px;
314
+ }
315
+
316
+ /* 播放状态指示器 */
317
+ .playing-indicator {
318
+ position: absolute;
319
+ top: 0;
320
+ left: 0;
321
+ right: 0;
322
+ bottom: 0;
323
+ background: rgba(0, 0, 0, 0.7);
324
  display: flex;
325
  align-items: center;
326
  justify-content: center;
327
+ border-radius: var(--radius-small);
 
 
 
 
328
  }
329
 
330
+ .playing-indicator i {
 
331
  color: white;
332
+ font-size: 16px;
333
  }
334
 
335
+ /* 动态声音波形 */
336
+ .sound-waves {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 2px;
340
+ height: 16px;
341
+ }
342
+
343
+ .sound-bar {
344
+ width: 3px;
345
+ height: 8px;
346
+ background: white;
347
+ border-radius: 2px;
348
+ animation: sound-wave 1.2s ease-in-out infinite;
349
+ }
350
+
351
+ .sound-bar:nth-child(1) {
352
+ animation-delay: 0s;
353
+ height: 12px;
354
+ }
355
+
356
+ .sound-bar:nth-child(2) {
357
+ animation-delay: 0.2s;
358
+ height: 16px;
359
+ }
360
+
361
+ .sound-bar:nth-child(3) {
362
+ animation-delay: 0.4s;
363
+ height: 10px;
364
+ }
365
+
366
+ @keyframes sound-wave {
367
+ 0%, 100% {
368
+ opacity: 0.4;
369
+ transform: scaleY(0.6);
370
+ }
371
+ 50% {
372
+ opacity: 1;
373
+ transform: scaleY(1);
374
+ }
375
+ }
376
+
377
+ .song-details {
378
  flex: 1;
379
  min-width: 0;
 
380
  }
381
 
382
  .song-name {
383
+ font-size: 16px;
384
+ font-weight: 600;
385
  color: var(--text-primary);
386
+ margin: 0 0 4px;
 
387
  overflow: hidden;
388
  text-overflow: ellipsis;
389
+ white-space: nowrap;
390
  }
391
 
392
+ .song-meta {
 
 
 
 
393
  font-size: 13px;
394
  color: var(--text-secondary);
395
+ margin: 0 0 4px;
396
  overflow: hidden;
397
  text-overflow: ellipsis;
398
+ white-space: nowrap;
399
+ }
400
+
401
+ .separator {
402
+ margin: 0 4px;
403
+ opacity: 0.6;
404
  }
405
 
406
  .song-actions {
407
  display: flex;
408
+ gap: 8px;
 
409
  }
410
 
411
+ .song-actions .action-btn {
412
+ width: 36px;
413
+ height: 36px;
414
+ padding: 0;
415
  border-radius: 50%;
416
+ background: rgba(255, 255, 255, 0.1);
417
+ color: var(--text-secondary);
 
 
418
  display: flex;
419
  align-items: center;
420
  justify-content: center;
421
  font-size: 14px;
422
+ border: none;
423
+ cursor: pointer;
424
  transition: var(--transition-fast);
425
  }
426
 
427
+ .song-actions .action-btn:hover {
428
+ background: rgba(255, 255, 255, 0.2);
429
+ color: var(--text-primary);
430
  }
431
 
432
  .favorite-btn.active {
433
+ background: rgba(255, 107, 107, 0.2);
434
  color: var(--accent-red);
435
  }
436
 
437
  .favorite-btn.active:hover {
438
+ background: rgba(255, 107, 107, 0.3);
439
+ color: var(--accent-red);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  }
441
 
442
  /* 响应式 */
443
  @media (max-width: 375px) {
444
+ .favorite-item {
445
+ padding: 12px;
446
  }
447
+
448
+ .song-cover {
449
+ width: 45px;
450
+ height: 45px;
 
 
451
  }
452
+
453
+ .song-name {
454
+ font-size: 15px;
 
 
455
  }
456
+
457
+ .song-meta {
458
+ font-size: 12px;
459
  }
460
+
461
+ .song-actions .action-btn {
462
+ width: 32px;
463
+ height: 32px;
464
+ font-size: 12px;
465
  }
466
  }
467
+ </style>
src/components/search/SourceSelector.vue CHANGED
@@ -96,7 +96,7 @@ const selectSource = (sourceCode) => {
96
  align-items: center;
97
  padding: 20px 24px;
98
  border-bottom: 1px solid var(--border-strong);
99
- background: rgba(255, 255, 255, 0.05);
100
  }
101
 
102
  .selector-header h3 {
@@ -147,12 +147,12 @@ const selectSource = (sourceCode) => {
147
  }
148
 
149
  .source-item:hover {
150
- background: rgba(255, 255, 255, 0.05);
151
  border-bottom: 1px solid var(--border-light);
152
  }
153
 
154
  .source-item.active {
155
- background: rgba(255, 107, 107, 0.1);
156
  color: var(--accent-red);
157
  border-bottom: 1px solid var(--accent-red);
158
  }
 
96
  align-items: center;
97
  padding: 20px 24px;
98
  border-bottom: 1px solid var(--border-strong);
99
+ background: var(--overlay-lighter);
100
  }
101
 
102
  .selector-header h3 {
 
147
  }
148
 
149
  .source-item:hover {
150
+ background: var(--overlay-lighter);
151
  border-bottom: 1px solid var(--border-light);
152
  }
153
 
154
  .source-item.active {
155
+ background: var(--bg-gradient-3);
156
  color: var(--accent-red);
157
  border-bottom: 1px solid var(--accent-red);
158
  }
src/composables/useSongCoverLoader.js CHANGED
@@ -109,7 +109,14 @@ export const useSongCoverLoader = () => {
109
  return
110
  }
111
 
112
- const coverUrl = await playerStore.getAlbumCover(song, 300)
 
 
 
 
 
 
 
113
  if (coverUrl && imgElement.parentNode) {
114
  imgElement.src = coverUrl
115
  }
@@ -157,7 +164,13 @@ export const useSongCoverLoader = () => {
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)
@@ -175,7 +188,14 @@ export const useSongCoverLoader = () => {
175
  if (!imgElement || !song) return
176
 
177
  try {
178
- const coverUrl = await playerStore.getAlbumCover(song, size)
 
 
 
 
 
 
 
179
  if (coverUrl) {
180
  imgElement.src = coverUrl
181
  }
 
109
  return
110
  }
111
 
112
+ // 使用新的缓存机制
113
+ const { getCachedMusicPicUrlWithDelay } = await import('@/utils/musicPicCache.js')
114
+ const coverUrl = await getCachedMusicPicUrlWithDelay(
115
+ song.source,
116
+ song.pic_id || song.id,
117
+ 300
118
+ )
119
+
120
  if (coverUrl && imgElement.parentNode) {
121
  imgElement.src = coverUrl
122
  }
 
164
  if (!song) return getDefaultCover()
165
 
166
  try {
167
+ // 使用新的缓存机制(无延时版本)
168
+ const { getCachedMusicPicUrl } = await import('@/utils/musicPicCache.js')
169
+ const coverUrl = await getCachedMusicPicUrl(
170
+ song.source,
171
+ song.pic_id || song.id,
172
+ size
173
+ )
174
  return coverUrl || getDefaultCover()
175
  } catch (error) {
176
  console.error('加载封面失败:', error)
 
188
  if (!imgElement || !song) return
189
 
190
  try {
191
+ // 使用新的缓存机制
192
+ const { getCachedMusicPicUrlWithDelay } = await import('@/utils/musicPicCache.js')
193
+ const coverUrl = await getCachedMusicPicUrlWithDelay(
194
+ song.source,
195
+ song.pic_id || song.id,
196
+ size
197
+ )
198
+
199
  if (coverUrl) {
200
  imgElement.src = coverUrl
201
  }
src/router/index.js CHANGED
@@ -59,8 +59,8 @@ const routes = [
59
  name: 'PlayQueue',
60
  component: PlayQueuePage,
61
  meta: {
62
- title: '播放队列',
63
- keepAlive: false // 播放队列需要实时更新,不缓存
64
  }
65
  },
66
  {
@@ -78,7 +78,7 @@ const routes = [
78
  component: PlaylistDetailPage,
79
  meta: {
80
  title: '歌单详情',
81
- keepAlive: false
82
  }
83
  },
84
  {
 
59
  name: 'PlayQueue',
60
  component: PlayQueuePage,
61
  meta: {
62
+ title: '播放列表',
63
+ keepAlive: true // 启用缓存,避免重复加载图片,播放列表通过响应式数据实现实时更新
64
  }
65
  },
66
  {
 
78
  component: PlaylistDetailPage,
79
  meta: {
80
  title: '歌单详情',
81
+ keepAlive: true // 启用缓存,避免重复加载图片
82
  }
83
  },
84
  {
src/services/musicApi.js CHANGED
@@ -152,23 +152,18 @@ class MusicApi {
152
 
153
  // 获取专辑封面
154
  async getAlbumCover(source, picId, size = 300) {
 
 
 
155
  if (!picId) {
156
  return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDMwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIHJ4PSIxMiIgZmlsbD0iIzE4MTgxOCIvPjxwYXRoIGQ9Ik0xODcgMTUwQzE4NyAxNzEuNTM5IDE2OS41MzkgMTg5IDE0OCAxODlDMTI2LjQ2MSAxODkgMTA5IDE3MS41MzkgMTA5IDE1MEMxMDkgMTI4LjQ2MSAxMjYuNDYxIDExMSAxNDggMTExQzE2OS41MzkgMTExIDE4NyAxMjguNDYxIDE4NyAxNTBaIiBzdHJva2U9IiM2NjY2NjYiIHN0cm9rZS13aWR0aD0iNiIvPjxjaXJjbGUgY3g9IjE0OCIgY3k9IjE1MCIgcj0iNiIgZmlsbD0iIzY2NjY2NiIvPjwvc3ZnPgo='
157
  }
158
 
159
  try {
160
- const params = {
161
- types: 'pic',
162
- source: source,
163
- id: picId,
164
- size: size
165
- }
166
 
167
- const url = this.buildUrl(params)
168
- const response = await apiClient.get(url)
169
-
170
- if (response && response.url) {
171
- return response.url
172
  }
173
  } catch (error) {
174
  console.error('获取专辑图失败:', error)
 
152
 
153
  // 获取专辑封面
154
  async getAlbumCover(source, picId, size = 300) {
155
+ // 使用新的缓存机制
156
+ const { getCachedMusicPicUrlWithDelay } = await import('@/utils/musicPicCache.js')
157
+
158
  if (!picId) {
159
  return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDMwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjMwMCIgaGVpZ2h0PSIzMDAiIHJ4PSIxMiIgZmlsbD0iIzE4MTgxOCIvPjxwYXRoIGQ9Ik0xODcgMTUwQzE4NyAxNzEuNTM5IDE2OS41MzkgMTg5IDE0OCAxODlDMTI2LjQ2MSAxODkgMTA5IDE3MS41MzkgMTA5IDE1MEMxMDkgMTI4LjQ2MSAxMjYuNDYxIDExMSAxNDggMTExQzE2OS41MzkgMTExIDE4NyAxMjguNDYxIDE4NyAxNTBaIiBzdHJva2U9IiM2NjY2NjYiIHN0cm9rZS13aWR0aD0iNiIvPjxjaXJjbGUgY3g9IjE0OCIgY3k9IjE1MCIgcj0iNiIgZmlsbD0iIzY2NjY2NiIvPjwvc3ZnPgo='
160
  }
161
 
162
  try {
163
+ const coverUrl = await getCachedMusicPicUrlWithDelay(source, picId, size)
 
 
 
 
 
164
 
165
+ if (coverUrl) {
166
+ return coverUrl
 
 
 
167
  }
168
  } catch (error) {
169
  console.error('获取专辑图失败:', error)
src/stores/player.js CHANGED
@@ -4,7 +4,7 @@ import { musicApi } from '@/services/musicApi'
4
 
5
  /**
6
  * 播放器控制 Store - 单一职责:管理音频播放控制逻辑
7
- * 不包含播放队列管理,专注于播放器状态和音频控制
8
  */
9
  export const usePlayerStore = defineStore('player', () => {
10
  // 播放器状态
 
4
 
5
  /**
6
  * 播放器控制 Store - 单一职责:管理音频播放控制逻辑
7
+ * 不包含播放列表管理,专注于播放器状态和音频控制
8
  */
9
  export const usePlayerStore = defineStore('player', () => {
10
  // 播放器状态
src/stores/playlist.js CHANGED
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue'
3
 
4
  /**
5
  * 歌单管理 Store
6
- * 管理用户创建的歌单集合,不包含播放队列逻辑
7
  */
8
  export const usePlaylistStore = defineStore('playlist', () => {
9
  // 状态
@@ -72,7 +72,7 @@ export const usePlaylistStore = defineStore('playlist', () => {
72
  }
73
 
74
  // 歌曲管理方法
75
- const addSongToPlaylist = (playlistId, song) => {
76
  const playlist = getPlaylist(playlistId)
77
  if (!playlist) {
78
  return { success: false, message: '歌单不存在' }
@@ -102,14 +102,22 @@ export const usePlaylistStore = defineStore('playlist', () => {
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
 
@@ -125,9 +133,19 @@ export const usePlaylistStore = defineStore('playlist', () => {
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
  }
 
3
 
4
  /**
5
  * 歌单管理 Store
6
+ * 管理用户创建的歌单集合,不包含播放列表逻辑
7
  */
8
  export const usePlaylistStore = defineStore('playlist', () => {
9
  // 状态
 
72
  }
73
 
74
  // 歌曲管理方法
75
+ const addSongToPlaylist = async (playlistId, song) => {
76
  const playlist = getPlaylist(playlistId)
77
  if (!playlist) {
78
  return { success: false, message: '歌单不存在' }
 
102
 
103
  // 更新封面(使用第一首歌的封面)
104
  if (playlist.songs.length === 1 && song.pic_id) {
105
+ // 使用缓存机制获取封面URL
106
+ const { getCachedMusicPicUrl } = await import('@/utils/musicPicCache.js')
107
+ try {
108
+ const coverUrl = await getCachedMusicPicUrl(song.source, song.pic_id, 300)
109
+ playlist.cover = coverUrl || null
110
+ } catch (error) {
111
+ console.error('获取歌单封面失败:', error)
112
+ playlist.cover = null
113
+ }
114
  }
115
 
116
  saveToStorage()
117
  return { success: true, message: '添加成功' }
118
  }
119
 
120
+ const removeSongFromPlaylist = async (playlistId, songId, songSource) => {
121
  const playlist = getPlaylist(playlistId)
122
  if (!playlist) return false
123
 
 
133
  // 如果删除的是第一首歌且还有其他歌曲,更新封面
134
  if (index === 0 && playlist.songs.length > 0) {
135
  const firstSong = playlist.songs[0]
136
+ if (firstSong.pic_id) {
137
+ // 使用缓存机制获取封面URL
138
+ try {
139
+ const { getCachedMusicPicUrl } = await import('@/utils/musicPicCache.js')
140
+ const coverUrl = await getCachedMusicPicUrl(firstSong.source, firstSong.pic_id, 300)
141
+ playlist.cover = coverUrl || null
142
+ } catch (error) {
143
+ console.error('更新歌单封面失败:', error)
144
+ playlist.cover = null
145
+ }
146
+ } else {
147
+ playlist.cover = null
148
+ }
149
  } else if (playlist.songs.length === 0) {
150
  playlist.cover = null
151
  }
src/stores/playqueue.js CHANGED
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
2
  import { ref, computed } from 'vue'
3
 
4
  /**
5
- * 播放队列 Store - 单一职责:管理当前播放器的临时队列
6
  * 对应网易云音乐的 "Now Playing" 概念
7
  */
8
  export const usePlayQueueStore = defineStore('playqueue', () => {
@@ -48,11 +48,11 @@ export const usePlayQueueStore = defineStore('playqueue', () => {
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) {
@@ -74,7 +74,7 @@ export const usePlayQueueStore = defineStore('playqueue', () => {
74
  }
75
 
76
  saveQueue()
77
- return { success: true, message: '已添加到播放队列' }
78
  }
79
 
80
  const removeFromQueue = (index) => {
@@ -97,7 +97,7 @@ export const usePlayQueueStore = defineStore('playqueue', () => {
97
  }
98
 
99
  saveQueue()
100
- return { success: true, message: '已从播放队列移除' }
101
  }
102
 
103
  const clearQueue = () => {
@@ -283,7 +283,7 @@ export const usePlayQueueStore = defineStore('playqueue', () => {
283
  }
284
  localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
285
  } catch (error) {
286
- console.error('保存播放队列失败:', error)
287
  }
288
  }
289
 
@@ -301,7 +301,7 @@ export const usePlayQueueStore = defineStore('playqueue', () => {
301
  }
302
  }
303
  } catch (error) {
304
- console.error('加载播放队列失败:', error)
305
  queue.value = []
306
  currentIndex.value = -1
307
  playMode.value = 'list'
 
2
  import { ref, computed } from 'vue'
3
 
4
  /**
5
+ * 播放列表 Store - 单一职责:管理当前播放器的临时队列
6
  * 对应网易云音乐的 "Now Playing" 概念
7
  */
8
  export const usePlayQueueStore = defineStore('playqueue', () => {
 
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) {
 
74
  }
75
 
76
  saveQueue()
77
+ return { success: true, message: '已添加到播放列表' }
78
  }
79
 
80
  const removeFromQueue = (index) => {
 
97
  }
98
 
99
  saveQueue()
100
+ return { success: true, message: '已从播放列表移除' }
101
  }
102
 
103
  const clearQueue = () => {
 
283
  }
284
  localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
285
  } catch (error) {
286
+ console.error('保存播放列表失败:', error)
287
  }
288
  }
289
 
 
301
  }
302
  }
303
  } catch (error) {
304
+ console.error('加载播放列表失败:', error)
305
  queue.value = []
306
  currentIndex.value = -1
307
  playMode.value = 'list'
src/styles/global.css CHANGED
@@ -57,6 +57,7 @@
57
  --glow-color: rgba(255, 107, 107, 0.35);
58
  --overlay-dark: rgba(0, 0, 0, 0.6);
59
  --overlay-light: rgba(255, 255, 255, 0.1);
 
60
 
61
  /* 兼容性阴影 */
62
  --shadow-card: 0 8px 25px var(--shadow-color);
@@ -90,6 +91,7 @@
90
  /* 阴影 - 黑色朦胧主题 */
91
  --shadow-card: 0 8px 25px rgba(0, 0, 0, 0.4);
92
  --shadow-button: 0 4px 12px rgba(255, 107, 107, 0.5);
 
93
  }
94
 
95
  /* 全局重置 */
 
57
  --glow-color: rgba(255, 107, 107, 0.35);
58
  --overlay-dark: rgba(0, 0, 0, 0.6);
59
  --overlay-light: rgba(255, 255, 255, 0.1);
60
+ --overlay-lighter: rgba(255, 255, 255, 0.05);
61
 
62
  /* 兼容性阴影 */
63
  --shadow-card: 0 8px 25px var(--shadow-color);
 
91
  /* 阴影 - 黑色朦胧主题 */
92
  --shadow-card: 0 8px 25px rgba(0, 0, 0, 0.4);
93
  --shadow-button: 0 4px 12px rgba(255, 107, 107, 0.5);
94
+ --overlay-lighter: rgba(255, 255, 255, 0.05);
95
  }
96
 
97
  /* 全局重置 */
src/utils/musicPicCache.js ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { urlCacheManager } from './urlCache.js'
2
+
3
+ // 请求去重映射
4
+ const pendingRequests = new Map()
5
+
6
+ /**
7
+ * 带缓存的音乐图片URL获取函数
8
+ * @param {string} source - 音乐源
9
+ * @param {string} id - 图片ID
10
+ * @param {string|number} size - 图片尺寸
11
+ * @param {boolean} skipCache - 是否跳过缓存
12
+ * @returns {Promise<string>} 图片URL
13
+ */
14
+ export async function getCachedMusicPicUrl(source, id, size = 300, skipCache = false) {
15
+ if (!source || !id) return null
16
+
17
+ const sizeStr = String(size)
18
+
19
+ // 先检查缓存(除非明确跳过)
20
+ if (!skipCache) {
21
+ const cached = urlCacheManager.getCachedUrl(source, id, sizeStr)
22
+ if (cached) {
23
+ return cached
24
+ }
25
+ }
26
+
27
+ // 检查是否有正在进行的相同请求(去重)
28
+ const requestKey = `${source}+${id}+${sizeStr}`
29
+ if (pendingRequests.has(requestKey)) {
30
+ return pendingRequests.get(requestKey)
31
+ }
32
+
33
+ // 创建新的API请求
34
+ const apiUrl = `https://music-api.gdstudio.xyz/api.php?types=pic&source=${encodeURIComponent(source)}&id=${encodeURIComponent(id)}&size=${encodeURIComponent(sizeStr)}`
35
+
36
+ const requestPromise = fetch(apiUrl)
37
+ .then(response => {
38
+ if (!response.ok) {
39
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
40
+ }
41
+ return response.json()
42
+ })
43
+ .then(data => {
44
+ let resultUrl = null
45
+ if (data && data.url) {
46
+ resultUrl = data.url
47
+ // 将结果存入缓存
48
+ urlCacheManager.setCachedUrl(source, id, sizeStr, resultUrl)
49
+ }
50
+ return resultUrl
51
+ })
52
+ .catch(error => {
53
+ console.error('获取音乐图片失败:', error)
54
+ return null
55
+ })
56
+ .finally(() => {
57
+ // 请求完成后从去重映射中移除
58
+ pendingRequests.delete(requestKey)
59
+ })
60
+
61
+ // 将请求添加到去重映射
62
+ pendingRequests.set(requestKey, requestPromise)
63
+
64
+ return requestPromise
65
+ }
66
+
67
+ /**
68
+ * 带延时的音乐图片URL获取函数(懒加载专用)
69
+ * 第一个请求立即执行,后续请求间隔200ms
70
+ * @param {string} source - 音乐源
71
+ * @param {string} id - 图片ID
72
+ * @param {string|number} size - 图片尺寸
73
+ * @returns {Promise<string>} 图片URL
74
+ */
75
+ export async function getCachedMusicPicUrlWithDelay(source, id, size = 300) {
76
+ if (!source || !id) return null
77
+
78
+ const sizeStr = String(size)
79
+
80
+ // 先检查缓存
81
+ const cached = urlCacheManager.getCachedUrl(source, id, sizeStr)
82
+ if (cached) {
83
+ // 缓存命中,直接返回,无需延时
84
+ return cached
85
+ }
86
+
87
+ // 缓存未命中,需要请求API
88
+ // 如果不是第一个请求,添加200ms延时
89
+ const requestKey = `${source}+${id}+${sizeStr}`
90
+ if (pendingRequests.size > 0) {
91
+ await new Promise(resolve => setTimeout(resolve, 200))
92
+ }
93
+
94
+ return getCachedMusicPicUrl(source, id, size)
95
+ }
96
+
97
+ /**
98
+ * 批量预加载音乐图片URL
99
+ * @param {Array} songs - 歌曲数组,每个歌曲应包含source和pic_id字段
100
+ * @param {string|number} size - 图片尺寸
101
+ * @param {number} concurrency - 并发数量
102
+ */
103
+ export async function preloadMusicPics(songs, size = 300, concurrency = 1) {
104
+ if (!Array.isArray(songs) || songs.length === 0) return
105
+
106
+ const sizeStr = String(size)
107
+
108
+ // 过滤出需要加载的歌曲(未缓存的)
109
+ const toLoad = songs.filter(song => {
110
+ if (!song.source || !song.pic_id) return false
111
+ return !urlCacheManager.getCachedUrl(song.source, song.pic_id, sizeStr)
112
+ })
113
+
114
+ if (toLoad.length === 0) return
115
+
116
+ // 分批并发加载
117
+ for (let i = 0; i < toLoad.length; i += concurrency) {
118
+ const batch = toLoad.slice(i, i + concurrency)
119
+ const promises = batch.map(song =>
120
+ getCachedMusicPicUrl(song.source, song.pic_id, size).catch(error => {
121
+ console.warn(`预加载 ${song.name} 图片失败:`, error)
122
+ return null
123
+ })
124
+ )
125
+
126
+ await Promise.all(promises)
127
+
128
+ // 批次间间隔200ms
129
+ if (i + concurrency < toLoad.length) {
130
+ await new Promise(resolve => setTimeout(resolve, 200))
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 清除指定歌曲的图片缓存
137
+ * @param {string} source - 音乐源
138
+ * @param {string} id - 图片ID
139
+ */
140
+ export function clearMusicPicCache(source, id) {
141
+ if (!source || !id) return
142
+
143
+ // 清除所有尺寸的缓存
144
+ const commonSizes = ['300', '500', '800', '1200']
145
+ commonSizes.forEach(size => {
146
+ const key = urlCacheManager.getCacheKey(source, id, size)
147
+ urlCacheManager.cache.delete(key)
148
+ })
149
+
150
+ urlCacheManager.saveToStorage()
151
+ }
152
+
153
+ /**
154
+ * 获取缓存统计信息
155
+ */
156
+ export function getCacheStats() {
157
+ return urlCacheManager.getStats()
158
+ }
159
+
160
+ /**
161
+ * 清空所有缓存
162
+ */
163
+ export function clearAllCache() {
164
+ urlCacheManager.clear()
165
+ }
166
+
167
+ export default {
168
+ getCachedMusicPicUrl,
169
+ getCachedMusicPicUrlWithDelay,
170
+ preloadMusicPics,
171
+ clearMusicPicCache,
172
+ getCacheStats,
173
+ clearAllCache
174
+ }
src/utils/urlCache.js ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 音乐图片URL缓存管理器
2
+ class UrlCacheManager {
3
+ constructor() {
4
+ this.cache = new Map()
5
+ this.maxCacheSize = 1000 // 上限1000个
6
+ this.storageKey = 'music-url-cache-v1'
7
+ this.cacheExpiry = 7 * 24 * 60 * 60 * 1000 // 7天过期
8
+ this.loadFromStorage()
9
+ }
10
+
11
+ /**
12
+ * 生成缓存键
13
+ * @param {string} source - 音乐源
14
+ * @param {string} id - 歌曲或图片ID
15
+ * @param {string} br - 比特率或尺寸
16
+ * @returns {string} 缓存键
17
+ */
18
+ getCacheKey(source, id, br) {
19
+ return `${source}+${id}+${br}`
20
+ }
21
+
22
+ /**
23
+ * 获取缓存的URL
24
+ * @param {string} source - 音乐源
25
+ * @param {string} id - 歌曲或图片ID
26
+ * @param {string} br - 比特率或尺寸
27
+ * @returns {string|null} 缓存的URL,如果不存在返回null
28
+ */
29
+ getCachedUrl(source, id, br) {
30
+ if (!source || !id) return null
31
+
32
+ const key = this.getCacheKey(source, id, br)
33
+ const cached = this.cache.get(key)
34
+
35
+ if (cached) {
36
+ // 检查是否过期
37
+ if (Date.now() - cached.timestamp < this.cacheExpiry) {
38
+ return cached.url
39
+ } else {
40
+ // 过期删除
41
+ this.cache.delete(key)
42
+ this.saveToStorage()
43
+ }
44
+ }
45
+
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * 设置缓存URL
51
+ * @param {string} source - 音乐源
52
+ * @param {string} id - 歌曲或图片ID
53
+ * @param {string} br - 比特率或尺寸
54
+ * @param {string} url - 要缓存的URL
55
+ */
56
+ setCachedUrl(source, id, br, url) {
57
+ if (!source || !id || !url) return
58
+
59
+ const key = this.getCacheKey(source, id, br)
60
+
61
+ // 如果缓存已满,删除最旧的条目
62
+ if (this.cache.size >= this.maxCacheSize) {
63
+ this.evictOldest()
64
+ }
65
+
66
+ this.cache.set(key, {
67
+ url,
68
+ timestamp: Date.now()
69
+ })
70
+
71
+ this.saveToStorage()
72
+ }
73
+
74
+ /**
75
+ * 删除最旧的缓存条目
76
+ */
77
+ evictOldest() {
78
+ let oldestKey = null
79
+ let oldestTime = Date.now()
80
+
81
+ for (const [key, value] of this.cache.entries()) {
82
+ if (value.timestamp < oldestTime) {
83
+ oldestTime = value.timestamp
84
+ oldestKey = key
85
+ }
86
+ }
87
+
88
+ if (oldestKey) {
89
+ this.cache.delete(oldestKey)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 清理过期的缓存条目
95
+ */
96
+ cleanExpired() {
97
+ const now = Date.now()
98
+ const expiredKeys = []
99
+
100
+ for (const [key, value] of this.cache.entries()) {
101
+ if (now - value.timestamp > this.cacheExpiry) {
102
+ expiredKeys.push(key)
103
+ }
104
+ }
105
+
106
+ expiredKeys.forEach(key => this.cache.delete(key))
107
+
108
+ if (expiredKeys.length > 0) {
109
+ this.saveToStorage()
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 从localStorage加载缓存
115
+ */
116
+ loadFromStorage() {
117
+ try {
118
+ const saved = localStorage.getItem(this.storageKey)
119
+ if (saved) {
120
+ const data = JSON.parse(saved)
121
+ if (data && Array.isArray(data.entries)) {
122
+ // 恢复Map数据,同时清理过期条目
123
+ const now = Date.now()
124
+ data.entries.forEach(([key, value]) => {
125
+ if (now - value.timestamp < this.cacheExpiry) {
126
+ this.cache.set(key, value)
127
+ }
128
+ })
129
+ }
130
+ }
131
+ } catch (error) {
132
+ console.error('加载URL缓存失败:', error)
133
+ this.cache.clear()
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 保存缓存到localStorage
139
+ */
140
+ saveToStorage() {
141
+ try {
142
+ const data = {
143
+ entries: Array.from(this.cache.entries()),
144
+ timestamp: Date.now()
145
+ }
146
+ localStorage.setItem(this.storageKey, JSON.stringify(data))
147
+ } catch (error) {
148
+ console.error('保存URL缓存失败:', error)
149
+ // 如果保存失败(可能是空间不足),清理一些旧缓存
150
+ this.evictOldest()
151
+ try {
152
+ const data = {
153
+ entries: Array.from(this.cache.entries()),
154
+ timestamp: Date.now()
155
+ }
156
+ localStorage.setItem(this.storageKey, JSON.stringify(data))
157
+ } catch (e) {
158
+ console.warn('URL缓存保存最终失败:', e)
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 清空所有缓存
165
+ */
166
+ clear() {
167
+ this.cache.clear()
168
+ localStorage.removeItem(this.storageKey)
169
+ }
170
+
171
+ /**
172
+ * 获取缓存统计信息
173
+ */
174
+ getStats() {
175
+ return {
176
+ size: this.cache.size,
177
+ maxSize: this.maxCacheSize,
178
+ utilization: (this.cache.size / this.maxCacheSize * 100).toFixed(1) + '%'
179
+ }
180
+ }
181
+ }
182
+
183
+ // 创建全局实例
184
+ export const urlCacheManager = new UrlCacheManager()
185
+
186
+ // 启动定期清理过期缓存(每小时清理一次)
187
+ setInterval(() => {
188
+ urlCacheManager.cleanExpired()
189
+ }, 60 * 60 * 1000)
190
+
191
+ export default urlCacheManager
src/views/CurrentPlaylistPage.vue CHANGED
@@ -127,7 +127,7 @@
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>
@@ -281,12 +281,12 @@ const toggleFavorite = async (song) => {
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
  // 菜单操作
@@ -312,11 +312,11 @@ const hideSongActions = () => {
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()
@@ -555,11 +555,11 @@ const startDrag = (index) => {
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
 
 
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>
 
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
  // 菜单操作
 
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()
 
555
  }
556
 
557
  .song-item:hover {
558
+ background: var(--overlay-lighter);
559
  }
560
 
561
  .song-item.current {
562
+ background: var(--bg-gradient-3);
563
  border-left: 3px solid var(--accent-red);
564
  }
565
 
src/views/FavoritesPage.vue CHANGED
@@ -70,56 +70,17 @@
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
 
@@ -155,6 +116,7 @@ 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()
@@ -242,13 +204,32 @@ const goToHome = () => {
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
 
@@ -387,17 +368,17 @@ const handleMoreAction = async (action) => {
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
 
@@ -739,11 +720,11 @@ onMounted(async () => {
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
 
 
70
  </div>
71
 
72
  <div v-else class="favorites-list">
73
+ <SongItem
74
  v-for="(favorite, index) in displayedFavorites"
75
  :key="`${favorite.song.id}-${favorite.favoriteTime}`"
76
+ :song="favorite.song"
77
+ :index="index"
78
+ :showBatchActions="showBatchActions"
79
+ :isSelected="selectedItems.has(favorite.song.id)"
80
+ @play="handlePlay"
81
+ @toggleSelection="toggleItemSelection"
82
+ @showMoreActions="handleShowMoreActions"
83
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  </div>
85
  </div>
86
 
 
116
  import { utils } from '@/services/musicApi'
117
  import MoreActionsPanel from '@/components/player/MoreActionsPanel.vue'
118
  import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
119
+ import SongItem from '@/components/search/SongItem.vue'
120
 
121
  const router = useRouter()
122
  const favoritesStore = useFavoritesStore()
 
204
  }
205
 
206
  // 播放相关方法
207
+ const handlePlay = async (song, index) => {
208
+ if (showBatchActions.value) {
209
+ toggleItemSelection(song)
210
+ return
211
+ }
212
+
213
+ // 将所有收藏的歌曲添加到播放列表
214
+ const songs = displayedFavorites.value.map(fav => fav.song)
215
+ const result = playQueueStore.setQueue(songs, index)
216
+
217
+ if (result) {
218
+ // 播放选中的歌曲
219
+ await playerStore.playSong(song)
220
+ toastStore.success(`开始播放 "${song.name}"`)
221
+ } else {
222
+ toastStore.error('播放失败')
223
+ }
224
+ }
225
+
226
  const playSong = async (song, index) => {
227
  if (showBatchActions.value) {
228
  toggleItemSelection(song)
229
  return
230
  }
231
 
232
+ // 将所有收藏的歌曲添加到播放列表
233
  const songs = displayedFavorites.value.map(fav => fav.song)
234
  const result = playQueueStore.setQueue(songs, index)
235
 
 
368
  break
369
 
370
  case 'addToPlaylist':
371
+ // 添加到播放列表
372
  try {
373
  const result = playQueueStore.addToQueue(song, 'last')
374
  if (result.success) {
375
+ toastStore.success(`"${song.name}" 已添加到播放列表`)
376
  } else {
377
  toastStore.error(result.message || '添加失败')
378
  }
379
  } catch (error) {
380
+ console.error('添加到播放列表失败:', error)
381
+ toastStore.error('添加到播放列表失败')
382
  }
383
  break
384
 
 
720
  }
721
 
722
  .favorite-item:hover {
723
+ background: var(--bg-gradient-1);
724
  }
725
 
726
  .favorite-item.selected {
727
+ background: var(--bg-gradient-1);
728
  border-left: 4px solid var(--accent-red);
729
  }
730
 
src/views/FullPlayerPage.vue CHANGED
@@ -142,7 +142,7 @@
142
  </div>
143
  </div>
144
 
145
- <!-- 播放队列面板 -->
146
  <PlaylistPanel
147
  v-if="showPlaylist"
148
  @close="showPlaylist = false"
@@ -254,7 +254,7 @@ const isDesktop = computed(() => {
254
  })
255
 
256
  const defaultCover = computed(() => {
257
- return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjgwIiBoZWlnaHQ9IjI4MCIgdmlld0JveD0iMCAwIDI4MCAyODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyODAiIGhlaWdodD0iMjgwIiBmaWxsPSJyZ2JhKDIwMCwyMDAsMjAwLDAuMykiIHJ4PSI0MCIvPgo8Y2lyY2xlIGN4PSIxNDAiIGN5PSIxNDAiIHI9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiLz4KPHN2ZyB4PSIxMTAiIHk9IjExMCIgd2lkdGg9IjYwIiBoZWlnaHQ9IjYwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9InJnYmEoMTYwLDE2MCwxNjAsMC42KSI+CjxwYXRoIGQ9Ik0xMiAzdjEwLjU1Yy0uNTktLjM0LTEuMjctLjU1LTItLjU1QzcuNzkgMTMgNiAxNC43OSA2IDEdzEuNzkgNCA0IDRjMS45NCAwIDMuNS0xLjI5IDMuOTEtM0gxNFYzeiIvPgo8L3N2Zz4KPC9zdmc+'
258
  })
259
 
260
  const currentCover = computed(() => {
@@ -480,7 +480,7 @@ 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
  }
@@ -1139,11 +1139,11 @@ watch(currentSong, (newSong, oldSong) => {
1139
  }
1140
 
1141
  .quality-item:hover {
1142
- background: rgba(255, 255, 255, 0.05);
1143
  }
1144
 
1145
  .quality-item.active {
1146
- background: rgba(255, 107, 107, 0.1);
1147
  }
1148
 
1149
  .quality-item.loading {
 
142
  </div>
143
  </div>
144
 
145
+ <!-- 播放列表面板 -->
146
  <PlaylistPanel
147
  v-if="showPlaylist"
148
  @close="showPlaylist = false"
 
254
  })
255
 
256
  const defaultCover = computed(() => {
257
+ return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjgwIiBoZWlnaHQ9IjI4MCIgdmlld0JveD0iMCAwIDI4MCAyODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyODAiIGhlaWdodD0iMjgwIiBmaWxsPSJyZ2JhKDIwMCwyMDAsMjAwLDAuMykiIHJ4PSI0MCIvPgo8Y2lyY2xlIGN4PSIxNDAiIGN5PSIxNDAiIHI9IjgwIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiLz4KPHN2ZyB4PSIxMTAiIHk9IjExMCIgd2lkdGg9IjYwIiBoZWlnaHQ9IjYwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9InJnYmEoMTYwLDE2MCwxNjAsMC42KSI+CjxwYXRoIGQ9Ik0xMiAzdjEwLjU1Yy0uNTktLjM0LTEuMjctLjU1LTItLjU1QzcuNzkgMTMgNiAxNC43OSA2IDEdzMS43OSA0IDQgNEMxLjk0IDAIDMuNS0xLjI5IDMuOTEtM0gxNFYzeiIvPgo8L3N2Zz4KPC9zdmc+'
258
  })
259
 
260
  const currentCover = computed(() => {
 
480
  try {
481
  const result = playQueueStore.removeFromQueue(index)
482
  if (result.success) {
483
+ toastStore.success('已从播放列表移除')
484
  } else {
485
  toastStore.error(result.message)
486
  }
 
1139
  }
1140
 
1141
  .quality-item:hover {
1142
+ background: var(--overlay-lighter);
1143
  }
1144
 
1145
  .quality-item.active {
1146
+ background: var(--bg-gradient-3);
1147
  }
1148
 
1149
  .quality-item.loading {
src/views/HistoryPage.vue CHANGED
@@ -288,6 +288,7 @@ 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({
@@ -387,8 +388,48 @@ const formatDate = (dateString) => {
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) => {
@@ -770,7 +811,7 @@ onMounted(() => {
770
 
771
  .history-item:hover,
772
  .top-item:hover {
773
- background: var(--bg-hover);
774
  }
775
 
776
  .rank-badge {
 
288
  const showMoreActions = ref(false)
289
  const moreActionsRef = ref(null)
290
  const confirmDialogRef = ref(null)
291
+ const songCovers = ref(new Map()) // 存储异步加载的封面URL
292
 
293
  // 确认对话框状态
294
  const confirmDialog = ref({
 
388
  }
389
 
390
  const getSongCover = (song) => {
391
+ const songKey = `${song.source}-${song.pic_id || song.id}`
392
+
393
+ // 先返回缓存的URL或默认图片
394
+ const cached = songCovers.value.get(songKey)
395
+ if (cached) {
396
+ return cached
397
+ }
398
+
399
+ // 异步加载封面URL
400
+ loadSongCover(song)
401
+
402
+ // 返回默认图片或playerStore缓存
403
+ return playerStore.getCachedCover(song) || imageCacheManager.getDefaultImage()
404
+ }
405
+
406
+ const loadSongCover = async (song) => {
407
+ const songKey = `${song.source}-${song.pic_id || song.id}`
408
+
409
+ // 如果已经在加载中,跳过
410
+ if (songCovers.value.has(songKey)) {
411
+ return
412
+ }
413
+
414
+ // 设置默认值,防止重复加载
415
+ songCovers.value.set(songKey, imageCacheManager.getDefaultImage())
416
+
417
+ // 使用新的缓存机制
418
+ if (song.pic_id || song.id) {
419
+ try {
420
+ const { getCachedMusicPicUrlWithDelay } = await import('@/utils/musicPicCache.js')
421
+ const coverUrl = await getCachedMusicPicUrlWithDelay(
422
+ song.source,
423
+ song.pic_id || song.id,
424
+ 300
425
+ )
426
+ if (coverUrl) {
427
+ songCovers.value.set(songKey, coverUrl)
428
+ }
429
+ } catch (error) {
430
+ console.error('获取历史歌曲封面失败:', error)
431
+ }
432
+ }
433
  }
434
 
435
  const handleImageError = (event) => {
 
811
 
812
  .history-item:hover,
813
  .top-item:hover {
814
+ background: var(--bg-gradient-1);
815
  }
816
 
817
  .rank-badge {
src/views/HomePage.vue CHANGED
@@ -83,7 +83,7 @@ const handlePlay = async (song, index) => {
83
  // SOLID原则:单一职责 - playQueueStore负责队列管理,playerStore负责播放控制
84
  const searchResults = searchStore.searchResults || []
85
 
86
- // 将搜索结果设置为播放队列,从指定索引开始播放
87
  const result = playQueueStore.setQueue(searchResults, index)
88
 
89
  if (result) {
@@ -96,7 +96,7 @@ const handlePlay = async (song, index) => {
96
  // 用户反馈
97
  toastStore.success(`开始播放 "${song.name}"`)
98
  } else {
99
- throw new Error('设置播放队列失败')
100
  }
101
  } catch (error) {
102
  console.error('播放失败:', error)
 
83
  // SOLID原则:单一职责 - playQueueStore负责队列管理,playerStore负责播放控制
84
  const searchResults = searchStore.searchResults || []
85
 
86
+ // 将搜索结果设置为播放列表,从指定索引开始播放
87
  const result = playQueueStore.setQueue(searchResults, index)
88
 
89
  if (result) {
 
96
  // 用户反馈
97
  toastStore.success(`开始播放 "${song.name}"`)
98
  } else {
99
+ throw new Error('设置播放列表失败')
100
  }
101
  } catch (error) {
102
  console.error('播放失败:', error)
src/views/PlayQueuePage.vue CHANGED
@@ -7,25 +7,22 @@
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
  />
@@ -34,7 +31,29 @@
34
  </button>
35
  </div>
36
  </div>
37
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  <!-- 内容区域 -->
39
  <div class="content-area">
40
  <!-- 空状态 -->
@@ -47,70 +66,25 @@
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"
@@ -124,7 +98,7 @@
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'
@@ -133,6 +107,7 @@ 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()
@@ -143,6 +118,8 @@ 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)
@@ -183,11 +160,18 @@ 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] || '顺序播放'
@@ -269,7 +253,29 @@ const toggleFavorite = async (song) => {
269
  toastStore.success(message)
270
  }
271
 
 
 
 
 
 
 
272
  // 播放控制
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  const playAtIndex = async (index) => {
274
  if (searchKeyword.value.trim()) {
275
  // 如果在搜索状态,直接播放歌曲
@@ -278,7 +284,7 @@ const playAtIndex = async (index) => {
278
  toastStore.success(`开始播放 "${song.name}"`)
279
  return
280
  }
281
-
282
  // 正常队列播放
283
  const song = playQueueStore.playAtIndex(index)
284
  if (song) {
@@ -297,15 +303,26 @@ const 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 {
@@ -313,28 +330,80 @@ const removeFromQueue = (index) => {
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
  }
@@ -345,20 +414,25 @@ const startDrag = (index, event) => {
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()
@@ -392,7 +466,7 @@ onMounted(() => {
392
  }
393
 
394
  .page-title i {
395
- color: var(--accent-green);
396
  }
397
 
398
  .header-actions {
@@ -406,7 +480,7 @@ onMounted(() => {
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;
@@ -416,7 +490,7 @@ onMounted(() => {
416
  }
417
 
418
  .action-btn:hover {
419
- background: var(--accent-green-hover);
420
  transform: translateY(-1px);
421
  }
422
 
@@ -428,16 +502,6 @@ onMounted(() => {
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;
@@ -538,7 +602,7 @@ onMounted(() => {
538
 
539
  .current-status {
540
  font-size: 12px;
541
- color: var(--accent-green);
542
  margin: 0;
543
  font-weight: 600;
544
  }
@@ -551,7 +615,7 @@ onMounted(() => {
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;
@@ -563,7 +627,7 @@ onMounted(() => {
563
  }
564
 
565
  .playmode-btn:hover {
566
- background: rgba(255, 255, 255, 0.2);
567
  color: var(--text-primary);
568
  }
569
 
@@ -596,7 +660,7 @@ onMounted(() => {
596
  .stat-number {
597
  font-size: 18px;
598
  font-weight: 700;
599
- color: var(--accent-green);
600
  margin-bottom: 4px;
601
  }
602
 
@@ -647,7 +711,7 @@ onMounted(() => {
647
  }
648
 
649
  .clear-btn:hover {
650
- background: rgba(255, 255, 255, 0.1);
651
  color: var(--text-primary);
652
  }
653
 
@@ -689,7 +753,7 @@ onMounted(() => {
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;
@@ -699,15 +763,14 @@ onMounted(() => {
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;
@@ -716,166 +779,105 @@ onMounted(() => {
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
  /* 响应式 */
@@ -883,47 +885,47 @@ onMounted(() => {
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
  }
@@ -934,17 +936,17 @@ onMounted(() => {
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>
 
7
  播放列表
8
  </h1>
9
  <div class="header-actions">
10
+ <button v-if="!isEmpty" class="action-btn" @click="showBatchActions = !showBatchActions">
11
+ <i class="fas fa-tasks"></i>
12
  </button>
13
+ <button class="action-btn" @click="goToSettings">
 
 
 
14
  <i class="fas fa-cog"></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
  />
 
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 remove-btn" @click="removeSelected">
52
+ <i class="fas fa-trash"></i>
53
+ </button>
54
+ </div>
55
+ </div>
56
+
57
  <!-- 内容区域 -->
58
  <div class="content-area">
59
  <!-- 空状态 -->
 
66
  去发现音乐
67
  </button>
68
  </div>
69
+
70
  <!-- 队列列表 -->
71
  <div v-else class="queue-list">
72
+ <SongItem
73
+ v-for="(song, index) in displayedQueue"
74
  :key="`${song.id}-${index}`"
75
+ :song="song"
76
+ :index="index"
77
+ :showBatchActions="showBatchActions"
78
+ :isSelected="selectedItems.has(song.id)"
79
+ :show-remove="true"
80
+ @play="handlePlay"
81
+ @remove="removeFromQueue"
82
+ @toggleSelection="toggleItemSelection"
83
+ @showMoreActions="handleShowMoreActions"
84
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  </div>
86
  </div>
87
+
88
  <!-- 确认对话框 -->
89
  <ConfirmDialog
90
  ref="confirmDialogRef"
 
98
  </template>
99
 
100
  <script setup>
101
+ import { ref, computed, onMounted, watch } from 'vue'
102
  import { useRouter } from 'vue-router'
103
  import { usePlayQueueStore } from '@/stores/playqueue'
104
  import { useFavoritesStore } from '@/stores/favorites'
 
107
  import { useSongCoverLoader } from '@/composables/useSongCoverLoader'
108
  import { utils } from '@/services/musicApi'
109
  import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
110
+ import SongItem from '@/components/search/SongItem.vue'
111
 
112
  const router = useRouter()
113
  const playQueueStore = usePlayQueueStore()
 
118
 
119
  // 响应式数据
120
  const searchKeyword = ref('')
121
+ const showBatchActions = ref(false)
122
+ const selectedItems = ref(new Set())
123
  const confirmDialogRef = ref(null)
124
  const isDragging = ref(false)
125
  const dragStartIndex = ref(-1)
 
160
  return Math.max(0, queueLength.value - currentIndex.value - 1)
161
  })
162
 
163
+ // 批量选择相关
164
+ const selectedCount = computed(() => selectedItems.value.size)
165
+ const isAllSelected = computed(() => {
166
+ return displayedQueue.value.length > 0 &&
167
+ selectedCount.value === displayedQueue.value.length
168
+ })
169
+
170
  // 播放模式相关
171
  const playModeText = computed(() => {
172
  const modes = {
173
  'list': '顺序播放',
174
+ 'random': '随机播放',
175
  'single': '单曲循环'
176
  }
177
  return modes[playMode.value] || '顺序播放'
 
253
  toastStore.success(message)
254
  }
255
 
256
+ // 更多操作处理
257
+ const handleShowMoreActions = (song, event) => {
258
+ // 播放列表页面暂不实现更多操作菜单
259
+ console.log('更多操作:', song)
260
+ }
261
+
262
  // 播放控制
263
+ const handlePlay = async (song, index) => {
264
+ if (searchKeyword.value.trim()) {
265
+ // 如果在搜索状态,直接播放歌曲
266
+ await playerStore.playSong(song)
267
+ toastStore.success(`开始播放 "${song.name}"`)
268
+ return
269
+ }
270
+
271
+ // 正常队列播放
272
+ const result = playQueueStore.playAtIndex(index)
273
+ if (result) {
274
+ await playerStore.playSong(song, true) // restoreProgress = true
275
+ toastStore.success(`开始播放 "${song.name}"`)
276
+ }
277
+ }
278
+
279
  const playAtIndex = async (index) => {
280
  if (searchKeyword.value.trim()) {
281
  // 如果在搜索状态,直接播放歌曲
 
284
  toastStore.success(`开始播放 "${song.name}"`)
285
  return
286
  }
287
+
288
  // 正常队列播放
289
  const song = playQueueStore.playAtIndex(index)
290
  if (song) {
 
303
  toastStore.success('队列已随机排列')
304
  }
305
 
306
+ const playAll = async () => {
307
+ if (isEmpty.value) return
308
+
309
+ // 播放当前队列的第一首歌(从队列开头开始)
310
+ const result = playQueueStore.playAtIndex(0)
311
+ if (result) {
312
+ await playerStore.playSong(result, true)
313
+ toastStore.success(`开始播放播放列表`)
314
+ }
315
+ }
316
+
317
  const removeFromQueue = (index) => {
318
  if (searchKeyword.value.trim()) {
319
  toastStore.warning('搜索状态下无法删除,请清空搜索后操作')
320
  return
321
  }
322
+
323
  const song = queue.value[index]
324
  const result = playQueueStore.removeFromQueue(index)
325
+
326
  if (result.success) {
327
  toastStore.success(`"${song.name}" 已从列表移除`)
328
  } else {
 
330
  }
331
  }
332
 
333
+ // 批量操作
334
+ const toggleSelectAll = () => {
335
+ if (isAllSelected.value) {
336
+ selectedItems.value.clear()
337
+ } else {
338
+ selectedItems.value = new Set(displayedQueue.value.map(song => song.id))
339
+ }
340
+ }
341
+
342
+ const toggleItemSelection = (song) => {
343
+ if (selectedItems.value.has(song.id)) {
344
+ selectedItems.value.delete(song.id)
345
+ } else {
346
+ selectedItems.value.add(song.id)
347
+ }
348
+ }
349
+
350
+ const removeSelected = () => {
351
+ if (selectedCount.value === 0) return
352
+
353
  confirmDialog.value = {
354
+ title: '批量删除',
355
+ message: `确定要删除选中的 ${selectedCount.value} 首歌曲吗?`,
356
+ confirmText: '删除',
357
  type: 'warning',
358
+ action: 'remove-selected',
359
+ data: Array.from(selectedItems.value) // 现在存储的是歌曲 ID
360
  }
361
+
362
  confirmDialogRef.value?.show()
363
  }
364
 
365
+ const playSelected = async () => {
366
+ const selectedSongs = Array.from(selectedItems.value).map(songId =>
367
+ queue.value.find(song => song.id === songId)
368
+ ).filter(song => song)
369
+
370
+ if (selectedSongs.length === 0) return
371
+
372
+ // 播放选中的第一首歌
373
+ const result = playQueueStore.setQueue(selectedSongs, 0)
374
+ if (result && selectedSongs.length > 0) {
375
+ await playerStore.playSong(selectedSongs[0])
376
+ toastStore.success(`开始播放选中的 ${selectedSongs.length} 首歌曲`)
377
+ selectedItems.value.clear()
378
+ showBatchActions.value = false
379
+ } else {
380
+ toastStore.error('播放失败')
381
+ }
382
+ }
383
+
384
  // 确认对话框处理
385
  const handleConfirm = () => {
386
+ const { action, data } = confirmDialog.value
387
+
388
  switch (action) {
389
+ case 'remove-selected':
390
+ let removedCount = 0
391
+ // 根据歌曲 ID 找到对应的索引并删除
392
+ for (const songId of data) {
393
+ const index = queue.value.findIndex(song => song.id === songId)
394
+ if (index !== -1) {
395
+ const result = playQueueStore.removeFromQueue(index)
396
+ if (result.success) {
397
+ removedCount++
398
+ }
399
+ }
400
+ }
401
+
402
+ if (removedCount > 0) {
403
+ toastStore.success(`已删除 ${removedCount} 首歌曲`)
404
+ selectedItems.value.clear()
405
+ showBatchActions.value = false
406
+ }
407
  break
408
  }
409
  }
 
414
  toastStore.warning('搜索状态下无法拖拽排序')
415
  return
416
  }
417
+
418
  isDragging.value = true
419
  dragStartIndex.value = index
420
+
421
  // TODO: 实现完整的拖拽功能
422
  toastStore.info('拖拽排序功能开发中...')
423
  }
424
 
425
+ // 监听搜索关键词变化,清空选择
426
+ watch(searchKeyword, () => {
427
+ selectedItems.value.clear()
428
+ })
429
+
430
  // 生命周期
431
  onMounted(() => {
432
+ // 加载播放列表
433
  playQueueStore.loadQueue()
434
  favoritesStore.loadFavorites()
435
+
436
  // 初始化懒加载
437
  setTimeout(() => {
438
  initLazyImages()
 
466
  }
467
 
468
  .page-title i {
469
+ color: var(--accent-red);
470
  }
471
 
472
  .header-actions {
 
480
  gap: 6px;
481
  padding: 8px 16px;
482
  border: none;
483
+ background: var(--accent-red);
484
  color: white;
485
  border-radius: 20px;
486
  font-size: 14px;
 
490
  }
491
 
492
  .action-btn:hover {
493
+ background: var(--accent-red-hover);
494
  transform: translateY(-1px);
495
  }
496
 
 
502
  background: #ff6666;
503
  }
504
 
 
 
 
 
 
 
 
 
 
 
505
  .current-playing {
506
  display: flex;
507
  align-items: center;
 
602
 
603
  .current-status {
604
  font-size: 12px;
605
+ color: var(--accent-red);
606
  margin: 0;
607
  font-weight: 600;
608
  }
 
615
  width: 48px;
616
  height: 48px;
617
  border: none;
618
+ background: var(--bg-overlay);
619
  color: var(--text-secondary);
620
  border-radius: 50%;
621
  font-size: 16px;
 
627
  }
628
 
629
  .playmode-btn:hover {
630
+ background: var(--bg-gradient-1);
631
  color: var(--text-primary);
632
  }
633
 
 
660
  .stat-number {
661
  font-size: 18px;
662
  font-weight: 700;
663
+ color: var(--accent-red);
664
  margin-bottom: 4px;
665
  }
666
 
 
711
  }
712
 
713
  .clear-btn:hover {
714
+ background: var(--bg-gradient-1);
715
  color: var(--text-primary);
716
  }
717
 
 
753
  gap: 8px;
754
  padding: 12px 24px;
755
  border: none;
756
+ background: var(--accent-red);
757
  color: white;
758
  border-radius: 25px;
759
  font-size: 14px;
 
763
  }
764
 
765
  .discover-btn:hover {
766
+ background: var(--accent-red-hover);
767
  transform: scale(1.05);
768
  }
769
 
770
  .queue-list {
771
  padding-bottom: calc(var(--mini-player-height) + var(--tabbar-height) + 20px);
772
  }
773
+ .favorite-item {
 
774
  display: flex;
775
  align-items: center;
776
  padding: 12px 16px;
 
779
  transition: var(--transition-fast);
780
  }
781
 
782
+ .favorite-item:hover {
783
+ background: var(--bg-gradient-1);
784
  }
785
 
786
+ .favorite-item.selected {
787
+ background: var(--bg-gradient-1);
788
+ border-left: 4px solid var(--accent-red);
789
  }
790
+ .item-checkbox {
791
+ margin-right: 12px;
 
792
  }
793
 
794
+ .batch-actions {
 
 
795
  display: flex;
796
  align-items: center;
797
+ justify-content: space-between;
798
+ padding: 12px 16px;
799
+ background: var(--bg-card);
800
+ border-top: 1px solid var(--border-light);
801
+ border-bottom: 1px solid var(--border-light);
 
 
 
 
 
 
 
 
802
  }
803
 
804
+ .batch-selection {
805
  display: flex;
806
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
  }
808
 
809
+ .checkbox-wrapper {
 
 
 
 
 
 
810
  display: flex;
811
  align-items: center;
812
+ gap: 8px;
813
+ cursor: pointer;
 
814
  }
815
 
816
+ .checkbox-wrapper input {
817
+ display: none;
818
  }
819
 
820
+ .checkmark {
821
+ width: 18px;
822
+ height: 18px;
823
+ border: 2px solid var(--border-strong);
824
+ border-radius: 4px;
825
+ position: relative;
826
+ transition: var(--transition-fast);
827
  }
828
 
829
+ .checkbox-wrapper input:checked + .checkmark {
830
+ background: var(--accent-red);
831
+ border-color: var(--accent-red);
832
  }
833
 
834
+ .checkbox-wrapper input:checked + .checkmark::after {
835
+ content: '';
836
+ position: absolute;
837
+ left: 5px;
838
+ top: 2px;
839
+ width: 4px;
840
+ height: 8px;
841
+ border: solid white;
842
+ border-width: 0 2px 2px 0;
843
+ transform: rotate(45deg);
844
  }
845
 
846
+ .checkbox-label {
847
+ font-size: 14px;
848
  color: var(--text-secondary);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  }
850
 
851
+ .batch-buttons {
852
  display: flex;
853
  gap: 8px;
 
854
  }
855
 
856
+ .batch-btn {
 
 
 
 
 
 
857
  display: flex;
858
  align-items: center;
859
+ gap: 4px;
860
+ padding: 6px 12px;
861
+ border: none;
862
+ background: rgba(255, 255, 255, 0.1);
863
+ color: var(--text-primary);
864
+ border-radius: 16px;
865
+ font-size: 12px;
866
+ cursor: pointer;
867
+ transition: var(--transition-fast);
868
  }
869
 
870
+ .batch-btn:hover {
871
  background: rgba(255, 255, 255, 0.2);
 
 
872
  }
873
 
874
+ .batch-btn.remove-btn {
875
+ background: #ff4444;
876
+ color: white;
877
  }
878
 
879
+ .batch-btn.remove-btn:hover {
880
+ background: #ff6666;
 
881
  }
882
 
883
  /* 响应式 */
 
885
  .page-header {
886
  padding: 12px;
887
  }
888
+
889
  .page-title {
890
  font-size: 20px;
891
  }
892
+
893
  .current-playing {
894
  margin: 12px;
895
  padding: 16px 12px;
896
  }
897
+
898
  .current-cover {
899
  width: 50px;
900
  height: 50px;
901
  }
902
+
903
  .current-name {
904
  font-size: 16px;
905
  }
906
+
907
  .queue-stats {
908
  margin: 0 12px 12px;
909
  padding: 16px 12px;
910
  }
911
+
912
  .search-section {
913
  padding: 0 12px 12px;
914
  }
915
+
916
  .queue-item {
917
  padding: 12px;
918
  }
919
+
920
  .song-cover {
921
  width: 45px;
922
  height: 45px;
923
  }
924
+
925
  .song-name {
926
  font-size: 15px;
927
  }
928
+
929
  .song-meta {
930
  font-size: 12px;
931
  }
 
936
  max-width: 1200px;
937
  margin: 0 auto;
938
  }
939
+
940
  .current-playing,
941
  .queue-stats {
942
  max-width: 800px;
943
  margin-left: auto;
944
  margin-right: auto;
945
  }
946
+
947
  .queue-list {
948
  max-width: 800px;
949
  margin: 0 auto;
950
  }
951
  }
952
+ </style>
src/views/PlaylistDetailPage.vue CHANGED
@@ -626,11 +626,11 @@ watch(() => route.params.id, () => {
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
 
 
626
  }
627
 
628
  .song-item:hover {
629
+ background: var(--overlay-lighter);
630
  }
631
 
632
  .song-item.active {
633
+ background: var(--bg-gradient-3);
634
  color: var(--accent-red);
635
  }
636
 
src/views/PlaylistsPage.vue CHANGED
@@ -7,9 +7,8 @@
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>
@@ -129,7 +128,7 @@ const customPlaylists = computed(() => playlistStore.playlists || [])
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])
@@ -220,12 +219,7 @@ onMounted(async () => {
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 {
@@ -257,12 +251,14 @@ onMounted(async () => {
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 {
@@ -511,7 +507,7 @@ onMounted(async () => {
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);
 
7
  我的歌单
8
  </h1>
9
  <div class="header-actions">
10
+ <button class="action-btn" @click="openCreatePlaylistDialog">
11
  <i class="fas fa-plus"></i>
 
12
  </button>
13
  </div>
14
  </div>
 
128
  const playPlaylist = async (playlist) => {
129
  if (playlist.songs.length === 0) return
130
 
131
+ // 用歌单替换播放列表并开始播放
132
  const result = playQueueStore.setQueue(playlist.songs, 0)
133
  if (result && playlist.songs.length > 0) {
134
  await playerStore.playSong(playlist.songs[0])
 
219
  align-items: center;
220
  justify-content: space-between;
221
  padding: 16px;
222
+ border-bottom: 1px solid var(--border-lighter);
 
 
 
 
 
223
  }
224
 
225
  .page-title {
 
251
  color: white;
252
  border-radius: 20px;
253
  font-size: 14px;
254
+ font-weight: 500;
255
  cursor: pointer;
256
  transition: var(--transition-fast);
257
  }
258
 
259
+ .action-btn:hover {
260
  background: var(--accent-red-hover);
261
+ transform: translateY(-1px);
262
  }
263
 
264
  .playlists-content {
 
507
  padding: 12px 16px;
508
  border: 2px solid var(--border-card);
509
  border-radius: 8px;
510
+ background: var(--overlay-lighter);
511
  color: var(--text-primary);
512
  font-size: 14px;
513
  transition: var(--transition-fast);
src/views/SettingsPage.vue CHANGED
@@ -340,8 +340,8 @@
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>
@@ -661,7 +661,7 @@ const clearAllData = async () => {
661
  // 播放列表 - 新的统一key
662
  'vue-music-playlists',
663
 
664
- // 播放队列 - 新的统一key
665
  'vue-music-play-queue',
666
 
667
  // 播放器状态
@@ -679,7 +679,7 @@ const clearAllData = async () => {
679
  favoritesStore.clearFavorites()
680
  ])
681
 
682
- // 清除播放列表和播放队列
683
  const { usePlaylistStore } = await import('@/stores/playlist')
684
  const { usePlayQueueStore } = await import('@/stores/playqueue')
685
  const playlistStore = usePlaylistStore()
@@ -980,7 +980,7 @@ input:checked + .slider:before {
980
 
981
  .mobile-select-btn:hover {
982
  border-color: var(--border-light);
983
- background: rgba(255, 255, 255, 0.05);
984
  }
985
 
986
  .mobile-select-btn i {
@@ -1022,7 +1022,7 @@ input:checked + .slider:before {
1022
  align-items: center;
1023
  padding: 20px 24px;
1024
  border-bottom: 1px solid var(--border-strong);
1025
- background: rgba(255, 255, 255, 0.05);
1026
  }
1027
 
1028
  .selector-header h3 {
@@ -1073,12 +1073,12 @@ input:checked + .slider:before {
1073
  }
1074
 
1075
  .option-item:hover {
1076
- background: rgba(255, 255, 255, 0.05);
1077
  border-bottom: 1px solid var(--border-light);
1078
  }
1079
 
1080
  .option-item.active {
1081
- background: rgba(255, 107, 107, 0.1);
1082
  color: var(--accent-red);
1083
  border-bottom: 1px solid var(--accent-red);
1084
  }
 
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>
 
661
  // 播放列表 - 新的统一key
662
  'vue-music-playlists',
663
 
664
+ // 播放列表 - 新的统一key
665
  'vue-music-play-queue',
666
 
667
  // 播放器状态
 
679
  favoritesStore.clearFavorites()
680
  ])
681
 
682
+ // 清除播放列表和播放列表
683
  const { usePlaylistStore } = await import('@/stores/playlist')
684
  const { usePlayQueueStore } = await import('@/stores/playqueue')
685
  const playlistStore = usePlaylistStore()
 
980
 
981
  .mobile-select-btn:hover {
982
  border-color: var(--border-light);
983
+ background: var(--overlay-lighter);
984
  }
985
 
986
  .mobile-select-btn i {
 
1022
  align-items: center;
1023
  padding: 20px 24px;
1024
  border-bottom: 1px solid var(--border-strong);
1025
+ background: var(--overlay-lighter);
1026
  }
1027
 
1028
  .selector-header h3 {
 
1073
  }
1074
 
1075
  .option-item:hover {
1076
+ background: var(--overlay-lighter);
1077
  border-bottom: 1px solid var(--border-light);
1078
  }
1079
 
1080
  .option-item.active {
1081
+ background: var(--bg-gradient-3);
1082
  color: var(--accent-red);
1083
  border-bottom: 1px solid var(--accent-red);
1084
  }
网易云音乐功能架构研究报告.md DELETED
@@ -1,106 +0,0 @@
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. 最后小幅修改搜索和播放页面