From 761884f23a5a84e8f84f50b91833e0dcad5237a8 Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 17 May 2026 23:08:22 +0800 Subject: [PATCH] =?UTF-8?q?refactor(playHistory):=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E9=87=8D=E5=86=99=EF=BC=8C=E7=BB=9F=E4=B8=80=E9=98=B2?= =?UTF-8?q?=E6=8A=96=E8=90=BD=E7=9B=98=E4=B8=8E=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 playHistory 接入 utils/debouncedStorage 与 utils/persistedSong, 配合 add* 方法重构与 clearAll 同步落盘,闭合 localStorage 配额防护。 - musicHistory 类型从 SongResult 收敛到 MusicHistoryItem(精简子集), 导出 MinifiedDjProgram、stripBase64Covers,给 podcast/playlist/album/ podcastRadio 历史也做顶层 picUrl/coverImgUrl/coverUrl 的 base64 兜底 - serializePlayHistoryState 提取为模块级函数,给 persistedstate.serializer 与 clearAll 同步落盘共用,避免格式漂移;isPodcast/program 字段必须 保留——playbackController.playTrack 用 isPodcast 决定写哪条历史 - 5 个 add* 全部重写成单步 ref 重赋值,避免 splice/pop/unshift 多次 触发 watch 与持久化;命中已有条目时累加 count + 刷新 lastPlayTime, picUrl/al 用新数据覆盖(封面可能换了短引用) - clearAll 增加 flushDebouncedStorage + 同步 setItem 空状态,防止 kill -9 落在 2s 防抖窗口里导致旧历史残留 - heatmap/index.vue 类型切到 MusicHistoryItem,移除 music.artists 兜底(minifySong 已合并 ar/artists,只剩 ar) --- src/renderer/store/modules/playHistory.ts | 265 +++++++++++++--------- src/renderer/views/heatmap/index.vue | 16 +- 2 files changed, 170 insertions(+), 111 deletions(-) diff --git a/src/renderer/store/modules/playHistory.ts b/src/renderer/store/modules/playHistory.ts index 397a56a..53f7756 100644 --- a/src/renderer/store/modules/playHistory.ts +++ b/src/renderer/store/modules/playHistory.ts @@ -3,6 +3,37 @@ import { ref } from 'vue'; import type { SongResult } from '@/types/music'; import type { DjProgram } from '@/types/podcast'; +import { debouncedLocalStorage, flushDebouncedStorage } from '@/utils/debouncedStorage'; +import type { MusicHistoryItem } from '@/utils/persistedSong'; +import { + minifyHistoryEntry, + minifyHistoryList, + stripBase64CoversList +} from '@/utils/persistedSong'; + +export type { MusicHistoryItem }; + +// 一次性清理 v1 时代遗留的 localStorage key。 +// 旧版本以 5 个独立 key 单独存历史,新版本合并到 PERSIST_KEY 由 persistedstate 管。 +// 不做数据迁移:历史是低关键性衍生数据,老用户升级后看到空"最近播放",重新听几次即可。 +// 仅清掉旧 key 释放配额,避免和新 key 双倍占用 +const LEGACY_KEYS = [ + 'musicHistory', + 'podcastHistory', + 'playlistHistory', + 'albumHistory', + 'podcastRadioHistory', + // v1 迁移方案的 flag,已随 e53a035 发布到用户机器上 + 'playHistory-migrated', + // v1 时代独立持久化的播放模式,现已并入 player-core-store + 'playMode' +]; + +export const cleanupLegacyPlayHistoryStorage = (): void => { + if (localStorage.getItem('playHistory-cleaned-v1')) return; + LEGACY_KEYS.forEach((key) => localStorage.removeItem(key)); + localStorage.setItem('playHistory-cleaned-v1', '1'); +}; // ==================== 类型定义 ==================== @@ -54,6 +85,43 @@ export type PodcastRadioHistoryItem = { // 历史记录最大条数 const MAX_HISTORY_SIZE = 500; +// persistedstate 的 storage key +const PERSIST_KEY = 'play-history-store'; + +// pick 出来的持久化状态,给 persistedstate.serializer 与 clearAll 同步落盘共用 +type PersistedPlayHistoryState = { + musicHistory: MusicHistoryItem[]; + podcastHistory: DjProgram[]; + playlistHistory: PlaylistHistoryItem[]; + albumHistory: AlbumHistoryItem[]; + podcastRadioHistory: PodcastRadioHistoryItem[]; +}; + +// 序列化层兜底:哪怕有代码绕过 addMusic 直接 push 完整 SongResult,也能在 serialize 时 +// 再过一遍 minifyHistoryList,确保 localStorage 里的 musicHistory 不混入 lyric/song/base64 封面 +// +// podcast/playlist/album/podcastRadio 等历史也走 stripBase64CoversList 兜底: +// 它们的图片字段(picUrl / coverImgUrl / coverUrl)若被注入 base64 Data URL, +// 同样会撑爆 5MB 配额;保持四类历史的防御对称,避免某一处漏掉变成隐患 +// +// 入参用 any 是为了同时兼容 persistedstate 的 StateTree 与 clearAll 手工构造的 PersistedPlayHistoryState +const serializePlayHistoryState = (state: any): string => { + const s = state as PersistedPlayHistoryState; + return JSON.stringify({ + ...state, + musicHistory: minifyHistoryList( + s.musicHistory as unknown as (SongResult & { + count?: number; + lastPlayTime?: number; + })[] + ), + podcastHistory: stripBase64CoversList(s.podcastHistory), + playlistHistory: stripBase64CoversList(s.playlistHistory), + albumHistory: stripBase64CoversList(s.albumHistory), + podcastRadioHistory: stripBase64CoversList(s.podcastRadioHistory) + }); +}; + /** * 播放记录统一管理 Store * 使用 Pinia 单例模式,解决多实例不同步问题 @@ -63,7 +131,9 @@ export const usePlayHistoryStore = defineStore( 'playHistory', () => { // ==================== 状态 ==================== - const musicHistory = ref([]); + // musicHistory 用 MusicHistoryItem 而非完整 SongResult:lyric/song/expiredAt 等 + // 派生字段不进 localStorage,避免 5MB 配额被撑爆。详见 utils/persistedSong.ts + const musicHistory = ref([]); const podcastHistory = ref([]); const playlistHistory = ref([]); const albumHistory = ref([]); @@ -71,20 +141,36 @@ export const usePlayHistoryStore = defineStore( // ==================== 音乐记录 ==================== + // lastPlayTime 语义:每次播放都刷新为当前时间("最近一次播放"),不是"首次添加"。 + // 与 playlistHistory/albumHistory 等其他历史保持一致;count 仍为累计播放次数 const addMusic = (music: SongResult): void => { const index = musicHistory.value.findIndex((item) => item.id === music.id); + // 单步 ref 重赋值,避免 splice/pop + unshift 多次触发 watch 与持久化 + let next: MusicHistoryItem[]; if (index !== -1) { - musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1; - musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]); + // 命中已有条目:累加计数 + 刷新时间戳,picUrl/al 等用新数据覆盖(封面可能换了短引用) + const existing = musicHistory.value[index]; + const refreshed: MusicHistoryItem = { + ...minifyHistoryEntry(music), + count: (existing.count || 0) + 1, + lastPlayTime: Date.now() + }; + next = [ + refreshed, + ...musicHistory.value.slice(0, index), + ...musicHistory.value.slice(index + 1) + ]; } else { - musicHistory.value.unshift({ ...music, count: 1 }); - } - if (musicHistory.value.length > MAX_HISTORY_SIZE) { - musicHistory.value.pop(); + next = [ + minifyHistoryEntry({ ...music, count: 1, lastPlayTime: Date.now() }), + ...musicHistory.value + ]; } + musicHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next; }; - const delMusic = (music: SongResult): void => { + // 删除入参允许 SongResult 或 MusicHistoryItem,仅按 id 匹配,类型放宽不影响逻辑 + const delMusic = (music: { id: SongResult['id'] }): void => { const index = musicHistory.value.findIndex((item) => item.id === music.id); if (index !== -1) { musicHistory.value.splice(index, 1); @@ -95,14 +181,19 @@ export const usePlayHistoryStore = defineStore( const addPodcast = (program: DjProgram): void => { const index = podcastHistory.value.findIndex((item) => item.id === program.id); + // 单步 ref 重赋值,避免 splice/unshift/pop 多次触发 watch 与持久化(与 addMusic 一致) + let next: DjProgram[]; if (index !== -1) { - podcastHistory.value.unshift(podcastHistory.value.splice(index, 1)[0]); + next = [ + podcastHistory.value[index], + ...podcastHistory.value.slice(0, index), + ...podcastHistory.value.slice(index + 1) + ]; } else { - podcastHistory.value.unshift(program); - } - if (podcastHistory.value.length > MAX_HISTORY_SIZE) { - podcastHistory.value.pop(); + next = [program, ...podcastHistory.value]; } + podcastHistory.value = + next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next; }; const delPodcast = (program: DjProgram): void => { @@ -117,16 +208,19 @@ export const usePlayHistoryStore = defineStore( const addPlaylist = (playlist: PlaylistHistoryItem): void => { const index = playlistHistory.value.findIndex((item) => item.id === playlist.id); const now = Date.now(); + let next: PlaylistHistoryItem[]; if (index !== -1) { - playlistHistory.value[index].count = (playlistHistory.value[index].count || 0) + 1; - playlistHistory.value[index].lastPlayTime = now; - playlistHistory.value.unshift(playlistHistory.value.splice(index, 1)[0]); + const existing = playlistHistory.value[index]; + next = [ + { ...existing, count: (existing.count || 0) + 1, lastPlayTime: now }, + ...playlistHistory.value.slice(0, index), + ...playlistHistory.value.slice(index + 1) + ]; } else { - playlistHistory.value.unshift({ ...playlist, count: 1, lastPlayTime: now }); - } - if (playlistHistory.value.length > MAX_HISTORY_SIZE) { - playlistHistory.value.pop(); + next = [{ ...playlist, count: 1, lastPlayTime: now }, ...playlistHistory.value]; } + playlistHistory.value = + next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next; }; const delPlaylist = (playlist: PlaylistHistoryItem): void => { @@ -141,16 +235,18 @@ export const usePlayHistoryStore = defineStore( const addAlbum = (album: AlbumHistoryItem): void => { const index = albumHistory.value.findIndex((item) => item.id === album.id); const now = Date.now(); + let next: AlbumHistoryItem[]; if (index !== -1) { - albumHistory.value[index].count = (albumHistory.value[index].count || 0) + 1; - albumHistory.value[index].lastPlayTime = now; - albumHistory.value.unshift(albumHistory.value.splice(index, 1)[0]); + const existing = albumHistory.value[index]; + next = [ + { ...existing, count: (existing.count || 0) + 1, lastPlayTime: now }, + ...albumHistory.value.slice(0, index), + ...albumHistory.value.slice(index + 1) + ]; } else { - albumHistory.value.unshift({ ...album, count: 1, lastPlayTime: now }); - } - if (albumHistory.value.length > MAX_HISTORY_SIZE) { - albumHistory.value.pop(); + next = [{ ...album, count: 1, lastPlayTime: now }, ...albumHistory.value]; } + albumHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next; }; const delAlbum = (album: AlbumHistoryItem): void => { @@ -165,17 +261,19 @@ export const usePlayHistoryStore = defineStore( const addPodcastRadio = (radio: PodcastRadioHistoryItem): void => { const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id); const now = Date.now(); + let next: PodcastRadioHistoryItem[]; if (index !== -1) { - const existing = podcastRadioHistory.value.splice(index, 1)[0]; - existing.count = (existing.count || 0) + 1; - existing.lastPlayTime = now; - podcastRadioHistory.value.unshift(existing); + const existing = podcastRadioHistory.value[index]; + next = [ + { ...existing, count: (existing.count || 0) + 1, lastPlayTime: now }, + ...podcastRadioHistory.value.slice(0, index), + ...podcastRadioHistory.value.slice(index + 1) + ]; } else { - podcastRadioHistory.value.unshift({ ...radio, count: 1, lastPlayTime: now }); - } - if (podcastRadioHistory.value.length > MAX_HISTORY_SIZE) { - podcastRadioHistory.value.pop(); + next = [{ ...radio, count: 1, lastPlayTime: now }, ...podcastRadioHistory.value]; } + podcastRadioHistory.value = + next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next; }; const delPodcastRadio = (radio: PodcastRadioHistoryItem): void => { @@ -213,72 +311,25 @@ export const usePlayHistoryStore = defineStore( clearPlaylistHistory(); clearAlbumHistory(); clearPodcastRadioHistory(); - }; - - // ==================== 数据迁移 ==================== - - /** - * 从旧的 localStorage 数据迁移到 Pinia store - * 只在首次启动时执行一次 - */ - const migrateFromLocalStorage = (): void => { - const migrated = localStorage.getItem('playHistory-migrated'); - if (migrated) return; - + // 同步落盘空状态:persistedstate 的 watch 异步触发,clearAll 同步代码返回时 + // watch 还没把空状态送进 storage;再叠 2s 防抖窗口 → 立刻 kill -9 会让 PERSIST_KEY + // 留在旧历史。手动 setItem 让"clearAll 返回 = 落盘完成"成立。 + // 前面 flushDebouncedStorage 是顺手清掉别的 store 排队的写入与防抖定时器, + // 避免后续 watch 异步把同一份空状态再写一次(无害但多余 I/O) + flushDebouncedStorage(); try { - // 迁移音乐记录 - const oldMusic = localStorage.getItem('musicHistory'); - if (oldMusic) { - const parsed = JSON.parse(oldMusic); - if (Array.isArray(parsed) && parsed.length > 0 && musicHistory.value.length === 0) { - musicHistory.value = parsed; - } - } - - // 迁移播客记录 - const oldPodcast = localStorage.getItem('podcastHistory'); - if (oldPodcast) { - const parsed = JSON.parse(oldPodcast); - if (Array.isArray(parsed) && parsed.length > 0 && podcastHistory.value.length === 0) { - podcastHistory.value = parsed; - } - } - - // 迁移歌单记录 - const oldPlaylist = localStorage.getItem('playlistHistory'); - if (oldPlaylist) { - const parsed = JSON.parse(oldPlaylist); - if (Array.isArray(parsed) && parsed.length > 0 && playlistHistory.value.length === 0) { - playlistHistory.value = parsed; - } - } - - // 迁移专辑记录 - const oldAlbum = localStorage.getItem('albumHistory'); - if (oldAlbum) { - const parsed = JSON.parse(oldAlbum); - if (Array.isArray(parsed) && parsed.length > 0 && albumHistory.value.length === 0) { - albumHistory.value = parsed; - } - } - - // 迁移播客电台记录 - const oldRadio = localStorage.getItem('podcastRadioHistory'); - if (oldRadio) { - const parsed = JSON.parse(oldRadio); - if ( - Array.isArray(parsed) && - parsed.length > 0 && - podcastRadioHistory.value.length === 0 - ) { - podcastRadioHistory.value = parsed; - } - } - - localStorage.setItem('playHistory-migrated', '1'); - console.log('[PlayHistory] 数据迁移完成'); + localStorage.setItem( + PERSIST_KEY, + serializePlayHistoryState({ + musicHistory: [], + podcastHistory: [], + playlistHistory: [], + albumHistory: [], + podcastRadioHistory: [] + }) + ); } catch (error) { - console.error('[PlayHistory] 数据迁移失败:', error); + console.error('[PlayHistory] 清空落盘失败:', error); } }; @@ -316,21 +367,27 @@ export const usePlayHistoryStore = defineStore( clearPodcastRadioHistory, // 通用 - clearAll, - migrateFromLocalStorage + clearAll }; }, { persist: { - key: 'play-history-store', - storage: localStorage, + key: PERSIST_KEY, + // 共用防抖 storage:addMusic 在播放切换时会触发 mutation,避免每次都 stringify + // 整条 musicHistory(最多 500 条)走一遍 minifyHistoryList + storage: debouncedLocalStorage, pick: [ 'musicHistory', 'podcastHistory', 'playlistHistory', 'albumHistory', 'podcastRadioHistory' - ] + ], + // 与 clearAll 的同步落盘共用同一个序列化函数,避免格式漂移 + serializer: { + serialize: serializePlayHistoryState, + deserialize: JSON.parse + } } } ); diff --git a/src/renderer/views/heatmap/index.vue b/src/renderer/views/heatmap/index.vue index 3d9b130..ed477af 100644 --- a/src/renderer/views/heatmap/index.vue +++ b/src/renderer/views/heatmap/index.vue @@ -152,6 +152,7 @@ import { usePlayerStore } from '@/store/modules/player'; import { usePlayHistoryStore } from '@/store/modules/playHistory'; import type { SongResult } from '@/types/music'; import { setAnimationClass } from '@/utils'; +import type { MusicHistoryItem } from '@/utils/persistedSong'; const { t } = useI18n(); const playHistoryStore = usePlayHistoryStore(); @@ -220,7 +221,7 @@ const processHistoryData = () => { const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000; // 遍历音乐历史记录 - playHistoryStore.musicHistory.forEach((music: SongResult & { count?: number }) => { + playHistoryStore.musicHistory.forEach((music: MusicHistoryItem) => { // 假设每次播放都记录在当前时间,我们根据 count 分散到最近的日期 const playCount = music.count || 1; const now = Date.now(); @@ -251,7 +252,7 @@ const processHistoryData = () => { dailyMap[dateKey].songs.set(songId, { id: music.id, name: music.name || 'Unknown', - artist: music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist', + artist: music.ar?.[0]?.name || 'Unknown Artist', playCount: 1 }); } @@ -307,11 +308,11 @@ const mostPlayedSong = computed<{ { id: string | number; name: string; artist: string; playCount: number } >(); - playHistoryStore.musicHistory.forEach((music: SongResult & { count?: number }) => { + playHistoryStore.musicHistory.forEach((music: MusicHistoryItem) => { const id = music.id; const count = music.count || 1; const name = music.name || 'Unknown'; - const artist = music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist'; + const artist = music.ar?.[0]?.name || 'Unknown Artist'; if (songPlayCounts.has(id)) { songPlayCounts.get(id)!.playCount += count; @@ -382,7 +383,7 @@ const latestNightSong = computed<{ return { id: randomSong.id, name: randomSong.name || 'Unknown', - artist: randomSong.ar?.[0]?.name || randomSong.artists?.[0]?.name || 'Unknown Artist', + artist: randomSong.ar?.[0]?.name || 'Unknown Artist', time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}` }; } @@ -395,7 +396,7 @@ const latestNightSong = computed<{ return { id: song.id, name: song.name || 'Unknown', - artist: song.ar?.[0]?.name || song.artists?.[0]?.name || 'Unknown Artist', + artist: song.ar?.[0]?.name || 'Unknown Artist', time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}` }; } @@ -407,7 +408,8 @@ const latestNightSong = computed<{ const handlePlaySong = async (songId: string | number) => { const song = playHistoryStore.musicHistory.find((music) => music.id === songId); if (song) { - await playerStore.setPlay(song); + // MusicHistoryItem 是 SongResult 的精简子集,setPlay 内部用 optional chain 访问字段 + await playerStore.setPlay(song as SongResult); playerStore.setPlayMusic(true); } };