Files
AlgerMusicPlayer/src/renderer/views/music/MusicListPage.vue
T
4everWZ 8726af556a perf: 长列表渐进式渲染优化与播放栏遮挡修复 (#589)
- 新增 useProgressiveRender composable,提取手工虚拟化逻辑(renderLimit + placeholderHeight)
- FavoritePage/DownloadPage 使用 composable 实现渐进式渲染,避免大量 DOM 一次性渲染
- MusicListPage 初始加载扩大至 200 首,工具栏按钮添加 n-tooltip,新增回到顶部按钮
- 播放栏动态底部间距替代 PlayBottom 组件,修复播放时列表底部被遮挡
- 下载页无下载任务时自动切换到已下载 tab
- i18n: 添加 scrollToTop/compactLayout/normalLayout 翻译(5 种语言)

Inspired-By: https://github.com/algerkong/AlgerMusicPlayer/pull/589
2026-04-10 23:26:34 +08:00

971 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="music-list-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleScroll">
<div class="music-list-content" :style="{ paddingBottom: contentPaddingBottom }">
<!-- 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>
</div>
<!-- Hero Content -->
<div class="hero-content relative z-10 page-padding-x pt-4 md:pt-10 pb-8">
<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
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()"
>
<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"
>
<i class="ri-play-fill text-3xl text-neutral-900 ml-1" />
</button>
</div>
</div>
</div>
<!-- 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
ref="titleElRef"
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>
<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>
</div>
</div>
</div>
</section>
</n-spin>
<!-- Action Bar (Sticky) -->
<section
v-if="songList.length > 0"
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"
>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<!-- Play All Button -->
<button
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"
@click="handlePlayAll"
>
<i class="ri-play-circle-line text-base md:text-lg" />
<span>{{ t('comp.musicList.playAll') }}</span>
</button>
<!-- Collect Button -->
<button
v-if="canCollect"
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"
: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"
>
<i
:class="isCollected ? 'ri-heart-fill' : 'ri-heart-line'"
class="text-base md:text-lg"
/>
<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>
<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>
<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>
<button
class="text-xs text-neutral-400 hover:text-neutral-600"
@click="cancelSelect"
>
{{ t('common.cancel') }}
</button>
</div>
</div>
<!-- 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>
<!-- Locate Current Song -->
<n-tooltip v-if="currentPlayingIndex >= 0" trigger="hover">
<template #trigger>
<button
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="scrollToCurrentSong"
>
<i class="ri-focus-3-line text-lg" />
</button>
</template>
{{ t('comp.musicList.locateCurrent') }}
</n-tooltip>
<!-- Layout Toggle -->
<n-tooltip v-if="!isMobile" trigger="hover">
<template #trigger>
<button
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>
</template>
{{
isCompactLayout
? t('comp.musicList.normalLayout')
: t('comp.musicList.compactLayout')
}}
</n-tooltip>
<!-- Scroll to Top -->
<n-tooltip trigger="hover">
<template #trigger>
<button
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="scrollToTop"
>
<i class="ri-arrow-up-line text-lg" />
</button>
</template>
{{ t('comp.musicList.scrollToTop') }}
</n-tooltip>
</div>
</div>
</section>
<!-- List Content -->
<section class="song-list-section page-padding-x mt-6">
<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">
<div
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
"
>
<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)"
/>
</div>
<!-- 未渲染项占位保持滚动条高度稳定 -->
<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') }} —
</div>
</div>
</section>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import PinyinMatch from 'pinyin-match';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { getAlbum, getListDetail } from '@/api/list';
import {
getMusicDetail,
subscribeAlbum,
subscribePlaylist,
updatePlaylistTracks
} from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { useDownload } from '@/hooks/useDownload';
import { useScrollTitle } from '@/hooks/useScrollTitle';
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
import { usePlayHistoryStore } from '@/store/modules/playHistory';
import { SongResult } from '@/types/music';
import { calculateAnimationDelay, getImgUrl, isElectron, isMobile } from '@/utils';
import { getLoginErrorMessage, hasPermission } from '@/utils/auth';
defineOptions({
name: 'MusicList'
});
const { t } = useI18n();
const route = useRoute();
const playerStore = usePlayerStore();
const musicStore = useMusicStore();
const recommendStore = useRecommendStore();
const userStore = useUserStore();
const message = useMessage();
const playHistoryStore = usePlayHistoryStore();
const loading = ref(false);
const isPlaying = computed(() => !!playerStore.playMusicUrl);
const contentPaddingBottom = computed(() =>
isPlaying.value && !isMobile.value ? '220px' : '80px'
);
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 }
);
const isDailyRecommend = computed(() => route.query.type === 'dailyRecommend');
const isAlbum = computed(() => route.query.type === 'album');
const name = computed(() => {
if (isDailyRecommend.value) return t('comp.recommendSinger.songlist');
return musicStore.currentMusicListName || '';
});
const titleElRef = ref<HTMLElement | null>(null);
useScrollTitle(name, titleElRef);
const songList = computed(() => {
if (isDailyRecommend.value) return recommendStore.dailyRecommendSongs;
return musicStore.currentMusicList || [];
});
const listInfo = computed(() => {
if (isDailyRecommend.value) return null;
return musicStore.currentListInfo || null;
});
const canRemove = computed(() => {
if (isDailyRecommend.value) return false;
return musicStore.canRemoveSong || false;
});
const canCollect = ref(false);
const isCollected = ref(false);
const pageSize = 200;
const initialAnimateCount = 20; // 仅前 20 项有入场动画
const displayedSongs = ref<SongResult[]>([]);
const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存
const loadingList = ref(false);
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();
const isCompactLayout = ref(
isMobile.value ? false : localStorage.getItem('musicListLayout') === 'compact'
);
const total = computed(() => {
if (listInfo.value?.trackIds) return listInfo.value.trackIds.length;
return songList.value.length;
});
const getCoverImgUrl = computed(() => {
const coverImgUrl = listInfo.value?.coverImgUrl || listInfo.value?.picUrl;
if (coverImgUrl) return coverImgUrl;
const song = songList.value[0];
return song?.picUrl || song?.al?.picUrl || song?.album?.picUrl || '';
});
// 全量歌曲列表(用于"播放全部"等操作)
const allFilteredSongs = computed(() => {
const sourceList = isDailyRecommend.value ? songList.value : displayedSongs.value;
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;
});
const resetListState = () => {
loadedIds.value.clear();
displayedSongs.value = [];
completePlaylist.value = [];
hasMore.value = true;
isFullPlaylistLoaded.value = false;
};
const formatSong = (item: any) => {
if (!item) return null;
// 专辑歌曲的 al.picUrl 可能为空,使用专辑封面兜底
const picUrl = item.al?.picUrl || item.picUrl || (isAlbum.value ? getCoverImgUrl.value : '');
return {
...item,
picUrl,
song: {
artists: item.ar || item.artists,
name: item.name,
id: item.id
}
};
};
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;
songs.forEach((song: any) => loadedIds.value.add(song.id));
if (appendToList) displayedSongs.value.push(...songs);
if (updateComplete) completePlaylist.value.push(...songs);
return songs;
}
} 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);
const loadedSongIds = new Set(displayedSongs.value.map((s) => s.id as number));
completePlaylist.value = [...displayedSongs.value];
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
if (unloadedIds.length === 0) {
isFullPlaylistLoaded.value = true;
return;
}
const batchSize = 500;
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) {
displayedSongs.value = [...displayedSongs.value, ...loadedBatch];
completePlaylist.value = [...completePlaylist.value, ...loadedBatch];
}
}
isFullPlaylistLoaded.value = true;
hasMore.value = false;
} catch (error) {
console.error('加载完整播放列表失败:', error);
} finally {
isPlaylistLoading.value = false;
}
};
const handlePlayAll = () => {
if (displayedSongs.value.length === 0) return;
saveHistory();
const list = searchKeyword.value
? filteredSongs.value
: isFullPlaylistLoaded.value
? completePlaylist.value
: allFilteredSongs.value;
playerStore.setPlayList(list.map(formatSong));
playerStore.setPlay(formatSong(list[0]));
if (!isFullPlaylistLoaded.value) loadFullPlaylist();
};
const handlePlayItem = (item: any) => {
playerStore.setPlay(formatSong(item));
if (!playerStore.playList.some((s) => s.id === item.id)) {
playerStore.addToNextPlay(formatSong(item));
}
};
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'));
displayedSongs.value = displayedSongs.value.filter((s) => s.id !== songId);
completePlaylist.value = completePlaylist.value.filter((s) => s.id !== songId);
musicStore.removeSongFromList(songId);
}
} catch (error) {
console.error('删除歌曲失败:', error);
message.error(t('user.message.deleteFailed'));
}
};
// 根据滚动位置计算需要渲染多少项,快速滚动也不会出现空白
const handleScroll = (e: Event) => {
if (searchKeyword.value) return;
const target = e.target as HTMLElement;
const { scrollTop, clientHeight } = target;
// 列表区域在滚动内容中的起始偏移(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) {
loadMoreSongs();
}
};
const loadMoreSongs = async () => {
if (
isFullPlaylistLoaded.value ||
searchKeyword.value ||
displayedSongs.value.length >= total.value
)
return;
loadingList.value = true;
try {
const start = displayedSongs.value.length;
const end = Math.min(start + pageSize, total.value);
if (listInfo.value?.trackIds) {
const ids = listInfo.value.trackIds
.slice(start, end)
.map((i) => i.id)
.filter((id) => !loadedIds.value.has(id));
if (ids.length > 0) await loadSongs(ids);
}
hasMore.value = displayedSongs.value.length < total.value;
// 新数据加载后扩展渲染窗口
renderLimit.value = displayedSongs.value.length;
} finally {
loadingList.value = false;
}
};
const saveHistory = () => {
if (!listInfo.value?.id) return;
if (isAlbum.value) {
playHistoryStore.addAlbum({
id: listInfo.value.id,
name: listInfo.value.name || '',
picUrl: getCoverImgUrl.value,
size: total.value,
artist: listInfo.value.artist
});
} else if (route.query.type === 'playlist') {
playHistoryStore.addPlaylist({
id: listInfo.value.id,
name: listInfo.value.name || '',
coverImgUrl: getCoverImgUrl.value,
trackCount: total.value,
playCount: listInfo.value.playCount,
creator: listInfo.value.creator
});
}
};
const toggleCollect = async () => {
if (!listInfo.value?.id || !hasPermission(true)) {
if (!listInfo.value?.id) return;
message.error(getLoginErrorMessage(true));
return;
}
const type = route.query.type as string;
try {
const tVal = isCollected.value ? 2 : 1;
const response =
type === 'album'
? await subscribeAlbum({ t: tVal, id: listInfo.value.id })
: await subscribePlaylist({ t: tVal, id: listInfo.value.id });
if (response.data.code === 200) {
isCollected.value = !isCollected.value;
message.success(
t(
isCollected.value
? 'comp.musicList.collectSuccess'
: 'comp.musicList.cancelCollectSuccess'
)
);
if (type === 'album') {
isCollected.value
? userStore.addCollectedAlbum(listInfo.value.id)
: userStore.removeCollectedAlbum(listInfo.value.id);
} else {
listInfo.value.subscribed = isCollected.value;
}
}
} catch (error) {
console.error('操作收藏失败:', error);
message.error(t('comp.musicList.operationFailed'));
}
};
const startSelect = () => {
isSelecting.value = true;
selectedSongs.value = [];
};
const cancelSelect = () => {
isSelecting.value = false;
selectedSongs.value = [];
};
const handleSelect = (id: number, selected: boolean) => {
selected
? selectedSongs.value.push(id)
: (selectedSongs.value = selectedSongs.value.filter((i) => i !== id));
};
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
);
const handleSelectAll = (checked: boolean) => {
selectedSongs.value = checked ? filteredSongs.value.map((s) => s.id as number) : [];
};
const handleBatchDownload = async () => {
const list = selectedSongs.value
.map((id) => filteredSongs.value.find((s) => s.id === id))
.filter((s) => s) as SongResult[];
await batchDownloadMusic(list);
cancelSelect();
};
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();
};
// 当前播放歌曲在列表中的索引
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);
};
const toggleLayout = () => {
isCompactLayout.value = !isCompactLayout.value;
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
};
const scrollToTop = () => {
scrollbarRef.value?.scrollTo({ top: 0, behavior: 'smooth' });
};
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;
}
};
watch(
songList,
(newSongs) => {
resetListState();
renderLimit.value = pageSize; // 重置 DOM 渲染窗口
if (newSongs.length > 0) {
displayedSongs.value = [...newSongs];
newSongs.forEach((s) => loadedIds.value.add(s.id));
}
hasMore.value = displayedSongs.value.length < total.value;
checkCollectionStatus();
},
{ immediate: true }
);
onMounted(checkCollectionStatus);
</script>
<style scoped lang="scss">
.music-list-page {
position: relative;
}
.hero-section {
min-height: 300px;
}
.action-bar {
transition:
background-color 0.3s,
box-shadow 0.3s;
}
.animate-item {
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.action-btn-pill {
@apply transition-all border-neutral-200 dark:border-neutral-800;
&:hover:not(:disabled) {
@apply border-primary/30 bg-primary/5;
}
}
.action-btn-icon {
@apply transition-all;
&:hover {
@apply scale-110 text-primary bg-primary/10;
}
}
.song-list-container {
padding-bottom: 100px;
}
.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;
}
}
.mobile {
.hero-section {
min-height: auto;
}
.action-bar {
@apply py-2;
}
}
</style>