2025-05-07 22:36:52 +08:00
|
|
|
|
<template>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
|
2026-03-15 14:11:59 +08:00
|
|
|
|
<n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleScroll">
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<div class="music-list-content pb-32">
|
|
|
|
|
|
<!-- Hero Section 和 Action Bar -->
|
|
|
|
|
|
<n-spin :show="loading">
|
|
|
|
|
|
<!-- Hero Section -->
|
|
|
|
|
|
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
|
|
|
|
|
|
<!-- Background Image with Blur -->
|
|
|
|
|
|
<div class="hero-bg absolute inset-0 -top-20">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute inset-0 bg-cover bg-center scale-110 blur-3xl opacity-40 dark:opacity-30"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
backgroundImage: `url(${getImgUrl(getCoverImgUrl, '800y800')})`
|
|
|
|
|
|
}"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white dark:via-black/80 dark:to-black"
|
|
|
|
|
|
></div>
|
2025-05-15 21:20:01 +08:00
|
|
|
|
</div>
|
2025-06-04 20:19:44 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<!-- Hero Content -->
|
2026-02-08 01:39:20 +08:00
|
|
|
|
<div class="hero-content relative z-10 page-padding-x pt-4 md:pt-10 pb-8">
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<div class="flex flex-col md:flex-row gap-8 md:gap-12 items-center md:items-end">
|
|
|
|
|
|
<!-- Playlist Cover -->
|
|
|
|
|
|
<div class="cover-wrapper relative group">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="cover-glow absolute -inset-2 rounded-2xl bg-gradient-to-br from-primary/30 via-primary/10 to-transparent blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="cover-container relative w-48 h-48 md:w-64 md:h-64 rounded-2xl overflow-hidden shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<n-image
|
|
|
|
|
|
:src="getImgUrl(getCoverImgUrl, '500y500')"
|
|
|
|
|
|
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
|
|
|
|
preview-disabled
|
|
|
|
|
|
/>
|
|
|
|
|
|
<!-- Play overlay on cover -->
|
|
|
|
|
|
<div
|
2026-02-06 12:50:58 +08:00
|
|
|
|
class="absolute inset-0 flex items-center justify-center bg-transparent group-hover:bg-black/30 transition-all duration-300"
|
|
|
|
|
|
:class="isMobile ? 'pointer-events-none' : 'cursor-pointer'"
|
|
|
|
|
|
@click="!isMobile && handlePlayAll()"
|
2026-02-04 20:16:52 +08:00
|
|
|
|
>
|
2026-02-06 12:50:58 +08:00
|
|
|
|
<button
|
|
|
|
|
|
v-if="!isMobile"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="play-icon w-16 h-16 rounded-full bg-white/90 flex items-center justify-center opacity-0 scale-75 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 shadow-xl hover:scale-110 active:scale-95 pointer-events-auto"
|
|
|
|
|
|
@click.stop="handlePlayAll"
|
2026-02-04 20:16:52 +08:00
|
|
|
|
>
|
|
|
|
|
|
<i class="ri-play-fill text-3xl text-neutral-900 ml-1" />
|
2026-02-06 12:50:58 +08:00
|
|
|
|
</button>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-06-04 20:19:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<!-- Playlist Info -->
|
|
|
|
|
|
<div class="playlist-info flex-1 text-center md:text-left">
|
|
|
|
|
|
<div class="playlist-badge mb-3">
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ isAlbum ? 'Album' : 'Playlist' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h1
|
2026-03-15 14:11:59 +08:00
|
|
|
|
ref="titleElRef"
|
2026-02-04 20:16:52 +08:00
|
|
|
|
class="playlist-name text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight mb-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ name }}
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Meta Info -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="flex flex-wrap items-center justify-center md:justify-start gap-4 mb-6"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="isAlbum && listInfo?.artist" class="flex items-center gap-2">
|
|
|
|
|
|
<n-avatar
|
|
|
|
|
|
round
|
|
|
|
|
|
:size="28"
|
|
|
|
|
|
:src="getImgUrl(listInfo.artist.picUrl, '50y50')"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="text-sm font-semibold text-neutral-700 dark:text-neutral-200 hover:text-primary cursor-pointer transition-colors"
|
|
|
|
|
|
>{{ listInfo.artist.name }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="!isAlbum && listInfo?.creator" class="flex items-center gap-2">
|
|
|
|
|
|
<n-avatar
|
|
|
|
|
|
round
|
|
|
|
|
|
:size="28"
|
|
|
|
|
|
:src="getImgUrl(listInfo.creator.avatarUrl, '50y50')"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span class="text-sm font-semibold text-neutral-700 dark:text-neutral-200">{{
|
|
|
|
|
|
listInfo.creator.nickname
|
|
|
|
|
|
}}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="h-1 w-1 rounded-full bg-neutral-300 dark:bg-neutral-700"></div>
|
|
|
|
|
|
<span class="text-sm text-neutral-500 dark:text-neutral-400">
|
|
|
|
|
|
{{ t('player.songNum', { num: total }) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-05-15 21:20:01 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<p
|
|
|
|
|
|
v-if="listInfo?.description"
|
|
|
|
|
|
class="text-sm md:text-base text-neutral-500 dark:text-neutral-400 line-clamp-2 leading-relaxed max-w-3xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ listInfo.description }}
|
|
|
|
|
|
</p>
|
2025-05-15 21:20:01 +08:00
|
|
|
|
</div>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</n-spin>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Action Bar (Sticky) -->
|
|
|
|
|
|
<section
|
|
|
|
|
|
v-if="songList.length > 0"
|
2026-02-08 01:39:20 +08:00
|
|
|
|
class="action-bar sticky top-0 z-20 page-padding-x py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
|
2026-02-04 20:16:52 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-center justify-between gap-4">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<!-- Play All Button -->
|
|
|
|
|
|
<button
|
2026-03-22 16:47:48 +08:00
|
|
|
|
class="play-all-btn flex items-center gap-1.5 md:gap-2 px-3.5 md:px-6 py-1.5 md:py-2.5 rounded-full bg-primary hover:bg-primary/90 text-white font-semibold text-xs md:text-sm transition-all duration-200 hover:scale-105 active:scale-95 shadow-lg shadow-primary/25"
|
2026-02-04 20:16:52 +08:00
|
|
|
|
@click="handlePlayAll"
|
|
|
|
|
|
>
|
2026-03-22 16:47:48 +08:00
|
|
|
|
<i class="ri-play-circle-line text-base md:text-lg" />
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<span>{{ t('comp.musicList.playAll') }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Collect Button -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="canCollect"
|
2026-03-22 16:47:48 +08:00
|
|
|
|
class="action-btn-pill flex items-center gap-1.5 md:gap-2 px-3.5 md:px-6 py-1.5 md:py-2.5 rounded-full font-semibold text-xs md:text-sm transition-all duration-200 hover:scale-105 active:scale-95 shadow-sm border"
|
2026-02-04 20:16:52 +08:00
|
|
|
|
:class="
|
|
|
|
|
|
isCollected
|
|
|
|
|
|
? 'bg-neutral-100 dark:bg-neutral-800 text-red-500 border-neutral-200 dark:border-neutral-700'
|
|
|
|
|
|
: 'bg-neutral-50 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 border-neutral-200 dark:border-neutral-800'
|
|
|
|
|
|
"
|
|
|
|
|
|
@click="toggleCollect"
|
|
|
|
|
|
>
|
2026-03-22 16:47:48 +08:00
|
|
|
|
<i
|
|
|
|
|
|
:class="isCollected ? 'ri-heart-fill' : 'ri-heart-line'"
|
|
|
|
|
|
class="text-base md:text-lg"
|
|
|
|
|
|
/>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<span>{{
|
|
|
|
|
|
isCollected ? t('comp.musicList.cancelCollect') : t('comp.musicList.collect')
|
|
|
|
|
|
}}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Batch Actions -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="filteredSongs.length > 0 && isElectron"
|
|
|
|
|
|
class="h-8 w-[1px] bg-neutral-200 dark:bg-neutral-800 mx-1 hidden md:block"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="!isSelecting && isElectron"
|
|
|
|
|
|
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
|
|
|
|
|
@click="startSelect"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="ri-checkbox-multiple-line text-lg" />
|
|
|
|
|
|
</button>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="isSelecting"
|
|
|
|
|
|
class="flex items-center gap-2 animate-in fade-in slide-in-from-left-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<n-checkbox
|
|
|
|
|
|
:checked="isAllSelected"
|
|
|
|
|
|
:indeterminate="isIndeterminate"
|
|
|
|
|
|
@update:checked="handleSelectAll"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ t('common.selectAll') }}
|
|
|
|
|
|
</n-checkbox>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="px-4 py-1.5 rounded-full bg-primary/10 text-primary text-xs font-bold hover:bg-primary/20 transition-all"
|
|
|
|
|
|
:disabled="selectedSongs.length === 0 || isDownloading"
|
|
|
|
|
|
@click="handleBatchDownload"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="ri-download-line mr-1" />
|
|
|
|
|
|
{{ t('favorite.download', { count: selectedSongs.length }) }}
|
|
|
|
|
|
</button>
|
2026-02-06 20:35:04 +08:00
|
|
|
|
<button
|
|
|
|
|
|
class="px-4 py-1.5 rounded-full bg-primary/10 text-primary text-xs font-bold hover:bg-primary/20 transition-all"
|
|
|
|
|
|
:disabled="selectedSongs.length === 0"
|
|
|
|
|
|
@click="handleAddToPlaylist"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="ri-play-list-add-line mr-1" />
|
|
|
|
|
|
{{ t('comp.musicList.addToPlaylist') }}
|
|
|
|
|
|
</button>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<button
|
|
|
|
|
|
class="text-xs text-neutral-400 hover:text-neutral-600"
|
|
|
|
|
|
@click="cancelSelect"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ t('common.cancel') }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-22 21:52:22 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<!-- Right Tools -->
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<!-- Search within list -->
|
|
|
|
|
|
<div class="relative group hidden sm:block">
|
|
|
|
|
|
<n-input
|
|
|
|
|
|
v-model:value="searchKeyword"
|
|
|
|
|
|
:placeholder="t('comp.musicList.searchSongs')"
|
|
|
|
|
|
round
|
|
|
|
|
|
clearable
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
class="w-48 focus:w-64 transition-all duration-300 !bg-neutral-100 dark:!bg-neutral-900 border-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #prefix>
|
|
|
|
|
|
<i class="ri-search-line text-neutral-400"></i>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</n-input>
|
|
|
|
|
|
</div>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
<!-- Locate Current Song -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="currentPlayingIndex >= 0"
|
|
|
|
|
|
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
|
|
|
|
|
:title="t('comp.musicList.locateCurrent', '定位当前播放')"
|
|
|
|
|
|
@click="scrollToCurrentSong"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="ri-focus-3-line text-lg" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<!-- Layout Toggle -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="!isMobile"
|
|
|
|
|
|
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
|
|
|
|
|
|
@click="toggleLayout"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i :class="isCompactLayout ? 'ri-list-check-2' : 'ri-grid-line'" class="text-lg" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
</div>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
</section>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<!-- List Content -->
|
2026-02-08 01:39:20 +08:00
|
|
|
|
<section class="song-list-section page-padding-x mt-6">
|
2026-03-15 14:11:59 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="filteredSongs.length === 0 && searchKeyword"
|
|
|
|
|
|
class="empty-state py-20 text-center text-neutral-400"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="ri-search-line text-4xl mb-4 opacity-20" />
|
|
|
|
|
|
<p>{{ t('comp.musicList.noSearchResults') }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="song-list-container">
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<div
|
2026-03-15 14:11:59 +08:00
|
|
|
|
v-for="(item, index) in filteredSongs"
|
|
|
|
|
|
:key="item.id"
|
|
|
|
|
|
class="mb-2"
|
|
|
|
|
|
:class="{ 'animate-item': index < initialAnimateCount }"
|
|
|
|
|
|
:style="
|
|
|
|
|
|
index < initialAnimateCount
|
|
|
|
|
|
? { animationDelay: calculateAnimationDelay(index, 0.03) }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
"
|
2026-02-04 20:16:52 +08:00
|
|
|
|
>
|
2026-03-15 14:11:59 +08:00
|
|
|
|
<song-item
|
|
|
|
|
|
:index="index"
|
|
|
|
|
|
:compact="isCompactLayout"
|
|
|
|
|
|
:item="formatSong(item)"
|
|
|
|
|
|
:can-remove="canRemove"
|
|
|
|
|
|
:selectable="isSelecting"
|
|
|
|
|
|
:selected="selectedSongs.includes(item.id as number)"
|
|
|
|
|
|
@play="handlePlayItem(item)"
|
|
|
|
|
|
@remove-song="handleRemoveSong"
|
|
|
|
|
|
@select="(id, selected) => handleSelect(id, selected)"
|
|
|
|
|
|
/>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
</div>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
<!-- 未渲染项占位,保持滚动条高度稳定 -->
|
|
|
|
|
|
<div v-if="placeholderHeight > 0" :style="{ height: placeholderHeight + 'px' }" />
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 底部加载指示器 -->
|
|
|
|
|
|
<div v-if="loadingList" class="flex items-center justify-center py-6 gap-2">
|
|
|
|
|
|
<n-spin :size="18" />
|
|
|
|
|
|
<span class="text-sm text-neutral-400">{{ t('common.loading') }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="
|
|
|
|
|
|
!hasMore &&
|
|
|
|
|
|
renderLimit >= allFilteredSongs.length &&
|
|
|
|
|
|
filteredSongs.length > 0 &&
|
|
|
|
|
|
!searchKeyword
|
|
|
|
|
|
"
|
|
|
|
|
|
class="py-6 text-center text-sm text-neutral-300 dark:text-neutral-600"
|
|
|
|
|
|
>
|
|
|
|
|
|
— {{ t('common.noMore') }} —
|
2025-05-07 22:36:52 +08:00
|
|
|
|
</div>
|
2026-03-15 14:11:59 +08:00
|
|
|
|
</div>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
</section>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
</div>
|
2026-02-04 20:16:52 +08:00
|
|
|
|
</n-scrollbar>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
<play-bottom />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2025-07-23 23:54:35 +08:00
|
|
|
|
import { useMessage } from 'naive-ui';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
import PinyinMatch from 'pinyin-match';
|
2026-03-15 14:11:59 +08:00
|
|
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
import { useI18n } from 'vue-i18n';
|
2026-02-04 20:16:52 +08:00
|
|
|
|
import { useRoute } from 'vue-router';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
import { getAlbum, getListDetail } from '@/api/list';
|
2025-10-22 21:50:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getMusicDetail,
|
|
|
|
|
|
subscribeAlbum,
|
|
|
|
|
|
subscribePlaylist,
|
|
|
|
|
|
updatePlaylistTracks
|
|
|
|
|
|
} from '@/api/music';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
import PlayBottom from '@/components/common/PlayBottom.vue';
|
2025-07-23 23:54:35 +08:00
|
|
|
|
import SongItem from '@/components/common/SongItem.vue';
|
|
|
|
|
|
import { useDownload } from '@/hooks/useDownload';
|
2026-03-15 14:11:59 +08:00
|
|
|
|
import { useScrollTitle } from '@/hooks/useScrollTitle';
|
2025-10-22 21:50:20 +08:00
|
|
|
|
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
|
2026-02-06 20:35:04 +08:00
|
|
|
|
import { usePlayHistoryStore } from '@/store/modules/playHistory';
|
2025-08-07 22:57:17 +08:00
|
|
|
|
import { SongResult } from '@/types/music';
|
2026-02-04 20:16:52 +08:00
|
|
|
|
import { calculateAnimationDelay, getImgUrl, isElectron, isMobile } from '@/utils';
|
2025-09-14 00:34:35 +08:00
|
|
|
|
import { getLoginErrorMessage, hasPermission } from '@/utils/auth';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
defineOptions({
|
|
|
|
|
|
name: 'MusicList'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const playerStore = usePlayerStore();
|
|
|
|
|
|
const musicStore = useMusicStore();
|
2025-09-10 00:29:50 +08:00
|
|
|
|
const recommendStore = useRecommendStore();
|
2025-10-22 21:50:20 +08:00
|
|
|
|
const userStore = useUserStore();
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const message = useMessage();
|
2026-02-06 20:35:04 +08:00
|
|
|
|
const playHistoryStore = usePlayHistoryStore();
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
2026-02-04 20:16:52 +08:00
|
|
|
|
|
|
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
|
const id = route.params.id;
|
|
|
|
|
|
const type = route.query.type;
|
|
|
|
|
|
|
|
|
|
|
|
if (!id || type === 'dailyRecommend') return;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否需要加载数据
|
|
|
|
|
|
if (
|
|
|
|
|
|
musicStore.currentListInfo?.id?.toString() === id.toString() &&
|
|
|
|
|
|
musicStore.currentMusicList &&
|
|
|
|
|
|
musicStore.currentMusicList.length > 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
let data: any;
|
|
|
|
|
|
if (type === 'album') {
|
|
|
|
|
|
const res = await getAlbum(Number(id));
|
|
|
|
|
|
data = res.data;
|
|
|
|
|
|
if (data.code === 200) {
|
|
|
|
|
|
musicStore.setCurrentMusicList(
|
|
|
|
|
|
data.songs,
|
|
|
|
|
|
data.album.name,
|
|
|
|
|
|
{ ...data.album, picUrl: data.album.picUrl },
|
|
|
|
|
|
false
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.error(t('common.loadFailed'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (type === 'playlist') {
|
|
|
|
|
|
const res = await getListDetail(id.toString());
|
|
|
|
|
|
data = res.data;
|
|
|
|
|
|
if (data.code === 200) {
|
|
|
|
|
|
const playlist = data.playlist;
|
|
|
|
|
|
musicStore.setCurrentMusicList(
|
|
|
|
|
|
playlist.tracks || [],
|
|
|
|
|
|
playlist.name,
|
|
|
|
|
|
playlist,
|
|
|
|
|
|
playlist.creator?.userId === userStore.user?.userId
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.error(t('common.loadFailed'));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载列表数据失败:', error);
|
|
|
|
|
|
message.error(t('common.loadFailed'));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => route.fullPath,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
fetchData();
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
|
);
|
2025-09-10 00:29:50 +08:00
|
|
|
|
const isDailyRecommend = computed(() => route.query.type === 'dailyRecommend');
|
2025-10-22 21:50:20 +08:00
|
|
|
|
const isAlbum = computed(() => route.query.type === 'album');
|
2026-02-04 20:16:52 +08:00
|
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
|
const name = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (isDailyRecommend.value) return t('comp.recommendSinger.songlist');
|
|
|
|
|
|
return musicStore.currentMusicListName || '';
|
2025-09-10 00:29:50 +08:00
|
|
|
|
});
|
2026-02-04 20:16:52 +08:00
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
const titleElRef = ref<HTMLElement | null>(null);
|
|
|
|
|
|
useScrollTitle(name, titleElRef);
|
|
|
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
|
const songList = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
|
2025-09-10 00:29:50 +08:00
|
|
|
|
return musicStore.currentMusicList || [];
|
|
|
|
|
|
});
|
2026-02-04 20:16:52 +08:00
|
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
|
const listInfo = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (isDailyRecommend.value) return null;
|
2025-09-10 00:29:50 +08:00
|
|
|
|
return musicStore.currentListInfo || null;
|
|
|
|
|
|
});
|
2026-02-04 20:16:52 +08:00
|
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
|
const canRemove = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (isDailyRecommend.value) return false;
|
2025-09-10 00:29:50 +08:00
|
|
|
|
return musicStore.canRemoveSong || false;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-05-15 21:20:01 +08:00
|
|
|
|
const canCollect = ref(false);
|
|
|
|
|
|
const isCollected = ref(false);
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const pageSize = 40;
|
2026-03-15 14:11:59 +08:00
|
|
|
|
const initialAnimateCount = 20; // 仅前 20 项有入场动画
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const displayedSongs = ref<SongResult[]>([]);
|
2026-03-15 14:11:59 +08:00
|
|
|
|
const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const loadingList = ref(false);
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const loadedIds = ref(new Set<number>());
|
|
|
|
|
|
const isPlaylistLoading = ref(false);
|
|
|
|
|
|
const completePlaylist = ref<SongResult[]>([]);
|
|
|
|
|
|
const hasMore = ref(true);
|
|
|
|
|
|
const searchKeyword = ref('');
|
|
|
|
|
|
const isFullPlaylistLoaded = ref(false);
|
|
|
|
|
|
|
|
|
|
|
|
const isSelecting = ref(false);
|
|
|
|
|
|
const selectedSongs = ref<number[]>([]);
|
|
|
|
|
|
const { isDownloading, batchDownloadMusic } = useDownload();
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2025-07-23 23:54:35 +08:00
|
|
|
|
const isCompactLayout = ref(
|
|
|
|
|
|
isMobile.value ? false : localStorage.getItem('musicListLayout') === 'compact'
|
2026-02-04 20:16:52 +08:00
|
|
|
|
);
|
2025-05-15 21:20:01 +08:00
|
|
|
|
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const total = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (listInfo.value?.trackIds) return listInfo.value.trackIds.length;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
return songList.value.length;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const getCoverImgUrl = computed(() => {
|
2025-10-22 21:50:20 +08:00
|
|
|
|
const coverImgUrl = listInfo.value?.coverImgUrl || listInfo.value?.picUrl;
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (coverImgUrl) return coverImgUrl;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const song = songList.value[0];
|
2026-02-04 20:16:52 +08:00
|
|
|
|
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
|
2025-05-07 22:36:52 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
// 全量歌曲列表(用于"播放全部"等操作)
|
|
|
|
|
|
const allFilteredSongs = computed(() => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
|
2026-03-15 14:11:59 +08:00
|
|
|
|
return sourceList.filter((s) => !playerStore.dislikeList.includes(s.id));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 实际渲染到 DOM 的歌曲(搜索时显示全部匹配,非搜索时按 renderLimit 分页渲染)
|
|
|
|
|
|
const filteredSongs = computed(() => {
|
|
|
|
|
|
if (searchKeyword.value) {
|
|
|
|
|
|
const keyword = searchKeyword.value.toLowerCase().trim();
|
|
|
|
|
|
return allFilteredSongs.value.filter((song) => {
|
|
|
|
|
|
const songName = song.name?.toLowerCase() || '';
|
|
|
|
|
|
const albumName = song.al?.name?.toLowerCase() || '';
|
|
|
|
|
|
const artists = song.ar || song.artists || [];
|
|
|
|
|
|
return (
|
|
|
|
|
|
songName.includes(keyword) ||
|
|
|
|
|
|
albumName.includes(keyword) ||
|
|
|
|
|
|
artists.some((a: any) => a.name?.toLowerCase().includes(keyword)) ||
|
|
|
|
|
|
PinyinMatch.match(songName, keyword)
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return allFilteredSongs.value.slice(0, renderLimit.value);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 未渲染项的占位高度,让滚动条从一开始就反映真实总高度
|
|
|
|
|
|
const estimatedItemHeight = computed(() => (isCompactLayout.value ? 50 : 70));
|
|
|
|
|
|
const placeholderHeight = computed(() => {
|
|
|
|
|
|
if (searchKeyword.value) return 0;
|
|
|
|
|
|
const unrenderedCount = allFilteredSongs.value.length - filteredSongs.value.length;
|
|
|
|
|
|
return Math.max(0, unrenderedCount) * estimatedItemHeight.value;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-10 00:29:50 +08:00
|
|
|
|
const resetListState = () => {
|
|
|
|
|
|
loadedIds.value.clear();
|
|
|
|
|
|
displayedSongs.value = [];
|
|
|
|
|
|
completePlaylist.value = [];
|
|
|
|
|
|
hasMore.value = true;
|
|
|
|
|
|
isFullPlaylistLoaded.value = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const formatSong = (item: any) => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (!item) return null;
|
2026-02-06 20:35:04 +08:00
|
|
|
|
// 专辑歌曲的 al.picUrl 可能为空,使用专辑封面兜底
|
|
|
|
|
|
const picUrl = item.al?.picUrl || item.picUrl || (isAlbum.value ? getCoverImgUrl.value : '');
|
2025-05-07 22:36:52 +08:00
|
|
|
|
return {
|
|
|
|
|
|
...item,
|
2026-02-06 20:35:04 +08:00
|
|
|
|
picUrl,
|
2025-05-07 22:36:52 +08:00
|
|
|
|
song: {
|
|
|
|
|
|
artists: item.ar || item.artists,
|
2026-02-04 20:16:52 +08:00
|
|
|
|
name: item.name,
|
|
|
|
|
|
id: item.id
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
|
|
|
|
|
|
if (ids.length === 0) return [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await getMusicDetail(ids);
|
|
|
|
|
|
if (data?.songs) {
|
|
|
|
|
|
const { songs } = data;
|
2026-02-04 20:16:52 +08:00
|
|
|
|
songs.forEach((song: any) => loadedIds.value.add(song.id));
|
|
|
|
|
|
if (appendToList) displayedSongs.value.push(...songs);
|
|
|
|
|
|
if (updateComplete) completePlaylist.value.push(...songs);
|
|
|
|
|
|
return songs;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载歌曲失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadFullPlaylist = async () => {
|
|
|
|
|
|
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
|
|
|
|
|
|
isPlaylistLoading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!listInfo.value?.trackIds) {
|
|
|
|
|
|
isFullPlaylistLoaded.value = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const allIds = listInfo.value.trackIds.map((item) => item.id);
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const loadedSongIds = new Set(displayedSongs.value.map((s) => s.id as number));
|
|
|
|
|
|
completePlaylist.value = [...displayedSongs.value];
|
2025-05-07 22:36:52 +08:00
|
|
|
|
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
|
|
|
|
|
|
|
|
|
|
|
|
if (unloadedIds.length === 0) {
|
|
|
|
|
|
isFullPlaylistLoaded.value = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const batchSize = 500;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
for (let i = 0; i < unloadedIds.length; i += batchSize) {
|
|
|
|
|
|
const batchIds = unloadedIds.slice(i, i + batchSize);
|
|
|
|
|
|
const loadedBatch = await loadSongs(batchIds, false, false);
|
|
|
|
|
|
if (loadedBatch.length > 0) {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
displayedSongs.value = [...displayedSongs.value, ...loadedBatch];
|
|
|
|
|
|
completePlaylist.value = [...completePlaylist.value, ...loadedBatch];
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
isFullPlaylistLoaded.value = true;
|
|
|
|
|
|
hasMore.value = false;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载完整播放列表失败:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isPlaylistLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const handlePlayAll = () => {
|
|
|
|
|
|
if (displayedSongs.value.length === 0) return;
|
2025-10-22 21:51:16 +08:00
|
|
|
|
saveHistory();
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const list = searchKeyword.value
|
|
|
|
|
|
? filteredSongs.value
|
|
|
|
|
|
: isFullPlaylistLoaded.value
|
|
|
|
|
|
? completePlaylist.value
|
2026-03-15 14:11:59 +08:00
|
|
|
|
: allFilteredSongs.value;
|
2026-02-04 20:16:52 +08:00
|
|
|
|
playerStore.setPlayList(list.map(formatSong));
|
|
|
|
|
|
playerStore.setPlay(formatSong(list[0]));
|
|
|
|
|
|
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
|
|
|
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const handlePlayItem = (item: any) => {
|
|
|
|
|
|
playerStore.setPlay(formatSong(item));
|
|
|
|
|
|
if (!playerStore.playList.some((s) => s.id === item.id)) {
|
|
|
|
|
|
playerStore.addToNextPlay(formatSong(item));
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRemoveSong = async (songId: number) => {
|
|
|
|
|
|
if (!listInfo.value?.id || !canRemove.value) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await updatePlaylistTracks({
|
|
|
|
|
|
op: 'del',
|
|
|
|
|
|
pid: listInfo.value.id,
|
|
|
|
|
|
tracks: songId.toString()
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.status === 200) {
|
|
|
|
|
|
message.success(t('user.message.deleteSuccess'));
|
2026-02-04 20:16:52 +08:00
|
|
|
|
displayedSongs.value = displayedSongs.value.filter((s) => s.id !== songId);
|
|
|
|
|
|
completePlaylist.value = completePlaylist.value.filter((s) => s.id !== songId);
|
|
|
|
|
|
musicStore.removeSongFromList(songId);
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
|
} catch (error) {
|
2025-05-07 22:36:52 +08:00
|
|
|
|
console.error('删除歌曲失败:', error);
|
2026-02-04 20:16:52 +08:00
|
|
|
|
message.error(t('user.message.deleteFailed'));
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
// 根据滚动位置计算需要渲染多少项,快速滚动也不会出现空白
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const handleScroll = (e: Event) => {
|
2026-03-15 14:11:59 +08:00
|
|
|
|
if (searchKeyword.value) return;
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const target = e.target as HTMLElement;
|
2026-03-15 14:11:59 +08:00
|
|
|
|
const { scrollTop, clientHeight } = target;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
// 列表区域在滚动内容中的起始偏移(hero + action bar + margin)
|
|
|
|
|
|
const listSection = document.querySelector('.song-list-section') as HTMLElement;
|
|
|
|
|
|
const listStart = listSection?.offsetTop || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 当前可见区域底部在列表中的位置
|
|
|
|
|
|
const visibleBottom = scrollTop + clientHeight - listStart;
|
|
|
|
|
|
if (visibleBottom <= 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 计算需要渲染到第几项(多渲染一屏作为缓冲)
|
|
|
|
|
|
const bufferHeight = clientHeight;
|
|
|
|
|
|
const neededIndex = Math.ceil((visibleBottom + bufferHeight) / estimatedItemHeight.value);
|
|
|
|
|
|
const allCount = allFilteredSongs.value.length;
|
|
|
|
|
|
|
|
|
|
|
|
if (neededIndex > renderLimit.value) {
|
|
|
|
|
|
renderLimit.value = Math.min(neededIndex, allCount);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 内存数据全部渲染完但还有更多数据需要从 API 加载
|
|
|
|
|
|
if (renderLimit.value >= allCount && !loadingList.value && hasMore.value) {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
loadMoreSongs();
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const loadMoreSongs = async () => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
isFullPlaylistLoaded.value ||
|
|
|
|
|
|
searchKeyword.value ||
|
|
|
|
|
|
displayedSongs.value.length >= total.value
|
|
|
|
|
|
)
|
2025-05-07 22:36:52 +08:00
|
|
|
|
return;
|
2026-02-04 20:16:52 +08:00
|
|
|
|
loadingList.value = true;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const start = displayedSongs.value.length;
|
|
|
|
|
|
const end = Math.min(start + pageSize, total.value);
|
|
|
|
|
|
if (listInfo.value?.trackIds) {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const ids = listInfo.value.trackIds
|
2025-05-07 22:36:52 +08:00
|
|
|
|
.slice(start, end)
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.map((i) => i.id)
|
2025-05-07 22:36:52 +08:00
|
|
|
|
.filter((id) => !loadedIds.value.has(id));
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (ids.length > 0) await loadSongs(ids);
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
hasMore.value = displayedSongs.value.length < total.value;
|
2026-03-15 14:11:59 +08:00
|
|
|
|
// 新数据加载后扩展渲染窗口
|
|
|
|
|
|
renderLimit.value = displayedSongs.value.length;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loadingList.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const saveHistory = () => {
|
|
|
|
|
|
if (!listInfo.value?.id) return;
|
|
|
|
|
|
if (isAlbum.value) {
|
2026-02-06 20:35:04 +08:00
|
|
|
|
playHistoryStore.addAlbum({
|
2026-02-04 20:16:52 +08:00
|
|
|
|
id: listInfo.value.id,
|
|
|
|
|
|
name: listInfo.value.name || '',
|
|
|
|
|
|
picUrl: getCoverImgUrl.value,
|
|
|
|
|
|
size: total.value,
|
|
|
|
|
|
artist: listInfo.value.artist
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (route.query.type === 'playlist') {
|
2026-02-06 20:35:04 +08:00
|
|
|
|
playHistoryStore.addPlaylist({
|
2026-02-04 20:16:52 +08:00
|
|
|
|
id: listInfo.value.id,
|
|
|
|
|
|
name: listInfo.value.name || '',
|
|
|
|
|
|
coverImgUrl: getCoverImgUrl.value,
|
|
|
|
|
|
trackCount: total.value,
|
|
|
|
|
|
playCount: listInfo.value.playCount,
|
|
|
|
|
|
creator: listInfo.value.creator
|
|
|
|
|
|
});
|
2025-05-15 21:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toggleCollect = async () => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (!listInfo.value?.id || !hasPermission(true)) {
|
|
|
|
|
|
if (!listInfo.value?.id) return;
|
2025-09-14 00:34:35 +08:00
|
|
|
|
message.error(getLoginErrorMessage(true));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-22 21:50:20 +08:00
|
|
|
|
const type = route.query.type as string;
|
2025-05-15 21:20:01 +08:00
|
|
|
|
try {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const tVal = isCollected.value ? 2 : 1;
|
2025-10-22 21:50:20 +08:00
|
|
|
|
const response =
|
|
|
|
|
|
type === 'album'
|
2026-02-04 20:16:52 +08:00
|
|
|
|
? await subscribeAlbum({ t: tVal, id: listInfo.value.id })
|
|
|
|
|
|
: await subscribePlaylist({ t: tVal, id: listInfo.value.id });
|
|
|
|
|
|
if (response.data.code === 200) {
|
2025-05-15 21:20:01 +08:00
|
|
|
|
isCollected.value = !isCollected.value;
|
2026-02-04 20:16:52 +08:00
|
|
|
|
message.success(
|
|
|
|
|
|
t(
|
|
|
|
|
|
isCollected.value
|
|
|
|
|
|
? 'comp.musicList.collectSuccess'
|
|
|
|
|
|
: 'comp.musicList.cancelCollectSuccess'
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
2025-10-22 21:50:20 +08:00
|
|
|
|
if (type === 'album') {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
isCollected.value
|
|
|
|
|
|
? userStore.addCollectedAlbum(listInfo.value.id)
|
|
|
|
|
|
: userStore.removeCollectedAlbum(listInfo.value.id);
|
2025-10-22 21:50:20 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
listInfo.value.subscribed = isCollected.value;
|
|
|
|
|
|
}
|
2025-05-15 21:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
console.error('操作收藏失败:', error);
|
2025-05-15 21:20:01 +08:00
|
|
|
|
message.error(t('comp.musicList.operationFailed'));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-06-04 20:19:44 +08:00
|
|
|
|
const startSelect = () => {
|
|
|
|
|
|
isSelecting.value = true;
|
|
|
|
|
|
selectedSongs.value = [];
|
|
|
|
|
|
};
|
|
|
|
|
|
const cancelSelect = () => {
|
|
|
|
|
|
isSelecting.value = false;
|
|
|
|
|
|
selectedSongs.value = [];
|
|
|
|
|
|
};
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const handleSelect = (id: number, selected: boolean) => {
|
|
|
|
|
|
selected
|
|
|
|
|
|
? selectedSongs.value.push(id)
|
|
|
|
|
|
: (selectedSongs.value = selectedSongs.value.filter((i) => i !== id));
|
2025-06-04 20:19:44 +08:00
|
|
|
|
};
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const isAllSelected = computed(
|
|
|
|
|
|
() => filteredSongs.value.length > 0 && selectedSongs.value.length === filteredSongs.value.length
|
|
|
|
|
|
);
|
|
|
|
|
|
const isIndeterminate = computed(
|
|
|
|
|
|
() => selectedSongs.value.length > 0 && selectedSongs.value.length < filteredSongs.value.length
|
|
|
|
|
|
);
|
2025-06-04 20:19:44 +08:00
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
selectedSongs.value = checked ? filteredSongs.value.map((s) => s.id as number) : [];
|
2025-06-04 20:19:44 +08:00
|
|
|
|
};
|
|
|
|
|
|
const handleBatchDownload = async () => {
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const list = selectedSongs.value
|
|
|
|
|
|
.map((id) => filteredSongs.value.find((s) => s.id === id))
|
|
|
|
|
|
.filter((s) => s) as SongResult[];
|
|
|
|
|
|
await batchDownloadMusic(list);
|
2025-06-04 20:19:44 +08:00
|
|
|
|
cancelSelect();
|
|
|
|
|
|
};
|
2025-10-22 21:52:22 +08:00
|
|
|
|
|
2026-02-06 20:35:04 +08:00
|
|
|
|
const handleAddToPlaylist = () => {
|
|
|
|
|
|
const songs = selectedSongs.value
|
|
|
|
|
|
.map((id) => filteredSongs.value.find((s) => s.id === id))
|
|
|
|
|
|
.filter((s) => s)
|
|
|
|
|
|
.map((s) => formatSong(s))
|
|
|
|
|
|
.filter((s) => s) as SongResult[];
|
|
|
|
|
|
if (songs.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const currentList = playerStore.playList;
|
|
|
|
|
|
const newSongs = songs.filter((s) => !currentList.some((item) => item.id === s.id));
|
|
|
|
|
|
if (newSongs.length === 0) {
|
|
|
|
|
|
message.warning(t('comp.musicList.songsAlreadyInPlaylist'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
playerStore.setPlayList([...currentList, ...newSongs], true);
|
|
|
|
|
|
message.success(t('comp.musicList.addToPlaylistSuccess', { count: newSongs.length }));
|
|
|
|
|
|
cancelSelect();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
// 当前播放歌曲在列表中的索引
|
|
|
|
|
|
const currentPlayingIndex = computed(() => {
|
|
|
|
|
|
const currentId = playerStore.playMusic?.id;
|
|
|
|
|
|
if (!currentId) return -1;
|
|
|
|
|
|
return allFilteredSongs.value.findIndex((s) => s.id === currentId);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const scrollbarRef = ref<any>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 滚动到当前播放歌曲
|
|
|
|
|
|
const scrollToCurrentSong = async () => {
|
|
|
|
|
|
const index = currentPlayingIndex.value;
|
|
|
|
|
|
if (index < 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 确保目标歌曲已渲染到 DOM
|
|
|
|
|
|
if (index >= renderLimit.value) {
|
|
|
|
|
|
renderLimit.value = index + 5;
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const container = document.querySelector('.song-list-container') as HTMLElement;
|
|
|
|
|
|
const target = container?.children[index] as HTMLElement;
|
|
|
|
|
|
if (!target || !scrollbarRef.value) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 获取 n-scrollbar 内部的可滚动容器
|
|
|
|
|
|
const scrollEl = document.querySelector('.music-list-page .n-scrollbar-container') as HTMLElement;
|
|
|
|
|
|
if (!scrollEl) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 用 getBoundingClientRect 精确测量目标位置
|
|
|
|
|
|
const scrollRect = scrollEl.getBoundingClientRect();
|
|
|
|
|
|
const targetRect = target.getBoundingClientRect();
|
|
|
|
|
|
const currentScrollTop = scrollEl.scrollTop;
|
|
|
|
|
|
|
|
|
|
|
|
// 目标在滚动内容中的绝对位置
|
|
|
|
|
|
const targetAbsoluteTop = currentScrollTop + targetRect.top - scrollRect.top;
|
|
|
|
|
|
|
|
|
|
|
|
// 粘性 action bar 占用的高度
|
|
|
|
|
|
const actionBarEl = document.querySelector('.action-bar') as HTMLElement;
|
|
|
|
|
|
const actionBarHeight = actionBarEl?.offsetHeight || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 可视区域高度(去掉 action bar)
|
|
|
|
|
|
const visibleHeight = scrollRect.height - actionBarHeight;
|
|
|
|
|
|
|
|
|
|
|
|
// 滚动到目标居中(在可视区域中间)
|
|
|
|
|
|
const scrollTop = targetAbsoluteTop - actionBarHeight - visibleHeight / 2 + targetRect.height / 2;
|
|
|
|
|
|
|
|
|
|
|
|
scrollbarRef.value.scrollTo({ top: Math.max(0, scrollTop), behavior: 'smooth' });
|
|
|
|
|
|
|
|
|
|
|
|
// 短暂高亮效果
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
target.classList.add('song-highlight');
|
|
|
|
|
|
setTimeout(() => target.classList.remove('song-highlight'), 2000);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const toggleLayout = () => {
|
|
|
|
|
|
isCompactLayout.value = !isCompactLayout.value;
|
|
|
|
|
|
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
|
2025-10-22 21:52:22 +08:00
|
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
const checkCollectionStatus = () => {
|
|
|
|
|
|
const type = route.query.type as string;
|
|
|
|
|
|
if (type === 'playlist' && listInfo.value?.id) {
|
|
|
|
|
|
canCollect.value = true;
|
|
|
|
|
|
isCollected.value = listInfo.value.subscribed || false;
|
|
|
|
|
|
} else if (type === 'album' && listInfo.value?.id) {
|
|
|
|
|
|
canCollect.value = true;
|
|
|
|
|
|
isCollected.value = userStore.isAlbumCollected(listInfo.value.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
canCollect.value = false;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
|
};
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
songList,
|
|
|
|
|
|
(newSongs) => {
|
|
|
|
|
|
resetListState();
|
2026-03-15 14:11:59 +08:00
|
|
|
|
renderLimit.value = pageSize; // 重置 DOM 渲染窗口
|
2026-02-04 20:16:52 +08:00
|
|
|
|
if (newSongs.length > 0) {
|
|
|
|
|
|
displayedSongs.value = [...newSongs];
|
|
|
|
|
|
newSongs.forEach((s) => loadedIds.value.add(s.id));
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
|
hasMore.value = displayedSongs.value.length < total.value;
|
|
|
|
|
|
checkCollectionStatus();
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
|
);
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
onMounted(checkCollectionStatus);
|
|
|
|
|
|
</script>
|
2025-05-07 22:36:52 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.music-list-page {
|
|
|
|
|
|
position: relative;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.hero-section {
|
|
|
|
|
|
min-height: 300px;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.action-bar {
|
|
|
|
|
|
transition:
|
|
|
|
|
|
background-color 0.3s,
|
|
|
|
|
|
box-shadow 0.3s;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.animate-item {
|
|
|
|
|
|
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
@keyframes fadeInUp {
|
|
|
|
|
|
from {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(20px);
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
|
to {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: translateY(0);
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.action-btn-pill {
|
|
|
|
|
|
@apply transition-all border-neutral-200 dark:border-neutral-800;
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
|
|
|
@apply border-primary/30 bg-primary/5;
|
2025-05-07 22:36:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-15 21:20:01 +08:00
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.action-btn-icon {
|
|
|
|
|
|
@apply transition-all;
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
@apply scale-110 text-primary bg-primary/10;
|
2025-05-15 21:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.song-list-container {
|
|
|
|
|
|
padding-bottom: 100px;
|
|
|
|
|
|
}
|
2025-05-15 21:20:01 +08:00
|
|
|
|
|
2026-03-15 14:11:59 +08:00
|
|
|
|
.song-highlight {
|
|
|
|
|
|
animation: highlightPulse 2s ease-out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes highlightPulse {
|
|
|
|
|
|
0%,
|
|
|
|
|
|
30% {
|
|
|
|
|
|
background-color: rgba(var(--primary-color-rgb, 64, 128, 255), 0.15);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
100% {
|
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.mobile {
|
|
|
|
|
|
.hero-section {
|
|
|
|
|
|
min-height: auto;
|
2025-05-15 21:20:01 +08:00
|
|
|
|
}
|
2026-02-04 20:16:52 +08:00
|
|
|
|
.action-bar {
|
|
|
|
|
|
@apply py-2;
|
2025-05-15 21:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-07-23 23:54:35 +08:00
|
|
|
|
</style>
|