diff --git a/src/renderer/components/lyric/MusicFullMobile.vue b/src/renderer/components/lyric/MusicFullMobile.vue index de3c57c..71220eb 100644 --- a/src/renderer/components/lyric/MusicFullMobile.vue +++ b/src/renderer/components/lyric/MusicFullMobile.vue @@ -962,6 +962,12 @@ onBeforeUnmount(() => { clearTimeout(autoScrollTimer.value); } + // 清理睡眠定时器倒计时刷新 interval,避免组件卸载后残留、闭包持有 store 无法回收 + if (sleepTimerInterval) { + clearInterval(sleepTimerInterval); + sleepTimerInterval = null; + } + // 清理鼠标事件监听 document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); @@ -1084,7 +1090,7 @@ const { getLrcStyle: originalLrcStyle } = useLyricProgress(); // 修改 getLrcStyle 函数 const getLrcStyle = (index: number) => { - const colors = textColors.value || getTextColors; + const colors = textColors.value || getTextColors(); const originalStyle = originalLrcStyle(index); if (index === nowIndex.value) { diff --git a/src/renderer/directive/loading/index.ts b/src/renderer/directive/loading/index.ts index 37f099a..ca7efc4 100644 --- a/src/renderer/directive/loading/index.ts +++ b/src/renderer/directive/loading/index.ts @@ -2,39 +2,46 @@ import { createVNode, render, VNode } from 'vue'; import Loading from './index.vue'; -const vnode: VNode = createVNode(Loading) as VNode; +// 每个使用 v-loading 的元素独立持有一个 Loading 实例, +// 避免此前"模块级单例 vnode"导致的多个 v-loading 争用同一实例、 +// spinner 只出现在最后挂载元素上的问题。 +const instanceMap = new WeakMap(); -export const vLoading = { - // 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用 - mounted: (el: HTMLElement) => { - render(vnode, el); - }, - // 在绑定元素的父组件 及他自己的所有子节点都更新后调用 - updated: (el: HTMLElement, binding: any) => { - if (binding.value) { - vnode?.component?.exposed?.show(); - } else { - vnode?.component?.exposed?.hide(); - } - // 动态添加删除自定义class: loading-parent - formatterClass(el, binding); - }, - // 绑定元素的父组件卸载后调用 - unmounted: () => { +const setLoading = (el: HTMLElement, visible: boolean) => { + const vnode = instanceMap.get(el); + if (visible) { + vnode?.component?.exposed?.show(); + } else { vnode?.component?.exposed?.hide(); } }; +export const vLoading = { + // 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用 + mounted: (el: HTMLElement, binding: any) => { + const vnode = createVNode(Loading); + render(vnode, el); + instanceMap.set(el, vnode); + setLoading(el, !!binding.value); + formatterClass(el, binding); + }, + // 在绑定元素的父组件及他自己的所有子节点都更新后调用 + updated: (el: HTMLElement, binding: any) => { + setLoading(el, !!binding.value); + // 动态添加删除自定义class: loading-parent + formatterClass(el, binding); + }, + // 绑定元素的父组件卸载后调用:真正卸载组件实例,释放资源 + unmounted: (el: HTMLElement) => { + render(null, el); + instanceMap.delete(el); + } +}; + function formatterClass(el: HTMLElement, binding: any) { - const classStr = el.getAttribute('class'); - const tagetClass: number = classStr?.indexOf('loading-parent') as number; if (binding.value) { - if (tagetClass === -1) { - el.setAttribute('class', `${classStr} loading-parent`); - } - } else if (tagetClass > -1) { - const classArray: Array = classStr?.split('') as string[]; - classArray.splice(tagetClass - 1, tagetClass + 15); - el.setAttribute('class', classArray?.join('')); + el.classList.add('loading-parent'); + } else { + el.classList.remove('loading-parent'); } } diff --git a/src/renderer/layout/components/SearchBar.vue b/src/renderer/layout/components/SearchBar.vue index c9afe66..fab065e 100644 --- a/src/renderer/layout/components/SearchBar.vue +++ b/src/renderer/layout/components/SearchBar.vue @@ -382,14 +382,22 @@ const showSuggestions = ref(false); const suggestionsLoading = ref(false); const highlightedIndex = ref(-1); +// 竞态守卫:记录最后一次请求的关键词,响应乱序返回时丢弃过期结果 +let lastSuggestKeyword = ''; const debouncedSuggest = useDebounceFn(async (kw: string) => { if (!kw.trim()) { suggestions.value = []; showSuggestions.value = false; return; } + lastSuggestKeyword = kw; suggestionsLoading.value = true; - suggestions.value = await getSearchSuggestions(kw); + const result = await getSearchSuggestions(kw); + // 若期间输入已变化,丢弃这次结果,避免旧关键词建议覆盖新关键词 + if (kw !== lastSuggestKeyword) { + return; + } + suggestions.value = result; suggestionsLoading.value = false; showSuggestions.value = suggestions.value.length > 0; highlightedIndex.value = -1; diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 4e7141b..0203731 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -45,16 +45,19 @@ export const calculateAnimationDelay = (index: any, baseDelay: number = 0.03): s return `${delay.toFixed(3)}s`; }; -// 将秒转换为分钟和秒 +// 将秒转换为时长字符串:<1 小时为 mm:ss,>=1 小时进位为 h:mm:ss export const secondToMinute = (s: number) => { - if (!s) { + if (!s || s < 0) { return '00:00'; } - const minute: number = Math.floor(s / 60); + const hour: number = Math.floor(s / 3600); + const minute: number = Math.floor((s % 3600) / 60); const second: number = Math.floor(s % 60); - const minuteStr: string = minute > 9 ? minute.toString() : `0${minute.toString()}`; - const secondStr: string = second > 9 ? second.toString() : `0${second.toString()}`; - return `${minuteStr}:${secondStr}`; + const pad = (n: number): string => (n > 9 ? `${n}` : `0${n}`); + if (hour > 0) { + return `${hour}:${pad(minute)}:${pad(second)}`; + } + return `${pad(minute)}:${pad(second)}`; }; // 格式化数字 千,万, 百万, 千万,亿 diff --git a/src/renderer/utils/linearColor.ts b/src/renderer/utils/linearColor.ts index 272a330..b8ec54d 100644 --- a/src/renderer/utils/linearColor.ts +++ b/src/renderer/utils/linearColor.ts @@ -115,6 +115,10 @@ const getAverageColor = (data: Uint8ClampedArray): number[] => { b += data[i + 2]; count++; } + // 0 尺寸图片(count 为 0)会得到 NaN,回退为中性灰避免生成 rgb(NaN,...) + if (count === 0) { + return [128, 128, 128]; + } return [Math.round(r / count), Math.round(g / count), Math.round(b / count)]; }; @@ -306,8 +310,13 @@ export const createGradientString = ( colors: { r: number; g: number; b: number }[], percentages = [0, 50, 100] ) => { + const count = colors.length; return `linear-gradient(to bottom, ${colors - .map((color, i) => `rgb(${color.r}, ${color.g}, ${color.b}) ${percentages[i]}%`) + .map((color, i) => { + // 当颜色数量与传入的 percentages 不一致时,按索引均匀分布,避免出现 undefined% + const percent = percentages[i] ?? (count > 1 ? Math.round((i / (count - 1)) * 100) : 0); + return `rgb(${color.r}, ${color.g}, ${color.b}) ${percent}%`; + }) .join(', ')})`; }; diff --git a/src/renderer/views/list/index.vue b/src/renderer/views/list/index.vue index 1bf3ee8..8075ec6 100644 --- a/src/renderer/views/list/index.vue +++ b/src/renderer/views/list/index.vue @@ -150,6 +150,10 @@ const loadList = async (type: string, isLoadMore = false) => { offset: page.value * TOTAL_ITEMS }; const { data } = await getListByCat(params); + // 竞态守卫:返回时若分类已切换,丢弃过期结果,避免旧分类覆盖新分类 + if (type !== currentType.value) { + return; + } if (isLoadMore) { recommendList.value.push(...data.playlists); } else { diff --git a/src/renderer/views/lyric/index.vue b/src/renderer/views/lyric/index.vue index 4704c95..1882866 100644 --- a/src/renderer/views/lyric/index.vue +++ b/src/renderer/views/lyric/index.vue @@ -343,6 +343,7 @@ const showTranslation = computed(() => lyricSetting.value.showTranslation); let hideControlsTimer: number | null = null; let removeMousePresenceListener: (() => void) | null = null; +let removeReceiveLyricListener: (() => void) | null = null; const isHovering = ref(false); @@ -806,8 +807,8 @@ onMounted(() => { updateContainerHeight(); window.addEventListener('resize', updateContainerHeight); - // 监听歌词数据 - windowData.electron.ipcRenderer.on('receive-lyric', (_, data) => { + // 监听歌词数据(保存移除函数,卸载时解绑,避免窗口复用/HMR 时监听器叠加) + const disposeReceiveLyric = windowData.electron.ipcRenderer.on('receive-lyric', (_, data) => { try { const parsedData = JSON.parse(data); handleDataUpdate(parsedData); @@ -815,6 +816,9 @@ onMounted(() => { console.error('Error parsing lyric data:', error); } }); + if (typeof disposeReceiveLyric === 'function') { + removeReceiveLyricListener = disposeReceiveLyric; + } // 通知主窗口歌词窗口已就绪,请求发送完整歌词数据 windowData.electron.ipcRenderer.send('lyric-ready'); @@ -847,6 +851,10 @@ onUnmounted(() => { removeMousePresenceListener(); removeMousePresenceListener = null; } + if (removeReceiveLyricListener) { + removeReceiveLyricListener(); + removeReceiveLyricListener = null; + } }); const checkTheme = () => { diff --git a/src/renderer/views/search/SearchResult.vue b/src/renderer/views/search/SearchResult.vue index 9c4d390..544af69 100644 --- a/src/renderer/views/search/SearchResult.vue +++ b/src/renderer/views/search/SearchResult.vue @@ -381,6 +381,11 @@ const loadSearch = async (isLoadMore = false) => { offset: page.value * ITEMS_PER_PAGE }); + // 竞态守卫:请求返回时若关键词/类型已切换,丢弃这次过期结果 + if (keywords !== currentKeyword.value || type !== searchType.value) { + return; + } + const songs = data.result.songs || []; const albums = data.result.albums || []; const mvs = (data.result.mvs || []).map((item: any) => ({