feat: 历史记录页面 添加本地和云端两种记录支持,支持歌曲、歌单、专辑

This commit is contained in:
alger
2025-10-22 21:51:16 +08:00
parent a9adb6be36
commit 6d7ba6dbae
16 changed files with 1045 additions and 137 deletions
+445 -84
View File
@@ -3,61 +3,361 @@
<div class="title" :class="setAnimationClass('animate__fadeInRight')">
{{ t('history.title') }}
</div>
<!-- 第一级Tab: 歌曲/歌单/专辑 -->
<div class="category-tabs-wrapper" :class="setAnimationClass('animate__fadeInRight')">
<n-tabs
v-model:value="currentCategory"
type="segment"
animated
size="large"
@update:value="handleCategoryChange"
>
<n-tab name="songs" :tab="t('history.categoryTabs.songs')"></n-tab>
<n-tab name="playlists" :tab="t('history.categoryTabs.playlists')"></n-tab>
<n-tab name="albums" :tab="t('history.categoryTabs.albums')"></n-tab>
</n-tabs>
</div>
<!-- 第二级Tab: 本地/云端 -->
<div class="tabs-wrapper" :class="setAnimationClass('animate__fadeInRight')">
<n-tabs
v-model:value="currentTab"
type="segment"
animated
@update:value="handleTabChange"
size="small"
>
<n-tab name="local" :tab="t('history.tabs.local')"></n-tab>
<n-tab name="cloud" :tab="t('history.tabs.cloud')"></n-tab>
</n-tabs>
</div>
<n-scrollbar ref="scrollbarRef" :size="100" @scroll="handleScroll">
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div
v-for="(item, index) in displayList"
:key="item.id"
class="history-item"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 30)"
>
<song-item class="history-item-content" :item="item" @play="handlePlay" />
<div class="history-item-count min-w-[60px]">
{{ t('history.playCount', { count: item.count }) }}
</div>
<div class="history-item-delete">
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
<!-- 歌曲列表 -->
<template v-if="currentCategory === 'songs'">
<div
v-for="(item, index) in displayList"
:key="item.id"
class="history-item"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 30)"
>
<song-item class="history-item-content" :item="item" @play="handlePlay" />
<div class="history-item-count min-w-[60px]" v-show="currentTab === 'local'">
{{ t('history.playCount', { count: item.count }) }}
</div>
<div class="history-item-delete" v-show="currentTab === 'local'">
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
</div>
</div>
</template>
<!-- 歌单列表 -->
<template v-if="currentCategory === 'playlists'">
<playlist-item
v-for="(item, index) in displayList"
:key="item.id"
:item="item"
:show-count="currentTab === 'local'"
:show-delete="currentTab === 'local'"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 30)"
@click="handlePlaylistClick(item)"
@delete="handleDelPlaylist(item)"
/>
</template>
<!-- 专辑列表 -->
<template v-if="currentCategory === 'albums'">
<album-item
v-for="(item, index) in displayList"
:key="item.id"
:item="item"
:show-count="currentTab === 'local'"
:show-delete="currentTab === 'local'"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 30)"
@click="handleAlbumClick(item)"
@delete="handleDelAlbum(item)"
/>
</template>
<div v-if="displayList.length === 0 && !loading" class="no-data">
{{ t('history.noData') }}
</div>
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<div v-if="noMore" class="no-more-tip">{{ t('common.noMore') }}</div>
<div v-if="noMore && displayList.length > 0" class="no-more-tip">
{{ t('common.noMore') }}
</div>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui';
import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { processBilibiliVideos } from '@/api/bilibili';
import { getListDetail } from '@/api/list';
import { getAlbumDetail } from '@/api/music';
import { getMusicDetail } from '@/api/music';
import { getRecentAlbums, getRecentPlaylists, getRecentSongs } from '@/api/user';
import AlbumItem from '@/components/common/AlbumItem.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import PlaylistItem from '@/components/common/PlaylistItem.vue';
import SongItem from '@/components/common/SongItem.vue';
import { useAlbumHistory } from '@/hooks/AlbumHistoryHook';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { usePlaylistHistory } from '@/hooks/PlaylistHistoryHook';
import { usePlayerStore } from '@/store/modules/player';
import { useUserStore } from '@/store/modules/user';
import type { SongResult } from '@/types/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
// 扩展历史记录类型以包含 playTime
interface HistoryRecord extends Partial<SongResult> {
id: string | number;
playTime?: number;
score?: number;
source?: 'netease' | 'bilibili';
count?: number;
recordSource?: 'local' | 'cloud';
sources?: ('local' | 'cloud')[];
bilibiliData?: {
bvid: string;
cid: number;
};
}
const { t } = useI18n();
const message = useMessage();
const router = useRouter();
const { delMusic, musicList } = useMusicHistory();
const { delPlaylist, playlistList } = usePlaylistHistory();
const { delAlbum, albumList } = useAlbumHistory();
const userStore = useUserStore();
const scrollbarRef = ref();
const loading = ref(false);
const noMore = ref(false);
const displayList = ref<SongResult[]>([]);
const displayList = ref<any[]>([]);
const playerStore = usePlayerStore();
const hasLoaded = ref(false);
const currentCategory = ref<'songs' | 'playlists' | 'albums'>('songs');
const currentTab = ref<'local' | 'cloud'>('local');
const cloudRecords = ref<HistoryRecord[]>([]);
const cloudPlaylists = ref<any[]>([]);
const cloudAlbums = ref<any[]>([]);
// 无限滚动相关配置
const pageSize = 100;
const currentPage = ref(1);
// 获取当前页的音乐详情
const getHistorySongs = async () => {
if (musicList.value.length === 0) {
// 获取云端播放记录
const getCloudRecords = async () => {
if (!userStore.user?.userId || userStore.loginType !== 'cookie') {
message.warning(t('history.needLogin'));
return [];
}
try {
const res = await getRecentSongs(1000);
if (res.data?.data?.list) {
return res.data.data.list.map((item: any) => ({
id: item.data?.id,
playTime: item.playTime,
source: 'netease',
count: 1,
data: item.data
}));
}
return [];
} catch (error: any) {
console.error(t('history.getCloudRecordFailed'), error);
if (error?.response?.status !== 301 && error?.response?.data?.code !== -2) {
message.error(t('history.getCloudRecordFailed'));
}
return [];
}
};
// 获取云端歌单播放记录
const getCloudPlaylists = async () => {
if (!userStore.user?.userId || userStore.loginType !== 'cookie') {
message.warning(t('history.needLogin'));
return [];
}
try {
const res = await getRecentPlaylists(100);
if (res.data?.data?.list) {
return res.data.data.list.map((item: any) => ({
id: item.data?.id,
name: item.data?.name,
coverImgUrl: item.data?.coverImgUrl,
picUrl: item.data?.picUrl,
trackCount: item.data?.trackCount,
playCount: item.data?.playCount,
creator: item.data?.creator,
playTime: item.playTime
}));
}
return [];
} catch (error: any) {
console.error(t('history.getCloudRecordFailed'), error);
if (error?.response?.status !== 301 && error?.response?.data?.code !== -2) {
message.error(t('history.getCloudRecordFailed'));
}
return [];
}
};
// 获取云端专辑播放记录
const getCloudAlbums = async () => {
if (!userStore.user?.userId || userStore.loginType !== 'cookie') {
message.warning(t('history.needLogin'));
return [];
}
try {
const res = await getRecentAlbums(100);
if (res.data?.data?.list) {
return res.data.data.list.map((item: any) => ({
id: item.data?.id,
name: item.data?.name,
picUrl: item.data?.picUrl,
size: item.data?.size,
artist: item.data?.artist,
playTime: item.playTime
}));
}
return [];
} catch (error: any) {
console.error(t('history.getCloudRecordFailed'), error);
if (error?.response?.status !== 301 && error?.response?.data?.code !== -2) {
message.error(t('history.getCloudRecordFailed'));
}
return [];
}
};
// 根据当前分类和tab获取要显示的列表
const getCurrentList = (): any[] => {
if (currentCategory.value === 'songs') {
switch (currentTab.value) {
case 'local':
return musicList.value;
case 'cloud':
return cloudRecords.value.filter((item) => item.id);
}
} else if (currentCategory.value === 'playlists') {
switch (currentTab.value) {
case 'local':
return playlistList.value;
case 'cloud':
return cloudPlaylists.value;
}
} else if (currentCategory.value === 'albums') {
switch (currentTab.value) {
case 'local':
return albumList.value;
case 'cloud':
return cloudAlbums.value;
}
}
return [];
};
// 处理分类切换
const handleCategoryChange = async (value: 'songs' | 'playlists' | 'albums') => {
currentCategory.value = value;
currentPage.value = 1;
noMore.value = false;
displayList.value = [];
// 如果切换到云端,且还没有加载对应的云端数据,则加载
if (currentTab.value === 'cloud') {
loading.value = true;
if (value === 'songs' && cloudRecords.value.length === 0) {
cloudRecords.value = await getCloudRecords();
} else if (value === 'playlists' && cloudPlaylists.value.length === 0) {
cloudPlaylists.value = await getCloudPlaylists();
} else if (value === 'albums' && cloudAlbums.value.length === 0) {
cloudAlbums.value = await getCloudAlbums();
}
loading.value = false;
}
await loadHistoryData();
};
// 处理歌单点击
const handlePlaylistClick = async (item: any) => {
try {
const res = await getListDetail(item.id);
if (res.data?.playlist) {
navigateToMusicList(router, {
id: item.id,
type: 'playlist',
name: item.name,
songList: res.data.playlist.tracks || [],
listInfo: res.data.playlist,
canRemove: false
});
}
} catch (error) {
console.error('打开歌单失败:', error);
message.error('打开歌单失败');
}
};
// 处理专辑点击
const handleAlbumClick = async (item: any) => {
try {
const res = await getAlbumDetail(item.id.toString());
if (res.data?.album && res.data?.songs) {
const albumData = res.data.album;
const songs = res.data.songs.map((song: any) => ({
...song,
picUrl: albumData.picUrl
}));
navigateToMusicList(router, {
id: item.id,
type: 'album',
name: albumData.name,
songList: songs,
listInfo: albumData,
canRemove: false
});
}
} catch (error) {
console.error('打开专辑失败:', error);
message.error('打开专辑失败');
}
};
// 删除歌单记录
const handleDelPlaylist = (item: any) => {
delPlaylist(item);
displayList.value = displayList.value.filter((playlist) => playlist.id !== item.id);
};
// 删除专辑记录
const handleDelAlbum = (item: any) => {
delAlbum(item);
displayList.value = displayList.value.filter((album) => album.id !== item.id);
};
// 加载历史数据(根据当前分类)
const loadHistoryData = async () => {
const currentList = getCurrentList();
if (currentList.length === 0) {
displayList.value = [];
return;
}
@@ -66,70 +366,77 @@ const getHistorySongs = async () => {
try {
const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentPageItems = musicList.value.slice(startIndex, endIndex);
const currentPageItems = currentList.slice(startIndex, endIndex);
// 分离网易云音乐和B站视频
const neteaseItems = currentPageItems.filter((item) => item.source !== 'bilibili');
const bilibiliItems = currentPageItems.filter((item) => item.source === 'bilibili');
// 根据分类处理不同的数据
if (currentCategory.value === 'songs') {
// 处理歌曲数据
const neteaseItems = currentPageItems.filter((item) => item.source !== 'bilibili');
const bilibiliItems = currentPageItems.filter((item) => item.source === 'bilibili');
// 处理网易云音乐
let neteaseSongs: SongResult[] = [];
if (neteaseItems.length > 0) {
const currentIds = neteaseItems.map((item) => item.id as number);
const res = await getMusicDetail(currentIds);
if (res.data.songs) {
neteaseSongs = res.data.songs.map((song: SongResult) => {
const historyItem = neteaseItems.find((item) => item.id === song.id);
return {
...song,
picUrl: song.al?.picUrl || '',
count: historyItem?.count || 0,
source: 'netease'
};
});
}
}
// 处理B站视频 - 使用公用方法
const bilibiliIds = bilibiliItems
.map((item) => `${item.bilibiliData?.bvid}--1--${item.bilibiliData?.cid}`)
.filter((id) => id && !id.includes('undefined'));
const bilibiliSongs = await processBilibiliVideos(bilibiliIds);
// 添加count信息
bilibiliSongs.forEach((song) => {
const historyItem = bilibiliItems.find(
(item) =>
item.bilibiliData?.bvid === song.bilibiliData?.bvid &&
item.bilibiliData?.cid === song.bilibiliData?.cid
);
if (historyItem) {
song.count = historyItem.count || 0;
}
});
// 合并两种来源的数据,并保持原有顺序
const newSongs = currentPageItems
.map((item) => {
if (item.source === 'bilibili') {
return bilibiliSongs.find(
(song) =>
song.bilibiliData?.bvid === item.bilibiliData?.bvid &&
song.bilibiliData?.cid === item.bilibiliData?.cid
);
let neteaseSongs: SongResult[] = [];
if (neteaseItems.length > 0) {
const currentIds = neteaseItems.map((item) => item.id as number);
const res = await getMusicDetail(currentIds);
if (res.data.songs) {
neteaseSongs = res.data.songs.map((song: SongResult) => {
const historyItem = neteaseItems.find((item) => item.id === song.id);
return {
...song,
picUrl: song.al?.picUrl || '',
count: historyItem?.count || 0,
source: 'netease'
};
});
}
return neteaseSongs.find((song) => song.id === item.id);
})
.filter((song): song is SongResult => !!song);
}
if (currentPage.value === 1) {
displayList.value = newSongs;
const bilibiliIds = bilibiliItems
.map((item) => `${item.bilibiliData?.bvid}--1--${item.bilibiliData?.cid}`)
.filter((id) => id && !id.includes('undefined'));
const bilibiliSongs = await processBilibiliVideos(bilibiliIds);
bilibiliSongs.forEach((song) => {
const historyItem = bilibiliItems.find(
(item) =>
item.bilibiliData?.bvid === song.bilibiliData?.bvid &&
item.bilibiliData?.cid === song.bilibiliData?.cid
);
if (historyItem) {
song.count = historyItem.count || 0;
}
});
const newSongs = currentPageItems
.map((item) => {
if (item.source === 'bilibili') {
return bilibiliSongs.find(
(song) =>
song.bilibiliData?.bvid === item.bilibiliData?.bvid &&
song.bilibiliData?.cid === item.bilibiliData?.cid
);
}
return neteaseSongs.find((song) => song.id === item.id);
})
.filter((song): song is SongResult => !!song);
if (currentPage.value === 1) {
displayList.value = newSongs;
} else {
displayList.value = [...displayList.value, ...newSongs];
}
} else {
displayList.value = [...displayList.value, ...newSongs];
// 处理歌单和专辑数据(直接显示,不需要额外请求)
if (currentPage.value === 1) {
displayList.value = currentPageItems;
} else {
displayList.value = [...displayList.value, ...currentPageItems];
}
}
noMore.value = displayList.value.length >= musicList.value.length;
const totalLength = getCurrentList().length;
noMore.value = displayList.value.length >= totalLength;
} catch (error) {
console.error(t('history.getHistoryFailed'), error);
} finally {
@@ -140,11 +447,11 @@ const getHistorySongs = async () => {
// 处理滚动事件
const handleScroll = (e: any) => {
const { scrollTop, scrollHeight, offsetHeight } = e.target;
const threshold = 100; // 距离底部多少像素时加载更多
const threshold = 100;
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
currentPage.value++;
getHistorySongs();
loadHistoryData();
}
};
@@ -153,22 +460,45 @@ const handlePlay = () => {
playerStore.setPlayList(displayList.value);
};
// 处理 tab 切换
const handleTabChange = async (value: 'local' | 'cloud') => {
currentTab.value = value;
currentPage.value = 1;
noMore.value = false;
displayList.value = [];
// 如果切换到云端,且还没有加载对应的云端数据,则加载
if (value === 'cloud') {
loading.value = true;
if (currentCategory.value === 'songs' && cloudRecords.value.length === 0) {
cloudRecords.value = await getCloudRecords();
} else if (currentCategory.value === 'playlists' && cloudPlaylists.value.length === 0) {
cloudPlaylists.value = await getCloudPlaylists();
} else if (currentCategory.value === 'albums' && cloudAlbums.value.length === 0) {
cloudAlbums.value = await getCloudAlbums();
}
loading.value = false;
}
await loadHistoryData();
};
onMounted(async () => {
if (!hasLoaded.value) {
await getHistorySongs();
await loadHistoryData();
hasLoaded.value = true;
}
});
// 监听历史列表变化,变化时重置并重新加载
watch(
musicList,
[musicList, playlistList, albumList],
async () => {
hasLoaded.value = false;
currentPage.value = 1;
noMore.value = false;
await getHistorySongs();
hasLoaded.value = true;
if (hasLoaded.value) {
currentPage.value = 1;
noMore.value = false;
await loadHistoryData();
}
},
{ deep: true }
);
@@ -191,6 +521,14 @@ const handleDelMusic = async (item: SongResult) => {
@apply text-gray-900 dark:text-white;
}
.category-tabs-wrapper {
@apply px-4 mb-2;
}
.tabs-wrapper {
@apply px-4;
}
.history-list-content {
@apply mt-2 pb-28 px-4;
.history-item {
@@ -220,4 +558,27 @@ const handleDelMusic = async (item: SongResult) => {
@apply text-center py-4 text-sm;
@apply text-gray-500 dark:text-gray-400;
}
.no-data {
@apply text-center py-8 text-gray-500 dark:text-gray-400;
}
:deep(.n-tabs-rail) {
@apply rounded-xl overflow-hidden !important;
.n-tabs-capsule {
@apply rounded-xl !important;
}
}
.category-tabs-wrapper {
:deep(.n-tabs-rail) {
@apply rounded-xl overflow-hidden bg-white dark:bg-dark-300 !important;
.n-tabs-capsule {
@apply rounded-xl bg-green-500 dark:bg-green-600 !important;
}
.n-tabs-tab--active {
@apply text-white !important;
}
}
}
</style>