refactor(playHistory): 持久化重写,统一防抖落盘与序列化兜底

把 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)
This commit is contained in:
chengww
2026-05-17 23:08:22 +08:00
parent 537e280fdd
commit 761884f23a
2 changed files with 170 additions and 111 deletions
+161 -104
View File
@@ -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<SongResult[]>([]);
// musicHistory 用 MusicHistoryItem 而非完整 SongResultlyric/song/expiredAt 等
// 派生字段不进 localStorage,避免 5MB 配额被撑爆。详见 utils/persistedSong.ts
const musicHistory = ref<MusicHistoryItem[]>([]);
const podcastHistory = ref<DjProgram[]>([]);
const playlistHistory = ref<PlaylistHistoryItem[]>([]);
const albumHistory = ref<AlbumHistoryItem[]>([]);
@@ -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,
// 共用防抖 storageaddMusic 在播放切换时会触发 mutation,避免每次都 stringify
// 整条 musicHistory(最多 500 条)走一遍 minifyHistoryList
storage: debouncedLocalStorage,
pick: [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory'
]
],
// 与 clearAll 的同步落盘共用同一个序列化函数,避免格式漂移
serializer: {
serialize: serializePlayHistoryState,
deserialize: JSON.parse
}
}
}
);
+9 -7
View File
@@ -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);
}
};