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
This commit is contained in:
4everWZ
2026-04-08 20:04:40 +08:00
committed by alger
parent 0ab784024c
commit 8726af556a
9 changed files with 318 additions and 144 deletions
+52 -19
View File
@@ -1,7 +1,7 @@
<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 pb-32">
<div class="music-list-content" :style="{ paddingBottom: contentPaddingBottom }">
<!-- Hero Section Action Bar -->
<n-spin :show="loading">
<!-- Hero Section -->
@@ -217,23 +217,50 @@
</div>
<!-- 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>
<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 -->
<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>
<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>
@@ -296,7 +323,6 @@
</section>
</div>
</n-scrollbar>
<play-bottom />
</div>
</template>
@@ -314,7 +340,6 @@ import {
subscribePlaylist,
updatePlaylistTracks
} from '@/api/music';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import { useDownload } from '@/hooks/useDownload';
import { useScrollTitle } from '@/hooks/useScrollTitle';
@@ -338,6 +363,10 @@ 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;
@@ -428,7 +457,7 @@ const canRemove = computed(() => {
const canCollect = ref(false);
const isCollected = ref(false);
const pageSize = 40;
const pageSize = 200;
const initialAnimateCount = 20; // 仅前 20 项有入场动画
const displayedSongs = ref<SongResult[]>([]);
const renderLimit = ref(pageSize); // DOM 渲染上限,数据全部在内存
@@ -832,6 +861,10 @@ const toggleLayout = () => {
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) {