From 7759d9b23a65e0c8e2bd6a89cc0d72f41799be2f Mon Sep 17 00:00:00 2001 From: 4everWZ Date: Wed, 8 Apr 2026 20:04:40 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E9=95=BF=E5=88=97=E8=A1=A8=E6=B8=90?= =?UTF-8?q?=E8=BF=9B=E5=BC=8F=E6=B8=B2=E6=9F=93=E4=BC=98=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=A0=8F=E9=81=AE=E6=8C=A1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20(#589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- src/i18n/lang/en-US/comp.ts | 3 + src/i18n/lang/ja-JP/comp.ts | 3 + src/i18n/lang/ko-KR/comp.ts | 3 + src/i18n/lang/zh-CN/comp.ts | 3 + src/i18n/lang/zh-Hant/comp.ts | 3 + src/renderer/hooks/useProgressiveRender.ts | 96 +++++++++++ src/renderer/views/download/DownloadPage.vue | 168 +++++++++++-------- src/renderer/views/favorite/index.vue | 112 +++++++------ src/renderer/views/music/MusicListPage.vue | 71 +++++--- 9 files changed, 318 insertions(+), 144 deletions(-) create mode 100644 src/renderer/hooks/useProgressiveRender.ts diff --git a/src/i18n/lang/en-US/comp.ts b/src/i18n/lang/en-US/comp.ts index bd3a782..e6f6245 100644 --- a/src/i18n/lang/en-US/comp.ts +++ b/src/i18n/lang/en-US/comp.ts @@ -223,6 +223,9 @@ export default { operationFailed: 'Operation Failed', songsAlreadyInPlaylist: 'Songs already in playlist', locateCurrent: 'Locate current song', + scrollToTop: 'Scroll to top', + compactLayout: 'Compact layout', + normalLayout: 'Normal layout', historyRecommend: 'Daily History', fetchDatesFailed: 'Failed to fetch dates', fetchSongsFailed: 'Failed to fetch songs', diff --git a/src/i18n/lang/ja-JP/comp.ts b/src/i18n/lang/ja-JP/comp.ts index 9352f31..69fdde6 100644 --- a/src/i18n/lang/ja-JP/comp.ts +++ b/src/i18n/lang/ja-JP/comp.ts @@ -223,6 +223,9 @@ export default { addToPlaylistSuccess: 'プレイリストに追加しました', songsAlreadyInPlaylist: '楽曲は既にプレイリストに存在します', locateCurrent: '再生中の曲を表示', + scrollToTop: 'トップに戻る', + compactLayout: 'コンパクト表示', + normalLayout: '通常表示', historyRecommend: '履歴の日次推薦', fetchDatesFailed: '日付リストの取得に失敗しました', fetchSongsFailed: '楽曲リストの取得に失敗しました', diff --git a/src/i18n/lang/ko-KR/comp.ts b/src/i18n/lang/ko-KR/comp.ts index 1b26f3b..98a0ed8 100644 --- a/src/i18n/lang/ko-KR/comp.ts +++ b/src/i18n/lang/ko-KR/comp.ts @@ -222,6 +222,9 @@ export default { addToPlaylistSuccess: '재생 목록에 추가 성공', songsAlreadyInPlaylist: '곡이 이미 재생 목록에 있습니다', locateCurrent: '현재 재생 곡 찾기', + scrollToTop: '맨 위로', + compactLayout: '간결한 레이아웃', + normalLayout: '일반 레이아웃', historyRecommend: '일일 기록 권장', fetchDatesFailed: '날짜를 가져오지 못했습니다', fetchSongsFailed: '곡을 가져오지 못했습니다', diff --git a/src/i18n/lang/zh-CN/comp.ts b/src/i18n/lang/zh-CN/comp.ts index 81ca947..f0140f32 100644 --- a/src/i18n/lang/zh-CN/comp.ts +++ b/src/i18n/lang/zh-CN/comp.ts @@ -216,6 +216,9 @@ export default { addToPlaylistSuccess: '添加到播放列表成功', songsAlreadyInPlaylist: '歌曲已存在于播放列表中', locateCurrent: '定位当前播放', + scrollToTop: '回到顶部', + compactLayout: '紧凑布局', + normalLayout: '常规布局', historyRecommend: '历史日推', fetchDatesFailed: '获取日期列表失败', fetchSongsFailed: '获取歌曲列表失败', diff --git a/src/i18n/lang/zh-Hant/comp.ts b/src/i18n/lang/zh-Hant/comp.ts index d422b49..a6ed2aa 100644 --- a/src/i18n/lang/zh-Hant/comp.ts +++ b/src/i18n/lang/zh-Hant/comp.ts @@ -216,6 +216,9 @@ export default { addToPlaylistSuccess: '新增至播放清單成功', songsAlreadyInPlaylist: '歌曲已存在於播放清單中', locateCurrent: '定位當前播放', + scrollToTop: '回到頂部', + compactLayout: '緊湊佈局', + normalLayout: '常規佈局', historyRecommend: '歷史日推', fetchDatesFailed: '獲取日期列表失敗', fetchSongsFailed: '獲取歌曲列表失敗', diff --git a/src/renderer/hooks/useProgressiveRender.ts b/src/renderer/hooks/useProgressiveRender.ts new file mode 100644 index 0000000..7f66844 --- /dev/null +++ b/src/renderer/hooks/useProgressiveRender.ts @@ -0,0 +1,96 @@ +import { computed, type ComputedRef, type Ref,ref } from 'vue'; + +import { usePlayerStore } from '@/store'; +import { isMobile } from '@/utils'; + +type ProgressiveRenderOptions = { + /** 全量数据列表 */ + items: ComputedRef | Ref; + /** 每项估算高度(px) */ + itemHeight: ComputedRef | number; + /** 列表区域的 CSS 选择器,用于计算偏移 */ + listSelector: string; + /** 初始渲染数量 */ + initialCount?: number; + /** 滚动到底部时的回调(用于加载更多数据) */ + onReachEnd?: () => void; +}; + +export const useProgressiveRender = (options: ProgressiveRenderOptions) => { + const { items, itemHeight, listSelector, initialCount = 40, onReachEnd } = options; + + const playerStore = usePlayerStore(); + const renderLimit = ref(initialCount); + + const getItemHeight = () => (typeof itemHeight === 'number' ? itemHeight : itemHeight.value); + + /** 截取到 renderLimit 的可渲染列表 */ + const renderedItems = computed(() => { + const all = items.value; + return all.slice(0, renderLimit.value); + }); + + /** 未渲染项的占位高度,让滚动条反映真实总高度 */ + const placeholderHeight = computed(() => { + const unrendered = items.value.length - renderedItems.value.length; + return Math.max(0, unrendered) * getItemHeight(); + }); + + /** 是否正在播放(用于动态底部间距) */ + const isPlaying = computed(() => !!playerStore.playMusicUrl); + + /** 内容区底部 padding,播放时留出播放栏空间 */ + const contentPaddingBottom = computed(() => + isPlaying.value && !isMobile.value ? '220px' : '80px' + ); + + /** 重置渲染限制 */ + const resetRenderLimit = () => { + renderLimit.value = initialCount; + }; + + /** 扩展渲染限制到指定索引 */ + const expandTo = (index: number) => { + renderLimit.value = Math.max(renderLimit.value, index); + }; + + /** + * 滚动事件处理函数,挂载到外层 n-scrollbar 的 @scroll + * 根据可视区域动态扩展 renderLimit + */ + const handleScroll = (e: Event) => { + const target = e.target as HTMLElement; + const { scrollTop, clientHeight } = target; + + const listSection = document.querySelector(listSelector) 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) / getItemHeight()); + const allCount = items.value.length; + + if (neededIndex > renderLimit.value) { + renderLimit.value = Math.min(neededIndex, allCount); + } + + // 所有项都已渲染,通知外部加载更多数据 + if (renderLimit.value >= allCount && onReachEnd) { + onReachEnd(); + } + }; + + return { + renderLimit, + renderedItems, + placeholderHeight, + isPlaying, + contentPaddingBottom, + resetRenderLimit, + expandTo, + handleScroll + }; +}; diff --git a/src/renderer/views/download/DownloadPage.vue b/src/renderer/views/download/DownloadPage.vue index 2d2cd44..31bad5f 100644 --- a/src/renderer/views/download/DownloadPage.vue +++ b/src/renderer/views/download/DownloadPage.vue @@ -1,7 +1,7 @@ @@ -149,9 +158,9 @@ import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import { getMusicDetail } from '@/api/music'; -import PlayBottom from '@/components/common/PlayBottom.vue'; import SongItem from '@/components/common/SongItem.vue'; import { useDownload } from '@/hooks/useDownload'; +import { useProgressiveRender } from '@/hooks/useProgressiveRender'; import { usePlayerStore } from '@/store'; import type { SongResult } from '@/types/music'; import { isElectron, setAnimationClass, setAnimationDelay } from '@/utils'; @@ -162,6 +171,31 @@ const favoriteList = computed(() => playerStore.favoriteList); const favoriteSongs = ref([]); const loading = ref(false); const noMore = ref(false); +const scrollbarRef = ref(); + +// 手工虚拟化 +const { + renderedItems, + placeholderHeight, + contentPaddingBottom, + handleScroll: progressiveScroll, + resetRenderLimit +} = useProgressiveRender({ + items: favoriteSongs, + itemHeight: 64, + listSelector: '.favorite-list-section', + initialCount: 40, + onReachEnd: () => { + if (!loading.value && !noMore.value) { + currentPage.value++; + getFavoriteSongs(); + } + } +}); + +const handleScroll = (e: Event) => { + progressiveScroll(e); +}; // 多选相关 const isSelecting = ref(false); @@ -191,28 +225,24 @@ const handleSelect = (songId: number, selected: boolean) => { // 批量下载 const handleBatchDownload = async () => { - // 获取选中歌曲的信息 const selectedSongsList = selectedSongs.value .map((songId) => favoriteSongs.value.find((s) => s.id === songId)) .filter((song) => song) as SongResult[]; - // 使用hook中的批量下载功能 await batchDownloadMusic(selectedSongsList); - - // 下载完成后取消选择 cancelSelect(); }; // 排序相关 -const isDescending = ref(true); // 默认倒序显示 +const isDescending = ref(true); -// 切换排序方式 const toggleSort = (descending: boolean) => { if (isDescending.value === descending) return; isDescending.value = descending; currentPage.value = 1; favoriteSongs.value = []; noMore.value = false; + resetRenderLimit(); getFavoriteSongs(); }; @@ -229,16 +259,14 @@ const props = defineProps({ // 获取当前页的收藏歌曲ID const getCurrentPageIds = () => { - let ids = [...favoriteList.value]; // 复制一份以免修改原数组 + let ids = [...favoriteList.value]; - // 根据排序方式调整顺序 if (isDescending.value) { - ids = ids.reverse(); // 倒序,最新收藏的在前面 + ids = ids.reverse(); } const startIndex = (currentPage.value - 1) * pageSize; const endIndex = startIndex + pageSize; - // 返回原始ID,不进行类型转换 return ids.slice(startIndex, endIndex); }; @@ -259,7 +287,6 @@ const getFavoriteSongs = async () => { const musicIds = currentIds.filter((id) => typeof id === 'number') as number[]; - // 处理音乐数据 let neteaseSongs: SongResult[] = []; if (musicIds.length > 0) { const res = await getMusicDetail(musicIds); @@ -272,31 +299,20 @@ const getFavoriteSongs = async () => { } } - console.log('获取数据统计:', { - neteaseSongs: neteaseSongs.length - }); - - // 合并数据,保持原有顺序 const newSongs = currentIds .map((id) => { const strId = String(id); - - // 查找音乐 const found = neteaseSongs.find((song) => String(song.id) === strId); return found; }) .filter((song): song is SongResult => !!song); - console.log(`最终歌曲列表: ${newSongs.length}首`); - - // 追加新数据而不是替换 if (currentPage.value === 1) { favoriteSongs.value = newSongs; } else { favoriteSongs.value = [...favoriteSongs.value, ...newSongs]; } - // 判断是否还有更多数据 noMore.value = favoriteSongs.value.length >= favoriteList.value.length; } catch (error) { console.error('获取收藏歌曲失败:', error); @@ -305,17 +321,6 @@ const getFavoriteSongs = async () => { } }; -// 处理滚动事件 -const handleScroll = (e: any) => { - const { scrollTop, scrollHeight, offsetHeight } = e.target; - const threshold = 100; // 距离底部多少像素时加载更多 - - if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) { - currentPage.value++; - getFavoriteSongs(); - } -}; - const hasLoaded = ref(false); onMounted(async () => { @@ -326,13 +331,13 @@ onMounted(async () => { } }); -// 监听收藏列表变化,变化时重置并重新加载 watch( favoriteList, async () => { hasLoaded.value = false; currentPage.value = 1; noMore.value = false; + resetRenderLimit(); await getFavoriteSongs(); hasLoaded.value = true; }, @@ -363,7 +368,6 @@ const isIndeterminate = computed(() => { return selectedSongs.value.length > 0 && selectedSongs.value.length < favoriteSongs.value.length; }); -// 处理全选/取消全选 const handleSelectAll = (checked: boolean) => { if (checked) { selectedSongs.value = favoriteSongs.value.map((song) => song.id as number); diff --git a/src/renderer/views/music/MusicListPage.vue b/src/renderer/views/music/MusicListPage.vue index cbb2fec..7c53b60 100644 --- a/src/renderer/views/music/MusicListPage.vue +++ b/src/renderer/views/music/MusicListPage.vue @@ -1,7 +1,7 @@ @@ -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([]); 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) {