Skip to content

Commit 7759d9b

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

9 files changed

Lines changed: 318 additions & 144 deletions

File tree

src/i18n/lang/en-US/comp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ export default {
223223
operationFailed: 'Operation Failed',
224224
songsAlreadyInPlaylist: 'Songs already in playlist',
225225
locateCurrent: 'Locate current song',
226+
scrollToTop: 'Scroll to top',
227+
compactLayout: 'Compact layout',
228+
normalLayout: 'Normal layout',
226229
historyRecommend: 'Daily History',
227230
fetchDatesFailed: 'Failed to fetch dates',
228231
fetchSongsFailed: 'Failed to fetch songs',

src/i18n/lang/ja-JP/comp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ export default {
223223
addToPlaylistSuccess: 'プレイリストに追加しました',
224224
songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します',
225225
locateCurrent: '再生中の曲を表示',
226+
scrollToTop: 'トップに戻る',
227+
compactLayout: 'コンパクト表示',
228+
normalLayout: '通常表示',
226229
historyRecommend: '履歴の日次推薦',
227230
fetchDatesFailed: '日付リストの取得に失敗しました',
228231
fetchSongsFailed: '楽曲リストの取得に失敗しました',

src/i18n/lang/ko-KR/comp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ export default {
222222
addToPlaylistSuccess: '재생 목록에 추가 성공',
223223
songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다',
224224
locateCurrent: '현재 재생 곡 찾기',
225+
scrollToTop: '맨 위로',
226+
compactLayout: '간결한 레이아웃',
227+
normalLayout: '일반 레이아웃',
225228
historyRecommend: '일일 기록 권장',
226229
fetchDatesFailed: '날짜를 가져오지 못했습니다',
227230
fetchSongsFailed: '곡을 가져오지 못했습니다',

src/i18n/lang/zh-CN/comp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ export default {
216216
addToPlaylistSuccess: '添加到播放列表成功',
217217
songsAlreadyInPlaylist: '歌曲已存在于播放列表中',
218218
locateCurrent: '定位当前播放',
219+
scrollToTop: '回到顶部',
220+
compactLayout: '紧凑布局',
221+
normalLayout: '常规布局',
219222
historyRecommend: '历史日推',
220223
fetchDatesFailed: '获取日期列表失败',
221224
fetchSongsFailed: '获取歌曲列表失败',

src/i18n/lang/zh-Hant/comp.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ export default {
216216
addToPlaylistSuccess: '新增至播放清單成功',
217217
songsAlreadyInPlaylist: '歌曲已存在於播放清單中',
218218
locateCurrent: '定位當前播放',
219+
scrollToTop: '回到頂部',
220+
compactLayout: '緊湊佈局',
221+
normalLayout: '常規佈局',
219222
historyRecommend: '歷史日推',
220223
fetchDatesFailed: '獲取日期列表失敗',
221224
fetchSongsFailed: '獲取歌曲列表失敗',
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { computed, type ComputedRef, type Ref,ref } from 'vue';
2+
3+
import { usePlayerStore } from '@/store';
4+
import { isMobile } from '@/utils';
5+
6+
type ProgressiveRenderOptions = {
7+
/** 全量数据列表 */
8+
items: ComputedRef<any[]> | Ref<any[]>;
9+
/** 每项估算高度(px) */
10+
itemHeight: ComputedRef<number> | number;
11+
/** 列表区域的 CSS 选择器,用于计算偏移 */
12+
listSelector: string;
13+
/** 初始渲染数量 */
14+
initialCount?: number;
15+
/** 滚动到底部时的回调(用于加载更多数据) */
16+
onReachEnd?: () => void;
17+
};
18+
19+
export const useProgressiveRender = (options: ProgressiveRenderOptions) => {
20+
const { items, itemHeight, listSelector, initialCount = 40, onReachEnd } = options;
21+
22+
const playerStore = usePlayerStore();
23+
const renderLimit = ref(initialCount);
24+
25+
const getItemHeight = () => (typeof itemHeight === 'number' ? itemHeight : itemHeight.value);
26+
27+
/** 截取到 renderLimit 的可渲染列表 */
28+
const renderedItems = computed(() => {
29+
const all = items.value;
30+
return all.slice(0, renderLimit.value);
31+
});
32+
33+
/** 未渲染项的占位高度,让滚动条反映真实总高度 */
34+
const placeholderHeight = computed(() => {
35+
const unrendered = items.value.length - renderedItems.value.length;
36+
return Math.max(0, unrendered) * getItemHeight();
37+
});
38+
39+
/** 是否正在播放(用于动态底部间距) */
40+
const isPlaying = computed(() => !!playerStore.playMusicUrl);
41+
42+
/** 内容区底部 padding,播放时留出播放栏空间 */
43+
const contentPaddingBottom = computed(() =>
44+
isPlaying.value && !isMobile.value ? '220px' : '80px'
45+
);
46+
47+
/** 重置渲染限制 */
48+
const resetRenderLimit = () => {
49+
renderLimit.value = initialCount;
50+
};
51+
52+
/** 扩展渲染限制到指定索引 */
53+
const expandTo = (index: number) => {
54+
renderLimit.value = Math.max(renderLimit.value, index);
55+
};
56+
57+
/**
58+
* 滚动事件处理函数,挂载到外层 n-scrollbar 的 @scroll
59+
* 根据可视区域动态扩展 renderLimit
60+
*/
61+
const handleScroll = (e: Event) => {
62+
const target = e.target as HTMLElement;
63+
const { scrollTop, clientHeight } = target;
64+
65+
const listSection = document.querySelector(listSelector) as HTMLElement;
66+
const listStart = listSection?.offsetTop || 0;
67+
68+
const visibleBottom = scrollTop + clientHeight - listStart;
69+
if (visibleBottom <= 0) return;
70+
71+
// 多渲染一屏作为缓冲
72+
const bufferHeight = clientHeight;
73+
const neededIndex = Math.ceil((visibleBottom + bufferHeight) / getItemHeight());
74+
const allCount = items.value.length;
75+
76+
if (neededIndex > renderLimit.value) {
77+
renderLimit.value = Math.min(neededIndex, allCount);
78+
}
79+
80+
// 所有项都已渲染,通知外部加载更多数据
81+
if (renderLimit.value >= allCount && onReachEnd) {
82+
onReachEnd();
83+
}
84+
};
85+
86+
return {
87+
renderLimit,
88+
renderedItems,
89+
placeholderHeight,
90+
isPlaying,
91+
contentPaddingBottom,
92+
resetRenderLimit,
93+
expandTo,
94+
handleScroll
95+
};
96+
};

src/renderer/views/download/DownloadPage.vue

Lines changed: 97 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22
<div class="download-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
3-
<n-scrollbar class="h-full">
4-
<div class="download-content pb-32">
3+
<n-scrollbar ref="scrollbarRef" class="h-full" @scroll="handleDownloadScroll">
4+
<div class="download-content" :style="{ paddingBottom: contentPaddingBottom }">
55
<!-- Hero Section -->
66
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
77
<!-- Background with Blur -->
@@ -210,86 +210,96 @@
210210
</p>
211211
</div>
212212
<div v-else class="space-y-2">
213-
<div
214-
v-for="(item, index) in downloadStore.completedList"
215-
:key="item.path || item.filePath"
216-
class="downloaded-item group animate-item p-3 rounded-2xl flex items-center gap-4 hover:bg-neutral-100 dark:hover:bg-neutral-900 transition-all"
217-
:style="{ animationDelay: `${index * 0.03}s` }"
218-
>
213+
<div class="downloaded-list-section">
219214
<div
220-
class="relative w-12 h-12 rounded-xl overflow-hidden shadow-lg flex-shrink-0"
215+
v-for="(item, index) in renderedDownloaded"
216+
:key="item.path || item.filePath"
217+
class="downloaded-item group p-3 rounded-2xl flex items-center gap-4 hover:bg-neutral-100 dark:hover:bg-neutral-900 transition-all"
218+
:class="{ 'animate-item': index < 20 }"
219+
:style="index < 20 ? { animationDelay: `${index * 0.03}s` } : undefined"
221220
>
222-
<img
223-
:src="getImgUrl(item.picUrl, '100y100')"
224-
class="w-full h-full object-cover"
225-
@error="handleCoverError"
226-
/>
227221
<div
228-
class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
229-
@click="handlePlayMusic(item)"
222+
class="relative w-12 h-12 rounded-xl overflow-hidden shadow-lg flex-shrink-0"
230223
>
231-
<i class="ri-play-fill text-white text-xl" />
232-
</div>
233-
</div>
234-
235-
<div class="flex-1 min-w-0">
236-
<div class="flex items-center gap-2">
237-
<span class="text-sm font-bold text-neutral-900 dark:text-white truncate">{{
238-
item.displayName || item.filename
239-
}}</span>
240-
<span class="text-xs text-neutral-400 flex-shrink-0">{{
241-
formatSize(item.size)
242-
}}</span>
243-
</div>
244-
<div class="flex items-center gap-4 mt-1">
245-
<span class="text-xs text-neutral-500 truncate max-w-[150px]">{{
246-
item.ar?.map((a) => a.name).join(', ')
247-
}}</span>
224+
<img
225+
:src="getImgUrl(item.picUrl, '100y100')"
226+
class="w-full h-full object-cover"
227+
@error="handleCoverError"
228+
/>
248229
<div
249-
class="hidden md:flex items-center gap-1 text-[10px] text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 rounded-full truncate"
230+
class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
231+
@click="handlePlayMusic(item)"
250232
>
251-
<i class="ri-folder-line" />
252-
<span class="truncate">{{ shortenPath(item.path || item.filePath) }}</span>
233+
<i class="ri-play-fill text-white text-xl" />
253234
</div>
254235
</div>
255-
</div>
256236

257-
<div class="flex items-center gap-1">
258-
<n-tooltip trigger="hover">
259-
<template #trigger>
260-
<button
261-
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
262-
@click="copyPath(item.path || item.filePath)"
263-
>
264-
<i class="ri-file-copy-line" />
265-
</button>
266-
</template>
267-
{{ t('download.path.copy') || '复制路径' }}
268-
</n-tooltip>
269-
<n-tooltip trigger="hover">
270-
<template #trigger>
271-
<button
272-
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
273-
@click="openDirectory(item.path || item.filePath)"
274-
>
275-
<i class="ri-folder-open-line" />
276-
</button>
277-
</template>
278-
{{ t('download.settingsPanel.open') }}
279-
</n-tooltip>
280-
<n-tooltip trigger="hover">
281-
<template #trigger>
282-
<button
283-
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/10 transition-all"
284-
@click="handleDelete(item)"
237+
<div class="flex-1 min-w-0">
238+
<div class="flex items-center gap-2">
239+
<span class="text-sm font-bold text-neutral-900 dark:text-white truncate">{{
240+
item.displayName || item.filename
241+
}}</span>
242+
<span class="text-xs text-neutral-400 flex-shrink-0">{{
243+
formatSize(item.size)
244+
}}</span>
245+
</div>
246+
<div class="flex items-center gap-4 mt-1">
247+
<span class="text-xs text-neutral-500 truncate max-w-[150px]">{{
248+
item.ar?.map((a) => a.name).join(', ')
249+
}}</span>
250+
<div
251+
class="hidden md:flex items-center gap-1 text-[10px] text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 rounded-full truncate"
285252
>
286-
<i class="ri-delete-bin-line" />
287-
</button>
288-
</template>
289-
{{ t('common.delete') }}
290-
</n-tooltip>
253+
<i class="ri-folder-line" />
254+
<span class="truncate">{{
255+
shortenPath(item.path || item.filePath)
256+
}}</span>
257+
</div>
258+
</div>
259+
</div>
260+
261+
<div class="flex items-center gap-1">
262+
<n-tooltip trigger="hover">
263+
<template #trigger>
264+
<button
265+
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
266+
@click="copyPath(item.path || item.filePath)"
267+
>
268+
<i class="ri-file-copy-line" />
269+
</button>
270+
</template>
271+
{{ t('download.path.copy') || '复制路径' }}
272+
</n-tooltip>
273+
<n-tooltip trigger="hover">
274+
<template #trigger>
275+
<button
276+
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-primary hover:bg-primary/10 transition-all"
277+
@click="openDirectory(item.path || item.filePath)"
278+
>
279+
<i class="ri-folder-open-line" />
280+
</button>
281+
</template>
282+
{{ t('download.settingsPanel.open') }}
283+
</n-tooltip>
284+
<n-tooltip trigger="hover">
285+
<template #trigger>
286+
<button
287+
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/10 transition-all"
288+
@click="handleDelete(item)"
289+
>
290+
<i class="ri-delete-bin-line" />
291+
</button>
292+
</template>
293+
{{ t('common.delete') }}
294+
</n-tooltip>
295+
</div>
291296
</div>
292297
</div>
298+
<!-- 未渲染项占位 -->
299+
<div
300+
v-if="downloadedPlaceholderHeight > 0"
301+
:style="{ height: downloadedPlaceholderHeight + 'px' }"
302+
/>
293303
</div>
294304
</n-spin>
295305
</div>
@@ -540,6 +550,7 @@ import { useMessage } from 'naive-ui';
540550
import { computed, onMounted, ref, watch } from 'vue';
541551
import { useI18n } from 'vue-i18n';
542552
553+
import { useProgressiveRender } from '@/hooks/useProgressiveRender';
543554
import { useDownloadStore } from '@/store/modules/download';
544555
import { usePlayerStore } from '@/store/modules/player';
545556
import type { SongResult } from '@/types/music';
@@ -551,8 +562,23 @@ const { t } = useI18n();
551562
const playerStore = usePlayerStore();
552563
const downloadStore = useDownloadStore();
553564
const message = useMessage();
565+
const scrollbarRef = ref();
566+
567+
const completedList = computed(() => downloadStore.completedList);
568+
569+
const {
570+
renderedItems: renderedDownloaded,
571+
placeholderHeight: downloadedPlaceholderHeight,
572+
contentPaddingBottom,
573+
handleScroll: handleDownloadScroll
574+
} = useProgressiveRender({
575+
items: completedList,
576+
itemHeight: 72,
577+
listSelector: '.downloaded-list-section',
578+
initialCount: 40
579+
});
554580
555-
const tabName = ref('downloading');
581+
const tabName = ref(downloadStore.downloadingList.length > 0 ? 'downloading' : 'downloaded');
556582
557583
// ── Status helpers ──────────────────────────────────────────────────────────
558584

0 commit comments

Comments
 (0)