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

View File

@@ -40,7 +40,8 @@ export default defineConfig({
],
publicDir: resolve('resources'),
server: {
host: '0.0.0.0'
host: '0.0.0.0',
port: 2389
}
}
});

View File

@@ -1,5 +1,21 @@
export default {
title: 'Play History',
playCount: '{count}',
getHistoryFailed: 'Failed to get play history'
getHistoryFailed: 'Failed to get play history',
categoryTabs: {
songs: 'Songs',
playlists: 'Playlists',
albums: 'Albums'
},
tabs: {
all: 'All Records',
local: 'Local Records',
cloud: 'Cloud Records'
},
getCloudRecordFailed: 'Failed to get cloud records',
needLogin: 'Please login with cookie to view cloud records',
merging: 'Merging records...',
noDescription: 'No description',
noData: 'No records',
newKey: 'New translation'
};

View File

@@ -1,5 +1,13 @@
export default {
title: '再生履歴',
playCount: '{count}',
getHistoryFailed: '履歴の取得に失敗しました'
getHistoryFailed: '履歴の取得に失敗しました',
tabs: {
all: 'すべての記録',
local: 'ローカル記録',
cloud: 'クラウド記録'
},
getCloudRecordFailed: 'クラウド記録の取得に失敗しました',
needLogin: 'cookieを使用してログインしてクラウド記録を表示できます',
merging: '記録を統合中...'
};

View File

@@ -1,5 +1,13 @@
export default {
title: '재생 기록',
playCount: '{count}',
getHistoryFailed: '기록 가져오기 실패'
getHistoryFailed: '기록 가져오기 실패',
tabs: {
all: '전체 기록',
local: '로컬 기록',
cloud: '클라우드 기록'
},
getCloudRecordFailed: '클라우드 기록 가져오기 실패',
needLogin: 'cookie를 사용하여 로그인하여 클라우드 기록을 볼 수 있습니다',
merging: '기록 병합 중...'
};

View File

@@ -1,5 +1,20 @@
export default {
title: '播放历史',
playCount: '{count}',
getHistoryFailed: '获取历史记录失败'
getHistoryFailed: '获取历史记录失败',
categoryTabs: {
songs: '歌曲',
playlists: '歌单',
albums: '专辑'
},
tabs: {
all: '全部记录',
local: '本地记录',
cloud: '云端记录'
},
getCloudRecordFailed: '获取云端记录失败',
needLogin: '请使用cookie登录以查看云端记录',
merging: '正在合并记录...',
noDescription: '暂无描述',
noData: '暂无记录'
};

View File

@@ -1,5 +1,20 @@
export default {
title: '播放歷史',
playCount: '{count}',
getHistoryFailed: '取得歷史記錄失敗'
getHistoryFailed: '取得歷史記錄失敗',
categoryTabs: {
songs: '歌曲',
playlists: '歌單',
albums: '專輯'
},
tabs: {
all: '全部記錄',
local: '本地記錄',
cloud: '雲端記錄'
},
getCloudRecordFailed: '取得雲端記錄失敗',
needLogin: '請使用cookie登入以查看雲端記錄',
merging: '正在合併記錄...',
noDescription: '暫無描述',
noData: '暫無記錄'
};

View File

@@ -21,7 +21,9 @@ import { useRouter } from 'vue-router';
import TrafficWarningDrawer from '@/components/TrafficWarningDrawer.vue';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { useUserStore } from '@/store/modules/user';
import { isElectron, isLyricWindow } from '@/utils';
import { checkLoginStatus } from '@/utils/auth';
import { initAudioListeners, initMusicHook } from './hooks/MusicHook';
import { audioService } from './services/audioService';
@@ -31,6 +33,7 @@ import { useAppShortcuts } from './utils/appShortcuts';
const { locale } = useI18n();
const settingsStore = useSettingsStore();
const playerStore = usePlayerStore();
const userStore = useUserStore();
const router = useRouter();
// 监听语言变化
@@ -72,6 +75,17 @@ if (!isLyricWindow.value) {
settingsStore.initializeSettings();
settingsStore.initializeTheme();
settingsStore.initializeSystemFonts();
// 初始化登录状态 - 从 localStorage 恢复用户信息和登录类型
const loginInfo = checkLoginStatus();
if (loginInfo.isLoggedIn) {
if (loginInfo.user && !userStore.user) {
userStore.setUser(loginInfo.user);
}
if (loginInfo.loginType && !userStore.loginType) {
userStore.setLoginType(loginInfo.loginType);
}
}
}
handleSetLanguage(settingsStore.setData.language);

View File

@@ -20,6 +20,33 @@ export function getUserRecord(uid: number, type: number = 0) {
} as any);
}
// 最近播放-歌曲
// /record/recent/song
export function getRecentSongs(limit: number = 100) {
return request.get('/record/recent/song', {
params: { limit },
noRetry: true
} as any);
}
// 最近播放-歌单
// /record/recent/playlist
export function getRecentPlaylists(limit: number = 100) {
return request.get('/record/recent/playlist', {
params: { limit },
noRetry: true
} as any);
}
// 最近播放-专辑
// /record/recent/album
export function getRecentAlbums(limit: number = 100) {
return request.get('/record/recent/album', {
params: { limit },
noRetry: true
} as any);
}
// 获取用户关注列表
// /user/follows?uid=32953014
export function getUserFollows(uid: number, limit: number = 30, offset: number = 0) {

View File

@@ -0,0 +1,112 @@
<template>
<div class="album-item" @click="handleClick">
<n-image
:src="getImgUrl(item.picUrl || '', '100y100')"
class="album-item-img"
lazy
preview-disabled
/>
<div class="album-item-info">
<div class="album-item-name">
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="album-item-desc">
{{ getDescription() }}
</div>
</div>
<div v-if="showCount && item.count" class="album-item-count">
{{ item.count }}
</div>
<div v-if="showDelete" class="album-item-delete" @click.stop="handleDelete">
<i class="iconfont icon-close"></i>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { AlbumHistoryItem } from '@/hooks/AlbumHistoryHook';
import { getImgUrl } from '@/utils';
interface Props {
item: AlbumHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showCount: false,
showDelete: false
});
const emit = defineEmits<{
click: [item: AlbumHistoryItem];
delete: [item: AlbumHistoryItem];
}>();
const { t } = useI18n();
const getDescription = () => {
const parts: string[] = [];
if (props.item.artist?.name) {
parts.push(props.item.artist.name);
}
if (props.item.size !== undefined) {
parts.push(t('user.album.songCount', { count: props.item.size }));
}
return parts.join(' · ') || t('history.noDescription');
};
const handleClick = () => {
emit('click', props.item);
};
const handleDelete = () => {
emit('delete', props.item);
};
</script>
<style scoped lang="scss">
.album-item {
@apply flex items-center px-2 py-2 rounded-xl cursor-pointer;
@apply transition-all duration-200;
@apply bg-light-100 dark:bg-dark-100;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
@apply mb-2;
&-img {
@apply flex items-center justify-center rounded-xl;
@apply w-[60px] h-[60px] flex-shrink-0;
@apply bg-light-300 dark:bg-dark-300;
}
&-info {
@apply ml-3 flex-1 min-w-0;
}
&-name {
@apply text-gray-900 dark:text-white text-base mb-1;
}
&-desc {
@apply text-sm text-gray-500 dark:text-gray-400;
}
&-count {
@apply px-4 text-lg text-center min-w-[60px];
@apply text-gray-600 dark:text-gray-400;
}
&-delete {
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
@apply border-gray-400 dark:border-gray-600;
@apply text-gray-600 dark:text-gray-400;
@apply hover:border-red-500 hover:text-red-500;
@apply transition-all;
}
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="playlist-item" @click="handleClick">
<n-image
:src="getImgUrl(item.coverImgUrl || item.picUrl || '', '100y100')"
class="playlist-item-img"
lazy
preview-disabled
/>
<div class="playlist-item-info">
<div class="playlist-item-name">
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="playlist-item-desc">
{{ getDescription() }}
</div>
</div>
<div v-if="showCount && item.count" class="playlist-item-count">
{{ item.count }}
</div>
<div v-if="showDelete" class="playlist-item-delete" @click.stop="handleDelete">
<i class="iconfont icon-close"></i>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { PlaylistHistoryItem } from '@/hooks/PlaylistHistoryHook';
import { getImgUrl } from '@/utils';
interface Props {
item: PlaylistHistoryItem;
showCount?: boolean;
showDelete?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showCount: false,
showDelete: false
});
const emit = defineEmits<{
click: [item: PlaylistHistoryItem];
delete: [item: PlaylistHistoryItem];
}>();
const { t } = useI18n();
const getDescription = () => {
const parts: string[] = [];
if (props.item.trackCount !== undefined) {
parts.push(t('user.playlist.trackCount', { count: props.item.trackCount }));
}
if (props.item.creator?.nickname) {
parts.push(props.item.creator.nickname);
}
return parts.join(' · ') || t('history.noDescription');
};
const handleClick = () => {
emit('click', props.item);
};
const handleDelete = () => {
emit('delete', props.item);
};
</script>
<style scoped lang="scss">
.playlist-item {
@apply flex items-center px-2 py-2 rounded-xl cursor-pointer;
@apply transition-all duration-200;
@apply bg-light-100 dark:bg-dark-100;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
@apply mb-2;
&-img {
@apply flex items-center justify-center rounded-xl;
@apply w-[60px] h-[60px] flex-shrink-0;
@apply bg-light-300 dark:bg-dark-300;
}
&-info {
@apply ml-3 flex-1 min-w-0;
}
&-name {
@apply text-gray-900 dark:text-white text-base mb-1;
}
&-desc {
@apply text-sm text-gray-500 dark:text-gray-400;
}
&-count {
@apply px-4 text-lg text-center min-w-[60px];
@apply text-gray-600 dark:text-gray-400;
}
&-delete {
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
@apply border-gray-400 dark:border-gray-600;
@apply text-gray-600 dark:text-gray-400;
@apply hover:border-red-500 hover:text-red-500;
@apply transition-all;
}
}
</style>

View File

@@ -0,0 +1,63 @@
import { useLocalStorage } from '@vueuse/core';
import { ref, watch } from 'vue';
// 专辑历史记录类型
export interface AlbumHistoryItem {
id: number;
name: string;
picUrl?: string;
size?: number; // 歌曲数量
artist?: {
name: string;
id: number;
};
count?: number; // 播放次数
lastPlayTime?: number; // 最后播放时间
}
export const useAlbumHistory = () => {
const albumHistory = useLocalStorage<AlbumHistoryItem[]>('albumHistory', []);
const addAlbum = (album: AlbumHistoryItem) => {
const index = albumHistory.value.findIndex((item) => item.id === album.id);
const now = Date.now();
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]);
} else {
// 如果不存在,添加新记录
albumHistory.value.unshift({
...album,
count: 1,
lastPlayTime: now
});
}
};
const delAlbum = (album: AlbumHistoryItem) => {
const index = albumHistory.value.findIndex((item) => item.id === album.id);
if (index !== -1) {
albumHistory.value.splice(index, 1);
}
};
const albumList = ref(albumHistory.value);
watch(
() => albumHistory.value,
() => {
albumList.value = albumHistory.value;
},
{ deep: true }
);
return {
albumHistory,
albumList,
addAlbum,
delAlbum
};
};

View File

@@ -0,0 +1,65 @@
import { useLocalStorage } from '@vueuse/core';
import { ref, watch } from 'vue';
// 歌单历史记录类型
export interface PlaylistHistoryItem {
id: number;
name: string;
coverImgUrl?: string;
picUrl?: string; // 兼容字段
trackCount?: number;
playCount?: number;
creator?: {
nickname: string;
userId: number;
};
count?: number; // 播放次数
lastPlayTime?: number; // 最后播放时间
}
export const usePlaylistHistory = () => {
const playlistHistory = useLocalStorage<PlaylistHistoryItem[]>('playlistHistory', []);
const addPlaylist = (playlist: PlaylistHistoryItem) => {
const index = playlistHistory.value.findIndex((item) => item.id === playlist.id);
const now = Date.now();
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]);
} else {
// 如果不存在,添加新记录
playlistHistory.value.unshift({
...playlist,
count: 1,
lastPlayTime: now
});
}
};
const delPlaylist = (playlist: PlaylistHistoryItem) => {
const index = playlistHistory.value.findIndex((item) => item.id === playlist.id);
if (index !== -1) {
playlistHistory.value.splice(index, 1);
}
};
const playlistList = ref(playlistHistory.value);
watch(
() => playlistHistory.value,
() => {
playlistList.value = playlistHistory.value;
},
{ deep: true }
);
return {
playlistHistory,
playlistList,
addPlaylist,
delPlaylist
};
};

View File

@@ -3,7 +3,7 @@ import { ref } from 'vue';
import { logout } from '@/api/login';
import { getLikedList } from '@/api/music';
import { getUserAlbumSublist } from '@/api/user';
import { getUserAlbumSublist, getUserPlaylist } from '@/api/user';
import { clearLoginStatus } from '@/utils/auth';
interface UserData {
@@ -30,6 +30,10 @@ export const useUserStore = defineStore('user', () => {
const searchType = ref(1);
// 收藏的专辑 ID 列表
const collectedAlbumIds = ref<Set<number>>(new Set());
// 用户的歌单列表
const playList = ref<any[]>([]);
// 用户的专辑列表
const albumList = ref<any[]>([]);
// 方法
const setUser = (userData: UserData) => {
@@ -51,6 +55,9 @@ export const useUserStore = defineStore('user', () => {
await logout();
user.value = null;
loginType.value = null;
collectedAlbumIds.value.clear();
playList.value = [];
albumList.value = [];
clearLoginStatus();
// 刷新
window.location.reload();
@@ -59,6 +66,9 @@ export const useUserStore = defineStore('user', () => {
// 即使API调用失败也要清除本地状态
user.value = null;
loginType.value = null;
collectedAlbumIds.value.clear();
playList.value = [];
albumList.value = [];
clearLoginStatus();
window.location.reload();
}
@@ -72,7 +82,41 @@ export const useUserStore = defineStore('user', () => {
searchType.value = type;
};
// 初始化收藏的专辑列表
// 初始化歌单列表
const initializePlaylist = async () => {
if (!user.value) {
playList.value = [];
return;
}
try {
const { data } = await getUserPlaylist(user.value.userId, 1000, 0);
playList.value = data?.playlist || [];
console.log(`已加载 ${playList.value.length} 个歌单`);
} catch (error) {
console.error('获取歌单列表失败:', error);
playList.value = [];
}
};
// 初始化专辑列表
const initializeAlbumList = async () => {
if (!user.value || !localStorage.getItem('token')) {
albumList.value = [];
return;
}
try {
const { data } = await getUserAlbumSublist({ limit: 1000, offset: 0 });
albumList.value = data?.data || [];
console.log(`已加载 ${albumList.value.length} 个收藏专辑`);
} catch (error) {
console.error('获取专辑列表失败:', error);
albumList.value = [];
}
};
// 初始化收藏的专辑ID列表
const initializeCollectedAlbums = async () => {
if (!user.value || !localStorage.getItem('token')) {
collectedAlbumIds.value.clear();
@@ -83,7 +127,7 @@ export const useUserStore = defineStore('user', () => {
const { data } = await getUserAlbumSublist({ limit: 1000, offset: 0 });
const albumIds = (data?.data || []).map((album: any) => album.id);
collectedAlbumIds.value = new Set(albumIds);
console.log(`已加载 ${albumIds.length} 个收藏专辑`);
console.log(`已加载 ${albumIds.length} 个收藏专辑ID`);
} catch (error) {
console.error('获取收藏专辑列表失败:', error);
collectedAlbumIds.value.clear();
@@ -113,8 +157,12 @@ export const useUserStore = defineStore('user', () => {
// 如果用户已登录,获取收藏列表
if (localStorage.getItem('token')) {
try {
// 同时初始化收藏专辑列表
await initializeCollectedAlbums();
// 并行加载歌单、专辑和收藏ID列表
await Promise.all([
initializePlaylist(),
initializeAlbumList(),
initializeCollectedAlbums()
]);
const { data } = await getLikedList(savedUser.userId);
return data?.ids || [];
@@ -134,6 +182,8 @@ export const useUserStore = defineStore('user', () => {
searchValue,
searchType,
collectedAlbumIds,
playList,
albumList,
// 方法
setUser,
@@ -142,6 +192,8 @@ export const useUserStore = defineStore('user', () => {
setSearchValue,
setSearchType,
initializeUser,
initializePlaylist,
initializeAlbumList,
initializeCollectedAlbums,
addCollectedAlbum,
removeCollectedAlbum,

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>

View File

@@ -239,6 +239,8 @@ import {
} from '@/api/music';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import { useAlbumHistory } from '@/hooks/AlbumHistoryHook';
import { usePlaylistHistory } from '@/hooks/PlaylistHistoryHook';
import { useDownload } from '@/hooks/useDownload';
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
import { SongResult } from '@/types/music';
@@ -252,6 +254,8 @@ const musicStore = useMusicStore();
const recommendStore = useRecommendStore();
const userStore = useUserStore();
const message = useMessage();
const { addPlaylist } = usePlaylistHistory();
const { addAlbum } = useAlbumHistory();
// 从路由参数或状态管理获取数据
const loading = ref(false);
@@ -631,7 +635,7 @@ const handlePlay = async () => {
playerStore.setPlayList(filteredSongs.value.map(formatSong));
return;
}
saveHistory();
// 如果完整播放列表已加载完成
if (isFullPlaylistLoaded.value && completePlaylist.value.length > 0) {
playerStore.setPlayList(completePlaylist.value.map(formatSong));
@@ -874,11 +878,27 @@ const toggleCollect = async () => {
// 更新收藏状态
if (type === 'album') {
// 专辑:更新 store 中的收藏状态
// 专辑:更新 store 中的收藏状态和专辑列表
if (isCollected.value) {
// 添加到收藏ID集合
userStore.addCollectedAlbum(listInfo.value.id);
// 添加到专辑列表
const albumData = {
id: listInfo.value.id,
name: listInfo.value.name,
picUrl: listInfo.value.picUrl || listInfo.value.coverImgUrl,
size: listInfo.value.size,
artist: listInfo.value.artist || listInfo.value.artists?.[0]
};
userStore.albumList.unshift(albumData);
} else {
// 从收藏ID集合中移除
userStore.removeCollectedAlbum(listInfo.value.id);
// 从专辑列表中移除
const index = userStore.albumList.findIndex((album) => album.id === listInfo.value.id);
if (index !== -1) {
userStore.albumList.splice(index, 1);
}
}
(listInfo.value as any).isSub = isCollected.value;
} else {
@@ -899,7 +919,7 @@ const toggleCollect = async () => {
// 播放全部
const handlePlayAll = () => {
if (displayedSongs.value.length === 0) return;
saveHistory();
// 如果有搜索关键词,只播放过滤后的歌曲
if (searchKeyword.value) {
playerStore.setPlayList(filteredSongs.value.map(formatSong));
@@ -914,6 +934,31 @@ const handlePlayAll = () => {
playerStore.setPlay(formatSong(displayedSongs.value[0]));
};
const saveHistory = () => {
if (listInfo.value?.id) {
if (isAlbum.value) {
// 保存专辑播放记录
addAlbum({
id: listInfo.value.id,
name: listInfo.value.name || '',
picUrl: listInfo.value.picUrl || listInfo.value.coverImgUrl || '',
size: listInfo.value.size || displayedSongs.value.length,
artist: listInfo.value.artist || listInfo.value.artists?.[0]
});
} else if (route.query.type === 'playlist') {
// 保存歌单播放记录
addPlaylist({
id: listInfo.value.id,
name: listInfo.value.name || '',
coverImgUrl: listInfo.value.coverImgUrl || listInfo.value.picUrl || '',
trackCount: listInfo.value.trackCount || displayedSongs.value.length,
playCount: listInfo.value.playCount,
creator: listInfo.value.creator
});
}
}
};
// 添加到播放列表末尾
const addToPlaylist = () => {
if (displayedSongs.value.length === 0) return;

View File

@@ -144,7 +144,6 @@ const userStore = useUserStore();
const playerStore = usePlayerStore();
const router = useRouter();
const userDetail = ref<IUserDetail>();
const playList = ref<any[]>([]);
const recordList = ref();
const infoLoading = ref(false);
const mounted = ref(true);
@@ -159,34 +158,27 @@ const tabs = [
{ key: 'album', label: 'user.tabs.album' }
];
const currentTab = ref('created');
const albumList = ref<any[]>([]);
const user = computed(() => userStore.user);
// 创建的歌单(当前用户创建的)
const createdPlaylists = computed(() => {
if (!user.value) return [];
return playList.value.filter((item) => item.creator?.userId === user.value!.userId);
return userStore.playList.filter((item) => item.creator?.userId === user.value!.userId);
});
// 收藏的歌单(当前用户创建的)
// 收藏的歌单(当前用户收藏的)
const favoritePlaylists = computed(() => {
if (!user.value) return [];
return playList.value.filter((item) => item.creator?.userId !== user.value!.userId);
return userStore.playList.filter((item) => item.creator?.userId !== user.value!.userId);
});
// 当前显示的列表(根据 tab 切换)
const currentList = computed(() => {
switch (currentTab.value) {
case 'created':
return createdPlaylists.value;
case 'favorite':
return favoritePlaylists.value;
case 'album':
return albumList.value;
default:
return [];
if (currentTab.value === 'album') {
return userStore.albumList;
}
return currentTab.value === 'created' ? createdPlaylists.value : favoritePlaylists.value;
});
// 获取封面图片 URL
@@ -226,28 +218,18 @@ onBeforeUnmount(() => {
// 检查登录状态
const checkLoginStatus = () => {
// 优先使用 userStore 的状态
// userStore 的状态已经在 App.vue 中全局初始化,这里只需要检查
if (userStore.user && userStore.loginType) {
return true;
}
// 如果 store 中没有数据,尝试从 localStorage 恢复
// 如果还是没有登录信息,跳转到登录页
const loginInfo = checkAuthStatus();
if (!loginInfo.isLoggedIn) {
!isMobile.value && router.push('/login');
return false;
}
// 恢复用户数据和登录类型到 store
if (!userStore.user && loginInfo.user) {
userStore.setUser(loginInfo.user);
}
if (!userStore.loginType && loginInfo.loginType) {
userStore.setLoginType(loginInfo.loginType);
}
return true;
};
@@ -270,22 +252,28 @@ const loadData = async () => {
return;
}
// 使用 Promise.all 并行请求提高效率
const [userDetailRes, playlistRes, recordRes] = await Promise.all([
getUserDetail(user.value.userId),
getUserPlaylist(user.value.userId),
getUserRecord(user.value.userId)
]);
// 如果 store 中还没有数据,则加载
const promises = [getUserDetail(user.value.userId), getUserRecord(user.value.userId)];
if (userStore.playList.length === 0) {
promises.push(getUserPlaylist(user.value.userId));
}
const results = await Promise.all(promises);
if (!mounted.value) return;
userDetail.value = userDetailRes.data;
playList.value = playlistRes.data.playlist;
recordList.value = recordRes.data.allData.map((item: any) => ({
userDetail.value = results[0].data;
recordList.value = results[1].data.allData.map((item: any) => ({
...item,
...item.song,
picUrl: item.song.al.picUrl
}));
// 如果加载了歌单,更新 store
if (results.length > 2 && results[2].data?.playlist) {
userStore.playList = results[2].data.playlist;
}
} catch (error: any) {
console.error('加载用户页面失败:', error);
if (error.response?.status === 401) {
@@ -304,11 +292,17 @@ const loadData = async () => {
// 加载专辑列表
const loadAlbumList = async () => {
// 如果 store 中已经有数据,直接返回
if (userStore.albumList.length > 0) {
return;
}
try {
infoLoading.value = true;
const res = await getUserAlbumSublist({ limit: 100, offset: 0 });
if (!mounted.value) return;
albumList.value = res.data.data || [];
// 更新 store 中的专辑列表
userStore.albumList = res.data.data || [];
} catch (error: any) {
console.error('加载专辑列表失败:', error);
message.error('加载专辑列表失败');
@@ -348,8 +342,8 @@ watch(currentTab, async (newTab) => {
if (newTab === 'album') {
// 刷新收藏专辑列表到 store
await userStore.initializeCollectedAlbums();
// 如果本地列表为空,则加载
if (albumList.value.length === 0) {
// 如果 store 中列表为空,则加载
if (userStore.albumList.length === 0) {
loadAlbumList();
}
}
@@ -487,7 +481,7 @@ const currentLoginType = computed(() => userStore.loginType);
.record-list {
@apply rounded-2xl;
@apply bg-light dark:bg-black;
height: calc(100% - 100px);
height: calc(100% - 60px);
.record-item {
@apply flex items-center px-2 mb-2 rounded-2xl bg-light-100 dark:bg-dark-100;