feat(components): 优化多个组件的样式和功能
Browse files- 调整了多个组件的背景色、文字颜色等样式
- 优化了部分组件的布局结构
- 改进了搜索历史、播放列表等组件的交互逻辑
- 统一了部分UI元素的样式
- index.html +1 -0
- src/components/favorites/FavoriteItem.vue +22 -9
- src/components/favorites/FavoritesList.vue +59 -59
- src/components/layout/SearchHeader.vue +4 -5
- src/components/player/MoreActionsPanel.vue +32 -32
- src/components/player/PlayControls.vue +1 -1
- src/components/player/PlaylistPanel.vue +24 -248
- src/components/playlist/PlaylistSelector.vue +36 -36
- src/components/search/SearchBox.vue +7 -13
- src/components/search/SearchHistory.vue +3 -3
- src/components/search/SearchResults.vue +61 -30
- src/components/search/SongItem.vue +342 -213
- src/components/search/SourceSelector.vue +3 -3
- src/composables/useSongCoverLoader.js +23 -3
- src/router/index.js +3 -3
- src/services/musicApi.js +6 -11
- src/stores/player.js +1 -1
- src/stores/playlist.js +25 -7
- src/stores/playqueue.js +7 -7
- src/styles/global.css +2 -0
- src/utils/musicPicCache.js +174 -0
- src/utils/urlCache.js +191 -0
- src/views/CurrentPlaylistPage.vue +8 -8
- src/views/FavoritesPage.vue +36 -55
- src/views/FullPlayerPage.vue +5 -5
- src/views/HistoryPage.vue +44 -3
- src/views/HomePage.vue +2 -2
- src/views/PlayQueuePage.vue +248 -246
- src/views/PlaylistDetailPage.vue +2 -2
- src/views/PlaylistsPage.vue +7 -11
- src/views/SettingsPage.vue +8 -8
- 网易云音乐功能架构研究报告.md +0 -106
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="
|
| 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 |
-
//
|
| 126 |
-
const
|
|
|
|
| 127 |
if (props.song.pic_id) {
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
})
|
| 132 |
|
| 133 |
// 是否是当前播放歌曲
|
|
@@ -285,17 +298,17 @@ document.addEventListener('click', () => {
|
|
| 285 |
}
|
| 286 |
|
| 287 |
.favorite-item:hover {
|
| 288 |
-
background:
|
| 289 |
border-bottom: 1px solid var(--border-light);
|
| 290 |
}
|
| 291 |
|
| 292 |
.favorite-item.playing {
|
| 293 |
-
background:
|
| 294 |
border-bottom: 1px solid var(--accent-red);
|
| 295 |
}
|
| 296 |
|
| 297 |
.favorite-item.selected {
|
| 298 |
-
background:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 469 |
color: var(--text-primary);
|
| 470 |
}
|
| 471 |
|
|
@@ -506,7 +506,7 @@ defineExpose({
|
|
| 506 |
|
| 507 |
.suggestion-item:hover,
|
| 508 |
.suggestion-item.active {
|
| 509 |
-
background:
|
| 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
|
| 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('
|
| 134 |
// 关闭当前面板
|
| 135 |
emit('close')
|
| 136 |
}
|
|
@@ -228,7 +228,7 @@ const handleAddedToPlaylist = (data) => {
|
|
| 228 |
}
|
| 229 |
|
| 230 |
.action-item:hover {
|
| 231 |
-
background:
|
| 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
|
| 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
|
| 57 |
<button class="add-music-btn" @click="$emit('close')">
|
| 58 |
去添加音乐
|
| 59 |
</button>
|
|
@@ -65,52 +40,17 @@
|
|
| 65 |
<span>下一首播放</span>
|
| 66 |
</div>
|
| 67 |
|
| 68 |
-
<
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 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(() =>
|
| 149 |
-
const currentSong = computed(() =>
|
| 150 |
-
const currentIndex = computed(() =>
|
| 151 |
const isPlaying = computed(() => playerStore.isPlaying)
|
| 152 |
-
const playMode = computed(() =>
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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"
|
| 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:
|
| 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:
|
| 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:
|
| 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 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 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:
|
| 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:
|
| 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
|
| 342 |
}
|
| 343 |
|
| 344 |
.expand-btn:hover {
|
| 345 |
color: var(--text-primary);
|
| 346 |
-
background:
|
| 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="
|
| 39 |
<SongItem
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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
|
| 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 (!
|
| 160 |
|
| 161 |
// 监听滚动事件
|
| 162 |
const handleScroll = () => {
|
| 163 |
-
const container =
|
| 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 |
-
|
| 190 |
|
| 191 |
// 清理函数
|
| 192 |
return () => {
|
| 193 |
-
if (
|
| 194 |
-
|
| 195 |
}
|
| 196 |
if (scrollTimer) clearTimeout(scrollTimer)
|
| 197 |
}
|
|
@@ -200,7 +241,7 @@ const setupInfiniteScroll = () => {
|
|
| 200 |
let cleanupScrollListener = null
|
| 201 |
|
| 202 |
// 监听歌曲容器元素
|
| 203 |
-
watch(
|
| 204 |
// 清理之前的监听器
|
| 205 |
if (cleanupScrollListener) {
|
| 206 |
cleanupScrollListener()
|
|
@@ -214,7 +255,7 @@ watch(songsContainer, (newContainer) => {
|
|
| 214 |
|
| 215 |
// 监听搜索结果变化,重新设置滚动监听
|
| 216 |
watch(results, () => {
|
| 217 |
-
if (
|
| 218 |
// 延迟设置,确保DOM已更新
|
| 219 |
setTimeout(() => {
|
| 220 |
if (cleanupScrollListener) {
|
|
@@ -226,7 +267,7 @@ watch(results, () => {
|
|
| 226 |
})
|
| 227 |
|
| 228 |
onMounted(() => {
|
| 229 |
-
if (
|
| 230 |
cleanupScrollListener = setupInfiniteScroll()
|
| 231 |
}
|
| 232 |
})
|
|
@@ -382,13 +423,7 @@ onUnmounted(() => {
|
|
| 382 |
font-weight: 500;
|
| 383 |
}
|
| 384 |
|
| 385 |
-
.
|
| 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="
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class="
|
| 15 |
-
|
| 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="
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
-
|
| 38 |
-
<!--
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
</div>
|
| 46 |
</template>
|
| 47 |
|
| 48 |
<script setup>
|
| 49 |
-
import { computed,
|
| 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
|
| 75 |
-
const
|
| 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 |
-
|
| 107 |
-
showMenu.value = !showMenu.value
|
| 108 |
}
|
| 109 |
|
| 110 |
-
//
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
showMenu.value = false
|
| 124 |
-
showPlaylistSelector.value = true
|
| 125 |
}
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
}
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
}
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
navigator.clipboard.writeText(url).then(() => {
|
| 141 |
-
toastStore.success('链接已复制到剪贴板')
|
| 142 |
-
}).catch(() => {
|
| 143 |
-
toastStore.error('复制失败,请重试')
|
| 144 |
-
})
|
| 145 |
-
showMenu.value = false
|
| 146 |
}
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
})
|
| 152 |
-
</script>
|
| 153 |
|
| 154 |
-
|
| 155 |
-
.song-item {
|
| 156 |
display: flex;
|
| 157 |
align-items: center;
|
| 158 |
-
|
| 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 |
-
.
|
| 169 |
-
|
| 170 |
-
border-bottom: 1px solid var(--border-light);
|
| 171 |
}
|
| 172 |
|
| 173 |
-
.
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
border
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
|
| 179 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
content: '';
|
| 181 |
position: absolute;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
left: 0;
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
-
.song-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
display: flex;
|
| 197 |
align-items: center;
|
| 198 |
justify-content: center;
|
| 199 |
-
|
| 200 |
-
font-weight: 600;
|
| 201 |
-
color: var(--text-secondary);
|
| 202 |
-
margin-right: 12px;
|
| 203 |
-
flex-shrink: 0;
|
| 204 |
}
|
| 205 |
|
| 206 |
-
.
|
| 207 |
-
background: var(--accent-red);
|
| 208 |
color: white;
|
|
|
|
| 209 |
}
|
| 210 |
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
flex: 1;
|
| 213 |
min-width: 0;
|
| 214 |
-
margin-right: 12px;
|
| 215 |
}
|
| 216 |
|
| 217 |
.song-name {
|
| 218 |
-
font-size:
|
| 219 |
-
font-weight:
|
| 220 |
color: var(--text-primary);
|
| 221 |
-
margin
|
| 222 |
-
white-space: nowrap;
|
| 223 |
overflow: hidden;
|
| 224 |
text-overflow: ellipsis;
|
|
|
|
| 225 |
}
|
| 226 |
|
| 227 |
-
.song-
|
| 228 |
-
color: var(--accent-red);
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
.song-artist {
|
| 232 |
font-size: 13px;
|
| 233 |
color: var(--text-secondary);
|
| 234 |
-
|
| 235 |
overflow: hidden;
|
| 236 |
text-overflow: ellipsis;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
|
| 239 |
.song-actions {
|
| 240 |
display: flex;
|
| 241 |
-
gap:
|
| 242 |
-
flex-shrink: 0;
|
| 243 |
}
|
| 244 |
|
| 245 |
-
.action-btn {
|
| 246 |
-
width:
|
| 247 |
-
height:
|
|
|
|
| 248 |
border-radius: 50%;
|
| 249 |
-
background:
|
| 250 |
-
|
| 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.
|
| 262 |
-
color: var(--text-
|
| 263 |
}
|
| 264 |
|
| 265 |
.favorite-btn.active {
|
|
|
|
| 266 |
color: var(--accent-red);
|
| 267 |
}
|
| 268 |
|
| 269 |
.favorite-btn.active:hover {
|
| 270 |
-
|
| 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 |
-
.
|
| 314 |
-
padding:
|
| 315 |
}
|
| 316 |
-
|
| 317 |
-
.song-
|
| 318 |
-
width:
|
| 319 |
-
height:
|
| 320 |
-
font-size: 11px;
|
| 321 |
-
margin-right: 10px;
|
| 322 |
}
|
| 323 |
-
|
| 324 |
-
.
|
| 325 |
-
|
| 326 |
-
height: 28px;
|
| 327 |
-
font-size: 12px;
|
| 328 |
}
|
| 329 |
-
|
| 330 |
-
.song-
|
| 331 |
-
|
| 332 |
}
|
| 333 |
-
|
| 334 |
-
.song-
|
| 335 |
-
|
|
|
|
|
|
|
| 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:
|
| 100 |
}
|
| 101 |
|
| 102 |
.selector-header h3 {
|
|
@@ -147,12 +147,12 @@ const selectSource = (sourceCode) => {
|
|
| 147 |
}
|
| 148 |
|
| 149 |
.source-item:hover {
|
| 150 |
-
background:
|
| 151 |
border-bottom: 1px solid var(--border-light);
|
| 152 |
}
|
| 153 |
|
| 154 |
.source-item.active {
|
| 155 |
-
background:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 64 |
}
|
| 65 |
},
|
| 66 |
{
|
|
@@ -78,7 +78,7 @@ const routes = [
|
|
| 78 |
component: PlaylistDetailPage,
|
| 79 |
meta: {
|
| 80 |
title: '歌单详情',
|
| 81 |
-
keepAlive:
|
| 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
|
| 161 |
-
types: 'pic',
|
| 162 |
-
source: source,
|
| 163 |
-
id: picId,
|
| 164 |
-
size: size
|
| 165 |
-
}
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
| 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:
|
| 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('
|
| 287 |
}
|
| 288 |
}
|
| 289 |
|
|
@@ -301,7 +301,7 @@ export const usePlayQueueStore = defineStore('playqueue', () => {
|
|
| 301 |
}
|
| 302 |
}
|
| 303 |
} catch (error) {
|
| 304 |
-
console.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:
|
| 559 |
}
|
| 560 |
|
| 561 |
.song-item.current {
|
| 562 |
-
background:
|
| 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 |
-
<
|
| 74 |
v-for="(favorite, index) in displayedFavorites"
|
| 75 |
:key="`${favorite.song.id}-${favorite.favoriteTime}`"
|
| 76 |
-
|
| 77 |
-
:
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 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('
|
| 400 |
-
toastStore.error('
|
| 401 |
}
|
| 402 |
break
|
| 403 |
|
|
@@ -739,11 +720,11 @@ onMounted(async () => {
|
|
| 739 |
}
|
| 740 |
|
| 741 |
.favorite-item:hover {
|
| 742 |
-
background: var(--bg-
|
| 743 |
}
|
| 744 |
|
| 745 |
.favorite-item.selected {
|
| 746 |
-
background:
|
| 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+
|
| 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:
|
| 1143 |
}
|
| 1144 |
|
| 1145 |
.quality-item.active {
|
| 1146 |
-
background:
|
| 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 |
-
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
}
|
| 393 |
|
| 394 |
const handleImageError = (event) => {
|
|
@@ -770,7 +811,7 @@ onMounted(() => {
|
|
| 770 |
|
| 771 |
.history-item:hover,
|
| 772 |
.top-item:hover {
|
| 773 |
-
background: var(--bg-
|
| 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="
|
| 11 |
-
<i class="fas fa-
|
| 12 |
</button>
|
| 13 |
-
<button
|
| 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 |
-
<
|
| 54 |
-
v-for="(song, index) in displayedQueue"
|
| 55 |
:key="`${song.id}-${index}`"
|
| 56 |
-
|
| 57 |
-
:
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
confirmDialog.value = {
|
| 318 |
-
title: '
|
| 319 |
-
message:
|
| 320 |
-
confirmText: '
|
| 321 |
type: 'warning',
|
| 322 |
-
action: '
|
| 323 |
-
data:
|
| 324 |
}
|
| 325 |
-
|
| 326 |
confirmDialogRef.value?.show()
|
| 327 |
}
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
// 确认对话框处理
|
| 330 |
const handleConfirm = () => {
|
| 331 |
-
const { action } = confirmDialog.value
|
| 332 |
-
|
| 333 |
switch (action) {
|
| 334 |
-
case '
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 396 |
}
|
| 397 |
|
| 398 |
.header-actions {
|
|
@@ -406,7 +480,7 @@ onMounted(() => {
|
|
| 406 |
gap: 6px;
|
| 407 |
padding: 8px 16px;
|
| 408 |
border: none;
|
| 409 |
-
background: var(--accent-
|
| 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-
|
| 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-
|
| 542 |
margin: 0;
|
| 543 |
font-weight: 600;
|
| 544 |
}
|
|
@@ -551,7 +615,7 @@ onMounted(() => {
|
|
| 551 |
width: 48px;
|
| 552 |
height: 48px;
|
| 553 |
border: none;
|
| 554 |
-
background:
|
| 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:
|
| 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-
|
| 600 |
margin-bottom: 4px;
|
| 601 |
}
|
| 602 |
|
|
@@ -647,7 +711,7 @@ onMounted(() => {
|
|
| 647 |
}
|
| 648 |
|
| 649 |
.clear-btn:hover {
|
| 650 |
-
background:
|
| 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-
|
| 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-
|
| 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 |
-
.
|
| 720 |
-
background: var(--bg-
|
| 721 |
}
|
| 722 |
|
| 723 |
-
.
|
| 724 |
-
background:
|
| 725 |
-
border-left: 4px solid var(--accent-
|
| 726 |
}
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
opacity: 0.6;
|
| 730 |
}
|
| 731 |
|
| 732 |
-
.
|
| 733 |
-
width: 24px;
|
| 734 |
-
height: 24px;
|
| 735 |
display: flex;
|
| 736 |
align-items: center;
|
| 737 |
-
justify-content:
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
}
|
| 743 |
-
|
| 744 |
-
.drag-handle:active {
|
| 745 |
-
cursor: grabbing;
|
| 746 |
-
}
|
| 747 |
-
|
| 748 |
-
.drag-handle:hover {
|
| 749 |
-
color: var(--text-secondary);
|
| 750 |
}
|
| 751 |
|
| 752 |
-
.
|
| 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 |
-
.
|
| 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 |
-
|
| 786 |
-
|
| 787 |
-
transition: var(--transition-fast);
|
| 788 |
}
|
| 789 |
|
| 790 |
-
.
|
| 791 |
-
|
| 792 |
}
|
| 793 |
|
| 794 |
-
.
|
| 795 |
-
|
| 796 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
}
|
| 798 |
|
| 799 |
-
.
|
| 800 |
-
|
| 801 |
-
|
| 802 |
}
|
| 803 |
|
| 804 |
-
.
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
|
|
|
|
|
|
| 812 |
}
|
| 813 |
|
| 814 |
-
.
|
| 815 |
-
font-size:
|
| 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 |
-
.
|
| 847 |
display: flex;
|
| 848 |
gap: 8px;
|
| 849 |
-
flex-shrink: 0;
|
| 850 |
}
|
| 851 |
|
| 852 |
-
.
|
| 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 |
-
|
| 862 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
}
|
| 864 |
|
| 865 |
-
.
|
| 866 |
background: rgba(255, 255, 255, 0.2);
|
| 867 |
-
color: var(--text-primary);
|
| 868 |
-
transform: none;
|
| 869 |
}
|
| 870 |
|
| 871 |
-
.
|
| 872 |
-
background:
|
| 873 |
-
color:
|
| 874 |
}
|
| 875 |
|
| 876 |
-
.
|
| 877 |
-
background:
|
| 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:
|
| 630 |
}
|
| 631 |
|
| 632 |
.song-item.active {
|
| 633 |
-
background:
|
| 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
|
| 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-
|
| 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
|
| 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:
|
| 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
|
| 344 |
-
<p class="setting-desc"
|
| 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 |
-
//
|
| 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:
|
| 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:
|
| 1026 |
}
|
| 1027 |
|
| 1028 |
.selector-header h3 {
|
|
@@ -1073,12 +1073,12 @@ input:checked + .slider:before {
|
|
| 1073 |
}
|
| 1074 |
|
| 1075 |
.option-item:hover {
|
| 1076 |
-
background:
|
| 1077 |
border-bottom: 1px solid var(--border-light);
|
| 1078 |
}
|
| 1079 |
|
| 1080 |
.option-item.active {
|
| 1081 |
-
background:
|
| 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. 最后小幅修改搜索和播放页面
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|