fix(ui): 修复内存泄漏、搜索竞态、播客时长及颜色/指令边界问题

- MusicFullMobile: 卸载清理睡眠定时器 interval;getTextColors 补上调用括号
- loading 指令: 改为每元素独立实例、卸载真正销毁、class 用 classList,避免多个 v-loading 争用单例
- lyric/index: 歌词窗口 receive-lyric IPC 监听卸载时解绑
- SearchBar/SearchResult/list: 三处异步搜索加竞态守卫,丢弃过期返回避免旧结果覆盖新结果
- linearColor: 0 尺寸图片 NaN 兜底、渐变色标越界兜底
- utils/secondToMinute: 时长 >=1 小时进位为 h:mm:ss(修复播客时长显示 78:34)
This commit is contained in:
alger
2026-07-05 19:53:50 +08:00
parent 4734c4d566
commit c709daec38
8 changed files with 88 additions and 38 deletions
@@ -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) {
+34 -27
View File
@@ -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<HTMLElement, VNode>();
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<string> = 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');
}
}
+9 -1
View File
@@ -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;
+9 -6
View File
@@ -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)}`;
};
// 格式化数字 千,万, 百万, 千万,亿
+10 -1
View File
@@ -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(', ')})`;
};
+4
View File
@@ -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 {
+10 -2
View File
@@ -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 = () => {
@@ -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) => ({