feat: 修改音乐列表为页面,优化专辑和歌单详情加载逻辑,支持通过路由跳转展示音乐列表

This commit is contained in:
alger
2025-05-07 22:36:52 +08:00
parent 3ca7e9a271
commit e2527c3fb8
15 changed files with 1208 additions and 259 deletions

View File

@@ -141,3 +141,45 @@ export const updatePlaylistTracks = (params: {
}) => {
return request.get('/playlist/tracks', { params });
};
/**
* 根据类型获取列表数据
* @param type 列表类型 album/playlist
* @param id 列表ID
*/
export function getMusicListByType(type: string, id: string) {
if (type === 'album') {
return getAlbumDetail(id);
} else if (type === 'playlist') {
return getPlaylistDetail(id);
}
return Promise.reject(new Error('未知列表类型'));
}
/**
* 获取专辑详情
* @param id 专辑ID
*/
export function getAlbumDetail(id: string) {
return request({
url: '/album',
method: 'get',
params: {
id
}
});
}
/**
* 获取歌单详情
* @param id 歌单ID
*/
export function getPlaylistDetail(id: string) {
return request({
url: '/playlist/detail',
method: 'get',
params: {
id
}
});
}

View File

@@ -0,0 +1,38 @@
import { Router } from 'vue-router';
import { useMusicStore } from '@/store/modules/music';
/**
* 导航到音乐列表页面的通用方法
* @param router Vue路由实例
* @param options 导航选项
*/
export function navigateToMusicList(
router: Router,
options: {
id?: string | number;
type?: 'album' | 'playlist' | 'dailyRecommend' | string;
name: string;
songList: any[];
listInfo?: any;
canRemove?: boolean;
}
) {
const musicStore = useMusicStore();
const { id, type, name, songList, listInfo, canRemove = false } = options;
// 保存数据到状态管理
musicStore.setCurrentMusicList(songList, name, listInfo, canRemove);
// 路由跳转
if (id) {
router.push({
name: 'musicList',
params: { id },
query: { type }
});
} else {
router.push({
name: 'musicList'
});
}
}

View File

@@ -1,11 +1,12 @@
<template>
<div v-if="isPlay" class="bottom" :style="{ height }"></div>
<div v-if="isPlay && !isMobile" class="bottom" :style="{ height }"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { usePlayerStore } from '@/store/modules/player';
import { isMobile } from '@/utils';
const playerStore = usePlayerStore();
const isPlay = computed(() => playerStore.playMusicUrl);

View File

@@ -21,15 +21,6 @@
<span>{{ item.size }}</span>
</div>
<music-list
v-if="['专辑', 'playlist'].includes(item.type)"
v-model:show="showPop"
:name="item.name"
:song-list="songList"
:list-info="listInfo"
:cover="false"
:z-index="zIndex"
/>
<mv-player
v-if="item.type === 'mv'"
v-model:show="showPop"
@@ -46,8 +37,8 @@ import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player';
import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils';
import MusicList from '../MusicList.vue';
import { useRouter } from 'vue-router';
import { useMusicStore } from '@/store/modules/music';
const props = withDefaults(
defineProps<{
@@ -72,6 +63,8 @@ const showPop = ref(false);
const listInfo = ref<any>(null);
const playerStore = usePlayerStore();
const router = useRouter();
const musicStore = useMusicStore();
const getCurrentMv = () => {
return {
@@ -83,7 +76,6 @@ const getCurrentMv = () => {
const handleClick = async () => {
listInfo.value = null;
if (props.item.type === '专辑') {
showPop.value = true;
const res = await getAlbum(props.item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || props.item.picUrl;
@@ -97,16 +89,41 @@ const handleClick = async () => {
},
description: res.data.album.description
};
}
if (props.item.type === 'playlist') {
showPop.value = true;
// 保存数据到store
musicStore.setCurrentMusicList(
songList.value,
props.item.name,
listInfo.value,
false
);
// 使用路由跳转
router.push({
name: 'musicList',
params: { id: props.item.id },
query: { type: 'album' }
});
} else if (props.item.type === 'playlist') {
const res = await getListDetail(props.item.id);
songList.value = res.data.playlist.tracks;
listInfo.value = res.data.playlist;
}
if (props.item.type === 'mv') {
// 保存数据到store
musicStore.setCurrentMusicList(
songList.value,
props.item.name,
listInfo.value,
false
);
// 使用路由跳转
router.push({
name: 'musicList',
params: { id: props.item.id },
query: { type: 'playlist' }
});
} else if (props.item.type === 'mv') {
handleShowMv();
}
};

View File

@@ -22,26 +22,19 @@
</div>
</template>
</div>
<music-list
v-model:show="showMusic"
:name="albumName"
:song-list="songList"
:cover="true"
:loading="loadingList"
:list-info="albumInfo"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getNewAlbum } from '@/api/home';
import { getAlbum } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import type { IAlbumNew } from '@/type/album';
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import type { IAlbumNew } from '@/type/album';
const { t } = useI18n();
const albumData = ref<IAlbumNew>();
@@ -50,33 +43,42 @@ const loadAlbumList = async () => {
albumData.value = data;
};
const showMusic = ref(false);
const songList = ref([]);
const albumName = ref('');
const loadingList = ref(false);
const albumInfo = ref<any>({});
const router = useRouter();
const handleClick = async (item: any) => {
songList.value = [];
albumInfo.value = {};
albumName.value = item.name;
loadingList.value = true;
showMusic.value = true;
const res = await getAlbum(item.id);
const { songs, album } = res.data;
songList.value = songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || album.picUrl;
song.picUrl = song.al.picUrl || album.picUrl || song.picUrl;
return song;
});
albumInfo.value = {
...album,
creator: {
avatarUrl: album.artist.img1v1Url,
nickname: `${album.artist.name} - ${album.company}`
},
description: album.description
};
loadingList.value = false;
openAlbum(item);
};
const openAlbum = async (album: any) => {
if (!album) return;
try {
const res = await getAlbum(album.id);
const { songs, album: albumInfo } = res.data;
const formattedSongs = songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || albumInfo.picUrl;
song.picUrl = song.al.picUrl || albumInfo.picUrl || song.picUrl;
return song;
});
navigateToMusicList(router, {
id: album.id,
type: 'album',
name: album.name,
songList: formattedSongs,
listInfo: {
...albumInfo,
creator: {
avatarUrl: albumInfo.artist.img1v1Url,
nickname: `${albumInfo.artist.name} - ${albumInfo.company}`
},
description: albumInfo.description
}
});
} catch (error) {
console.error('获取专辑详情失败:', error);
}
};
onMounted(() => {

View File

@@ -23,7 +23,7 @@
></div>
<div
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
@click="showMusic = true"
@click="showDayRecommend"
>
<div class="font-bold text-lg">
{{ t('comp.recommendSinger.title') }}
@@ -57,7 +57,7 @@
v-for="item in userPlaylist"
:key="item.id"
class="user-play-item"
@click="toPlaylist(item.id)"
@click="openPlaylist(item)"
>
<div class="user-play-item-img">
<img :src="getImgUrl(item.coverImgUrl, '200y200')" alt="" />
@@ -124,35 +124,18 @@
</n-carousel-item>
</n-carousel>
</div>
<music-list
v-if="dayRecommendData?.dailySongs.length"
v-model:show="showMusic"
:name="t('comp.recommendSinger.songlist')"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
<!-- 添加用户歌单弹窗 -->
<music-list
v-model:show="showPlaylist"
v-model:loading="playlistLoading"
:name="playlistItem?.name || ''"
:song-list="playlistDetail?.playlist?.tracks || []"
:list-info="playlistDetail?.playlist"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getDayRecommend, getHotSinger } from '@/api/home';
import { getListDetail } from '@/api/list';
import { getMusicDetail } from '@/api/music';
import { getUserPlaylist } from '@/api/user';
import MusicList from '@/components/MusicList.vue';
import { useArtist } from '@/hooks/useArtist';
import { usePlayerStore, useUserStore } from '@/store';
import { IDayRecommend } from '@/type/day_recommend';
@@ -168,20 +151,20 @@ import {
setBackgroundImg
} from '@/utils';
import { getArtistDetail } from '@/api/artist';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
const userStore = useUserStore();
const playerStore = usePlayerStore();
const router = useRouter();
const { t } = useI18n();
// 歌手信息
const hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
const userPlaylist = ref<Playlist[]>([]);
// 为歌单弹窗添加的状态
const showPlaylist = ref(false);
const playlistLoading = ref(false);
const playlistItem = ref<Playlist | null>(null);
const playlistDetail = ref<IListDetail | null>(null);
@@ -306,27 +289,34 @@ const handleArtistClick = (id: number) => {
navigateToArtist(id);
};
const toPlaylist = async (id: number) => {
const showDayRecommend = () => {
if (!dayRecommendData.value?.dailySongs) return;
navigateToMusicList(router, {
type: 'dailyRecommend',
name: t('comp.recommendSinger.songlist'),
songList: dayRecommendData.value.dailySongs,
canRemove: false
});
};
const openPlaylist = (item: any) => {
playlistItem.value = item;
playlistLoading.value = true;
playlistItem.value = null;
playlistDetail.value = null;
showPlaylist.value = true;
// 设置当前点击的歌单信息
const selectedPlaylist = userPlaylist.value.find((item) => item.id === id);
if (selectedPlaylist) {
playlistItem.value = selectedPlaylist;
}
try {
// 获取歌单详情
const { data } = await getListDetail(id);
playlistDetail.value = data;
} catch (error) {
console.error('获取歌单详情失败:', error);
} finally {
getListDetail(item.id).then(res => {
playlistDetail.value = res.data;
playlistLoading.value = false;
}
navigateToMusicList(router, {
id: item.id,
type: 'playlist',
name: item.name,
songList: res.data.playlist.tracks || [],
listInfo: res.data.playlist,
canRemove: false
});
});
};
// 添加直接播放歌单的方法

View File

@@ -62,20 +62,26 @@ import InstallAppModal from '@/components/common/InstallAppModal.vue';
import PlayBottom from '@/components/common/PlayBottom.vue';
import UpdateModal from '@/components/common/UpdateModal.vue';
import homeRouter from '@/router/home';
import otherRouter from '@/router/other';
import { useMenuStore } from '@/store/modules/menu';
import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings';
import { isElectron, isMobile } from '@/utils';
const keepAliveInclude = computed(() =>
homeRouter
const keepAliveInclude = computed(() => {
const allRoutes = [...homeRouter, ...otherRouter];
return allRoutes
.filter((item) => {
return item.meta.keepAlive;
return item.meta?.keepAlive;
})
.map((item) => {
return item.name.charAt(0).toUpperCase() + item.name.slice(1);
return typeof item.name === 'string'
? item.name.charAt(0).toUpperCase() + item.name.slice(1)
: '';
})
);
.filter(Boolean);
});
const AppMenu = defineAsyncComponent(() => import('./components/AppMenu.vue'));
const PlayBar = defineAsyncComponent(() => import('@/components/player/PlayBar.vue'));
@@ -142,7 +148,7 @@ provide('openPlaylistDrawer', openPlaylistDrawer);
.mobile {
.main-content {
height: calc(100vh - 154px);
height: calc(100vh - 130px);
overflow: auto;
display: block;
flex: none;

View File

@@ -53,6 +53,17 @@ const otherRouter = [
back: true
},
component: () => import('@/views/bilibili/BilibiliPlayer.vue')
},
{
path: '/music-list/:id?',
name: 'musicList',
meta: {
title: '音乐列表',
keepAlive: false,
showInMenu: false,
back: true
},
component: () => import('@/views/music/MusicListPage.vue')
}
];
export default otherRouter;

View File

@@ -16,5 +16,6 @@ export * from './modules/player';
export * from './modules/search';
export * from './modules/settings';
export * from './modules/user';
export * from './modules/music';
export default pinia;

View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia';
interface MusicState {
currentMusicList: any[] | null;
currentMusicListName: string;
currentListInfo: any | null;
canRemoveSong: boolean;
}
export const useMusicStore = defineStore('music', {
state: (): MusicState => ({
currentMusicList: null,
currentMusicListName: '',
currentListInfo: null,
canRemoveSong: false
}),
actions: {
// 设置当前音乐列表
setCurrentMusicList(list: any[], name: string, listInfo: any = null, canRemove = false) {
this.currentMusicList = list;
this.currentMusicListName = name;
this.currentListInfo = listInfo;
this.canRemoveSong = canRemove;
},
// 清除当前音乐列表
clearCurrentMusicList() {
this.currentMusicList = null;
this.currentMusicListName = '';
this.currentListInfo = null;
this.canRemoveSong = false;
},
// 从列表中移除一首歌曲
removeSongFromList(id: number) {
if (!this.currentMusicList) return;
const index = this.currentMusicList.findIndex((song) => song.id === id);
if (index !== -1) {
this.currentMusicList.splice(index, 1);
}
}
}
});

View File

@@ -73,7 +73,7 @@
<script setup lang="ts">
import { useDateFormat } from '@vueuse/core';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch, nextTick, onActivated, onDeactivated } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
@@ -86,6 +86,10 @@ import { usePlayerStore } from '@/store';
import { IArtist } from '@/type/artist';
import { getImgUrl } from '@/utils';
defineOptions({
name: 'ArtistDetail'
});
const { t } = useI18n();
const route = useRoute();
const playerStore = usePlayerStore();
@@ -122,10 +126,33 @@ const albumsLoadMoreRef = ref<HTMLElement | null>(null);
let songsObserver: IntersectionObserver | null = null;
let albumsObserver: IntersectionObserver | null = null;
// 添加上一个ID的引用用于比较
const previousId = ref<string | null>(null);
// 简化缓存机制
const artistDataCache = new Map();
// 单个缓存键函数
const getCacheKey = (id: string | number) => `artist_${id}`;
// 加载歌手信息
const loadArtistInfo = async () => {
if (!artistId.value) return;
// 简化缓存检查
const cacheKey = getCacheKey(artistId.value);
if (artistDataCache.has(cacheKey)) {
console.log('使用缓存数据');
const cachedData = artistDataCache.get(cacheKey);
artistInfo.value = cachedData.artistInfo;
songs.value = cachedData.songs;
albums.value = cachedData.albums;
songPage.value = cachedData.songPage;
albumPage.value = cachedData.albumPage;
return;
}
// 加载新数据
loading.value = true;
try {
const info = await getArtistDetail(artistId.value);
@@ -135,6 +162,15 @@ const loadArtistInfo = async () => {
// 重置分页并加载初始数据
resetPagination();
await Promise.all([loadSongs(), loadAlbums()]);
// 保存到缓存
artistDataCache.set(cacheKey, {
artistInfo: artistInfo.value,
songs: [...songs.value],
albums: [...albums.value],
songPage: { ...songPage.value },
albumPage: { ...albumPage.value }
});
} catch (error) {
console.error('加载歌手信息失败:', error);
} finally {
@@ -241,79 +277,106 @@ const handlePlay = () => {
);
};
// 设置无限滚动观察器
const setupIntersectionObservers = () => {
// 清除现有的观察器
// 简化观察器设置
const setupObservers = () => {
// 清理之前的观察器
if (songsObserver) songsObserver.disconnect();
if (albumsObserver) albumsObserver.disconnect();
// 创建歌曲观察器
songsObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !songLoading.value && songPage.value.hasMore) {
loadSongs();
}
}, { threshold: 0.1 });
// 创建专辑观察器
albumsObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !albumLoading.value && albumPage.value.hasMore) {
loadAlbums();
}
}, { threshold: 0.1 });
// 监听标签页更改,重新设置观察器
watch(activeTab, (newTab) => {
nextTick(() => {
if (newTab === 'songs' && songsLoadMoreRef.value && songPage.value.hasMore) {
songsObserver?.observe(songsLoadMoreRef.value);
} else if (newTab === 'albums' && albumsLoadMoreRef.value && albumPage.value.hasMore) {
albumsObserver?.observe(albumsLoadMoreRef.value);
}
});
});
// 监听引用元素的变化
watch(songsLoadMoreRef, (el) => {
if (el && activeTab.value === 'songs' && songPage.value.hasMore) {
songsObserver?.observe(el);
}
});
watch(albumsLoadMoreRef, (el) => {
if (el && activeTab.value === 'albums' && albumPage.value.hasMore) {
albumsObserver?.observe(el);
// 创建观察器(如果不存在)
if (!songsObserver) {
songsObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && songPage.value.hasMore) {
loadSongs();
}
},
{ threshold: 0.1 }
);
}
if (!albumsObserver) {
albumsObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && albumPage.value.hasMore) {
loadAlbums();
}
},
{ threshold: 0.1 }
);
}
// 观察当前标签页的元素
nextTick(() => {
if (activeTab.value === 'songs' && songsLoadMoreRef.value) {
songsObserver?.observe(songsLoadMoreRef.value);
} else if (activeTab.value === 'albums' && albumsLoadMoreRef.value) {
albumsObserver?.observe(albumsLoadMoreRef.value);
}
});
};
onMounted(() => {
loadArtistInfo();
// 添加nextTick以确保DOM已更新
nextTick(() => {
setupIntersectionObservers();
});
// 监听标签切换
watch(activeTab, () => {
setupObservers();
});
onUnmounted(() => {
// 清理观察器
// 监听引用元素的变化
watch([songsLoadMoreRef, albumsLoadMoreRef], () => {
setupObservers();
});
// 监听路由参数变化避免URL改变但未触发组件重新创建
watch(
() => route.params.id,
(newId, oldId) => {
if (newId && newId !== oldId) {
previousId.value = newId as string;
loadArtistInfo();
}
}
);
onActivated(() => {
const currentId = route.params.id as string;
// 首次加载或ID变化时加载数据
if (!previousId.value || previousId.value !== currentId) {
console.log('ID已变化加载新数据');
previousId.value = currentId;
loadArtistInfo();
}
// 重新设置观察器
setupObservers();
});
onMounted(() => {
// 首次挂载时加载数据
if (route.params.id) {
previousId.value = route.params.id as string;
loadArtistInfo();
setupObservers();
}
});
onDeactivated(() => {
// 断开观察器但不清除引用
if (songsObserver) songsObserver.disconnect();
if (albumsObserver) albumsObserver.disconnect();
});
// 监听路由参数变化
watch(
() => route.params.id,
(newId) => {
if (newId) {
loadArtistInfo();
// 添加nextTick以确保DOM已更新
nextTick(() => {
setupIntersectionObservers();
});
}
onUnmounted(() => {
// 完全清理观察器
if (songsObserver) {
songsObserver.disconnect();
songsObserver = null;
}
);
if (albumsObserver) {
albumsObserver.disconnect();
albumsObserver = null;
}
});
</script>
<style lang="scss" scoped>

View File

@@ -31,7 +31,7 @@
class="recommend-item"
:class="setAnimationClass('animate__bounceIn')"
:style="getItemAnimationDelay(index)"
@click.stop="selectRecommendItem(item)"
@click.stop="openPlaylist(item)"
>
<div class="recommend-item-img">
<n-image
@@ -57,22 +57,15 @@
</div>
<div v-if="!hasMore && recommendList.length > 0" class="no-more">没有更多了</div>
</n-scrollbar>
<music-list
v-model:show="showMusic"
v-model:loading="listLoading"
:name="recommendItem?.name || ''"
:song-list="listDetail?.playlist.tracks || []"
:list-info="listDetail?.playlist"
/>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import { getListByCat, getListDetail } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import type { IRecommendItem } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import type { IPlayListSort } from '@/type/playlist';
@@ -85,7 +78,6 @@ defineOptions({
const TOTAL_ITEMS = 42; // 每页数量
const recommendList = ref<any[]>([]);
const showMusic = ref(false);
const page = ref(0);
const hasMore = ref(true);
const isLoadingMore = ref(false);
@@ -100,15 +92,25 @@ const recommendItem = ref<IRecommendItem | null>();
const listDetail = ref<IListDetail | null>();
const listLoading = ref(true);
const selectRecommendItem = async (item: IRecommendItem) => {
listLoading.value = true;
recommendItem.value = null;
listDetail.value = null;
showMusic.value = true;
const router = useRouter();
const openPlaylist = (item: any) => {
recommendItem.value = item;
const { data } = await getListDetail(item.id);
listDetail.value = data;
listLoading.value = false;
listLoading.value = true;
getListDetail(item.id).then(res => {
listDetail.value = res.data;
listLoading.value = false;
navigateToMusicList(router, {
id: item.id,
type: 'playlist',
name: item.name,
songList: res.data.playlist.tracks || [],
listInfo: res.data.playlist,
canRemove: false
});
});
};
const route = useRoute();

View File

@@ -0,0 +1,773 @@
<template>
<div class="music-page">
<div class="music-header h-12 flex items-center justify-between">
<n-ellipsis :line-clamp="1" class="flex-shrink-0 mr-3">
<div class="music-title">
{{ name }}
</div>
</n-ellipsis>
<!-- 搜索框 -->
<div class="flex-grow flex-1 flex items-center justify-end">
<div class="search-container">
<n-input
v-model:value="searchKeyword"
:placeholder="t('comp.musicList.searchSongs')"
clearable
round
size="small"
>
<template #prefix>
<i class="icon iconfont ri-search-line text-sm"></i>
</template>
</n-input>
</div>
</div>
</div>
<div class="music-content">
<!-- 左侧歌单信息 -->
<div class="music-info">
<div class="music-cover">
<n-image
:src="getCoverImgUrl"
class="cover-img"
preview-disabled
:class="setAnimationClass('animate__fadeIn')"
object-fit="cover"
/>
</div>
<div v-if="listInfo?.creator" class="creator-info">
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
</div>
<div v-if="total" class="music-total">{{ t('player.songNum', { num: total }) }}</div>
<n-scrollbar style="max-height: 200px">
<div v-if="listInfo?.description" class="music-desc">
{{ listInfo.description }}
</div>
</n-scrollbar>
</div>
<!-- 右侧歌曲列表 -->
<div class="music-list-container">
<div class="music-list">
<n-spin :show="loadingList || loading">
<div class="music-list-content">
<div v-if="filteredSongs.length === 0 && searchKeyword" class="no-result">
{{ t('comp.musicList.noSearchResults') }}
</div>
<!-- 虚拟列表设置正确的固定高度 -->
<n-virtual-list
ref="songListRef"
class="song-virtual-list"
style="height: calc(80vh - 60px)"
:items="filteredSongs"
:item-size="70"
item-resizable
key-field="id"
@scroll="handleVirtualScroll"
>
<template #default="{ item }">
<div class="double-item">
<song-item
:item="formatSong(item)"
:can-remove="canRemove"
@play="handlePlay"
@remove-song="handleRemoveSong"
/>
</div>
</template>
</n-virtual-list>
</div>
</n-spin>
</div>
</div>
</div>
<play-bottom />
</div>
</template>
<script setup lang="ts">
// 添加组件名称定义
defineOptions({
name: 'MusicList'
});
import PinyinMatch from 'pinyin-match';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { updatePlaylistTracks } from '@/api/music';
import { useMessage } from 'naive-ui';
import { getMusicDetail, getMusicListByType } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import PlayBottom from '@/components/common/PlayBottom.vue';
import { useMusicStore, usePlayerStore } from '@/store';
import { SongResult } from '@/type/music';
import { getImgUrl, setAnimationClass } from '@/utils';
const { t } = useI18n();
const route = useRoute();
const playerStore = usePlayerStore();
const musicStore = useMusicStore();
const message = useMessage();
// 从路由参数或状态管理获取数据
const name = ref('');
const loading = ref(false);
const songList = ref<any[]>([]);
const listInfo = ref<any>(null);
const canRemove = ref(false);
const page = ref(0);
const pageSize = 40;
const isLoadingMore = ref(false);
const displayedSongs = ref<SongResult[]>([]);
const loadingList = ref(false);
const loadedIds = ref(new Set<number>()); // 用于追踪已加载的歌曲ID
const isPlaylistLoading = ref(false); // 标记是否正在加载播放列表
const completePlaylist = ref<SongResult[]>([]); // 存储完整的播放列表
const hasMore = ref(true); // 标记是否还有更多数据可加载
const searchKeyword = ref(''); // 搜索关键词
const isFullPlaylistLoaded = ref(false); // 标记完整播放列表是否已加载完成
// 计算总数
const total = computed(() => {
if (listInfo.value?.trackIds) {
return listInfo.value.trackIds.length;
}
return songList.value.length;
});
// 初始化数据
onMounted(() => {
initData();
});
// 从 pinia 或路由参数获取数据
const initData = () => {
// 优先从 pinia 获取数据
if (musicStore.currentMusicList) {
name.value = musicStore.currentMusicListName || '';
songList.value = musicStore.currentMusicList || [];
listInfo.value = musicStore.currentListInfo || null;
canRemove.value = musicStore.canRemoveSong || false;
// 初始化歌曲列表
initSongList(songList.value);
return;
}
// 从路由参数获取
const routeId = route.params.id as string;
const routeType = route.query.type as string;
if (routeId) {
// 这里根据 type 和 id 加载数据
// 例如: 获取歌单、专辑等
loading.value = true;
loadDataByType(routeType, routeId).finally(() => {
loading.value = false;
});
}
};
// 根据类型加载数据
const loadDataByType = async (type: string, id: string) => {
try {
const result = await getMusicListByType(type, id);
if (type === 'album') {
const { songs, album } = result.data;
name.value = album.name;
songList.value = songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || album.picUrl;
song.picUrl = song.al.picUrl || album.picUrl || song.picUrl;
return song;
});
listInfo.value = {
...album,
creator: {
avatarUrl: album.artist.img1v1Url,
nickname: `${album.artist.name} - ${album.company}`
},
description: album.description
};
} else if (type === 'playlist') {
const { playlist } = result.data;
name.value = playlist.name;
listInfo.value = playlist;
// 初始化歌曲列表
if (playlist.tracks) {
songList.value = playlist.tracks;
}
}
// 初始化歌曲列表
initSongList(songList.value);
} catch (error) {
console.error('加载数据失败:', error);
}
};
const getCoverImgUrl = computed(() => {
if (listInfo.value?.coverImgUrl) {
return listInfo.value.coverImgUrl;
}
const song = songList.value[0];
if (song?.picUrl) {
return song.picUrl;
}
if (song?.al?.picUrl) {
return song.al.picUrl;
}
if (song?.album?.picUrl) {
return song.album.picUrl;
}
return '';
});
// 过滤歌曲列表
const filteredSongs = computed(() => {
if (!searchKeyword.value) {
return displayedSongs.value;
}
const keyword = searchKeyword.value.toLowerCase().trim();
return displayedSongs.value.filter((song) => {
const songName = song.name?.toLowerCase() || '';
const albumName = song.al?.name?.toLowerCase() || '';
const artists = song.ar || song.artists || [];
// 原始文本匹配
const nameMatch = songName.includes(keyword);
const albumMatch = albumName.includes(keyword);
const artistsMatch = artists.some((artist: any) => {
return artist.name?.toLowerCase().includes(keyword);
});
// 拼音匹配
const namePinyinMatch = song.name && PinyinMatch.match(song.name, keyword);
const albumPinyinMatch = song.al?.name && PinyinMatch.match(song.al.name, keyword);
const artistsPinyinMatch = artists.some((artist: any) => {
return artist.name && PinyinMatch.match(artist.name, keyword);
});
return (
nameMatch ||
albumMatch ||
artistsMatch ||
namePinyinMatch ||
albumPinyinMatch ||
artistsPinyinMatch
);
});
});
// 格式化歌曲数据
const formatSong = (item: any) => {
if (!item) {
return null;
}
return {
...item,
picUrl: item.al?.picUrl || item.picUrl,
song: {
artists: item.ar || item.artists,
name: item.al?.name || item.name,
id: item.al?.id || item.id
}
};
};
/**
* 加载歌曲数据的核心函数
* @param ids 要加载的歌曲ID数组
* @param appendToList 是否将加载的歌曲追加到现有列表
* @param updateComplete 是否更新完整播放列表
*/
const loadSongs = async (ids: number[], appendToList = true, updateComplete = false) => {
if (ids.length === 0) return [];
try {
console.log(`请求歌曲详情ID数量: ${ids.length}`);
const { data } = await getMusicDetail(ids);
if (data?.songs) {
console.log(`API返回歌曲数量: ${data.songs.length}`);
// 直接使用API返回的所有歌曲不再过滤已加载的歌曲
// 因为当需要完整加载列表时我们希望获取所有歌曲即使ID可能重复
const { songs } = data;
// 只在非更新完整列表时执行过滤
let newSongs = songs;
if (!updateComplete) {
// 在普通加载模式下继续过滤已加载的歌曲,避免重复
newSongs = songs.filter((song: any) => !loadedIds.value.has(song.id));
console.log(`过滤已加载ID后剩余歌曲数量: ${newSongs.length}`);
}
// 更新已加载ID集合
songs.forEach((song: any) => {
loadedIds.value.add(song.id);
});
// 追加到显示列表 - 仅当appendToList=true时添加到displayedSongs
if (appendToList) {
displayedSongs.value.push(...newSongs);
}
// 更新完整播放列表 - 仅当updateComplete=true时添加到completePlaylist
if (updateComplete) {
completePlaylist.value.push(...songs);
console.log(`已添加到完整播放列表,当前完整列表长度: ${completePlaylist.value.length}`);
}
return updateComplete ? songs : newSongs;
}
console.log('API返回无歌曲数据');
return [];
} catch (error) {
console.error('加载歌曲失败:', error);
}
return [];
};
// 加载完整播放列表
const loadFullPlaylist = async () => {
if (isPlaylistLoading.value || isFullPlaylistLoaded.value) return;
isPlaylistLoading.value = true;
// 记录开始时间
const startTime = Date.now();
console.log(`开始加载完整播放列表,当前显示列表长度: ${displayedSongs.value.length}`);
try {
// 如果没有trackIds直接使用当前歌曲列表并标记为已完成
if (!listInfo.value?.trackIds) {
isFullPlaylistLoaded.value = true;
console.log('无trackIds信息使用当前列表作为完整列表');
return;
}
// 获取所有trackIds
const allIds = listInfo.value.trackIds.map((item) => item.id);
console.log(`歌单共有歌曲ID: ${allIds.length}`);
// 重置completePlaylist和当前显示歌曲ID集合保证不会重复添加歌曲
completePlaylist.value = [];
// 使用Set记录所有已加载的歌曲ID
const loadedSongIds = new Set<number>();
// 将当前显示列表中的歌曲和ID添加到集合中
displayedSongs.value.forEach((song) => {
loadedSongIds.add(song.id as number);
// 将已有歌曲添加到completePlaylist
completePlaylist.value.push(song);
});
console.log(
`已有显示歌曲: ${displayedSongs.value.length}已有ID数量: ${loadedSongIds.size}`
);
// 过滤出尚未加载的歌曲ID
const unloadedIds = allIds.filter((id) => !loadedSongIds.has(id));
console.log(`还需要加载的歌曲ID数量: ${unloadedIds.length}`);
if (unloadedIds.length === 0) {
console.log('所有歌曲已加载,无需再次加载');
isFullPlaylistLoaded.value = true;
hasMore.value = false;
return;
}
// 分批加载所有未加载的歌曲
const batchSize = 500; // 每批加载的歌曲数量
for (let i = 0; i < unloadedIds.length; i += batchSize) {
const batchIds = unloadedIds.slice(i, i + batchSize);
if (batchIds.length === 0) continue;
console.log(`请求第${Math.floor(i / batchSize) + 1}批歌曲,数量: ${batchIds.length}`);
// 关键修改: 设置appendToList为false避免loadSongs直接添加到displayedSongs
const loadedBatch = await loadSongs(batchIds, false, false);
// 添加新加载的歌曲到displayedSongs
if (loadedBatch.length > 0) {
// 过滤掉已有的歌曲,确保不会重复添加
const newSongs = loadedBatch.filter((song) => !loadedSongIds.has(song.id as number));
// 更新已加载ID集合
newSongs.forEach((song) => {
loadedSongIds.add(song.id as number);
});
console.log(`新增${newSongs.length}首歌曲到显示列表`);
// 更新显示列表和完整播放列表
if (newSongs.length > 0) {
// 添加到显示列表
displayedSongs.value = [...displayedSongs.value, ...newSongs];
// 添加到完整播放列表
completePlaylist.value.push(...newSongs);
// 如果当前正在播放的列表与这个列表匹配,实时更新播放列表
const currentPlaylist = playerStore.playList;
if (currentPlaylist.length > 0 && currentPlaylist[0].id === displayedSongs.value[0]?.id) {
console.log('实时更新当前播放列表');
playerStore.setPlayList(displayedSongs.value.map(formatSong));
}
}
}
// 添加小延迟避免请求过于密集
if (i + batchSize < unloadedIds.length) {
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 100);
});
}
}
// 加载完成,更新状态
isFullPlaylistLoaded.value = true;
hasMore.value = false;
// 计算加载耗时
const endTime = Date.now();
const timeUsed = Math.round(((endTime - startTime) / 1000) * 100) / 100;
console.log(
`完整播放列表加载完成,共加载${displayedSongs.value.length}首歌曲,耗时${timeUsed}`
);
console.log(`歌单应有${allIds.length}首歌,实际加载${displayedSongs.value.length}`);
// 检查加载的歌曲数量是否与预期相符
if (displayedSongs.value.length !== allIds.length) {
console.warn(
`警告: 加载的歌曲数量(${displayedSongs.value.length})与歌单应有数量(${allIds.length})不符`
);
// 如果数量不符可能是API未返回所有歌曲打印缺失的歌曲ID
if (displayedSongs.value.length < allIds.length) {
const loadedIds = new Set(displayedSongs.value.map((song) => song.id));
const missingIds = allIds.filter((id) => !loadedIds.has(id));
console.warn(`缺失的歌曲ID: ${missingIds.join(', ')}`);
}
}
} catch (error) {
console.error('加载完整播放列表失败:', error);
} finally {
isPlaylistLoading.value = false;
}
};
// 处理播放
const handlePlay = async () => {
// 当搜索状态下播放时,只播放过滤后的歌曲
if (searchKeyword.value) {
playerStore.setPlayList(filteredSongs.value.map(formatSong));
return;
}
// 如果完整播放列表已加载完成
if (isFullPlaylistLoaded.value && completePlaylist.value.length > 0) {
playerStore.setPlayList(completePlaylist.value.map(formatSong));
return;
}
// 如果完整播放列表未加载完成,先使用当前已加载的歌曲开始播放
playerStore.setPlayList(displayedSongs.value.map(formatSong));
// 如果完整播放列表正在加载中,不需要重新触发加载
if (isPlaylistLoading.value) {
return;
}
// 在后台继续加载完整播放列表(如果未加载完成)
if (!isFullPlaylistLoaded.value) {
console.log('播放时继续在后台加载完整列表');
loadFullPlaylist();
}
};
// 添加从歌单移除歌曲的方法
const handleRemoveSong = async (songId: number) => {
if (!listInfo.value?.id || !canRemove.value) return;
try {
const res = await updatePlaylistTracks({
op: 'del',
pid: listInfo.value.id,
tracks: songId.toString()
});
if (res.status === 200) {
message.success(t('user.message.deleteSuccess'));
// 从显示列表和完整播放列表中移除歌曲
displayedSongs.value = displayedSongs.value.filter(song => song.id !== songId);
completePlaylist.value = completePlaylist.value.filter(song => song.id !== songId);
// 如果正在播放该列表,也需要更新播放列表
const currentPlaylist = playerStore.playList;
if (currentPlaylist.length > 0 && currentPlaylist[0].id === displayedSongs.value[0]?.id) {
playerStore.setPlayList(displayedSongs.value.map(formatSong));
}
// 从Pinia状态中也移除
if (musicStore.currentMusicList) {
musicStore.removeSongFromList(songId);
}
} else {
throw new Error(res.data?.msg || t('user.message.deleteFailed'));
}
} catch (error: any) {
console.error('删除歌曲失败:', error);
message.error(error.message || t('user.message.deleteFailed'));
}
};
// 加载更多歌曲
const loadMoreSongs = async () => {
if (isFullPlaylistLoaded.value) {
hasMore.value = false;
return;
}
if (searchKeyword.value) {
return;
}
if (isLoadingMore.value || displayedSongs.value.length >= total.value) {
hasMore.value = false;
return;
}
isLoadingMore.value = true;
try {
const start = displayedSongs.value.length;
const end = Math.min(start + pageSize, total.value);
if (listInfo.value?.trackIds) {
const trackIdsToLoad = listInfo.value.trackIds
.slice(start, end)
.map((item) => item.id)
.filter((id) => !loadedIds.value.has(id));
if (trackIdsToLoad.length > 0) {
await loadSongs(trackIdsToLoad, true, false);
}
} else if (start < songList.value.length) {
const newSongs = songList.value.slice(start, end);
newSongs.forEach((song) => {
if (!loadedIds.value.has(song.id)) {
loadedIds.value.add(song.id);
displayedSongs.value.push(song);
}
});
}
hasMore.value = displayedSongs.value.length < total.value;
} catch (error) {
console.error('加载更多歌曲失败:', error);
} finally {
isLoadingMore.value = false;
loadingList.value = false;
}
};
// 处理虚拟列表滚动事件
const handleVirtualScroll = (e: any) => {
if (!e || !e.target) return;
const { scrollTop, scrollHeight, clientHeight } = e.target;
const threshold = 200;
if (
scrollHeight - scrollTop - clientHeight < threshold &&
!isLoadingMore.value &&
hasMore.value &&
!searchKeyword.value // 搜索状态下不触发加载更多
) {
loadMoreSongs();
}
};
// 初始化歌曲列表
const initSongList = (songs: any[]) => {
if (songs.length > 0) {
displayedSongs.value = [...songs];
songs.forEach((song) => loadedIds.value.add(song.id));
page.value = Math.ceil(songs.length / pageSize);
}
// 检查是否还有更多数据可加载
hasMore.value = displayedSongs.value.length < total.value;
};
watch(
() => listInfo.value,
(newListInfo) => {
if (newListInfo?.trackIds) {
loadFullPlaylist();
}
},
{ deep: true }
);
// 监听搜索关键词变化
watch(searchKeyword, () => {
// 当搜索关键词为空时,考虑加载更多歌曲
if (!searchKeyword.value && hasMore.value && displayedSongs.value.length < total.value) {
loadMoreSongs();
}
});
// 组件卸载时清理状态
onUnmounted(() => {
isPlaylistLoading.value = false;
});
</script>
<style scoped lang="scss">
.music {
&-title {
@apply text-xl font-bold text-gray-900 dark:text-white;
}
&-total {
@apply text-sm font-normal text-gray-500 dark:text-gray-400;
}
&-page {
@apply h-full bg-light-100 dark:bg-dark-100 px-4 mr-2 rounded-2xl;
}
&-close {
@apply cursor-pointer text-gray-500 dark:text-white hover:text-gray-900 dark:hover:text-gray-300 flex gap-2 items-center transition;
.icon {
@apply text-3xl;
}
}
&-content {
@apply flex h-[calc(100%-60px)];
}
&-info {
@apply w-[25%] flex-shrink-0 pr-8 flex flex-col;
.music-cover {
@apply w-full aspect-square rounded-2xl overflow-hidden mb-4 min-h-[250px];
.cover-img {
@apply w-full h-full object-cover;
}
}
.creator-info {
@apply flex items-center mb-4;
.creator-name {
@apply ml-2 text-gray-700 dark:text-gray-300;
}
}
.music-desc {
@apply text-sm text-gray-600 dark:text-gray-400 leading-relaxed pr-4;
}
}
&-list {
@apply flex-grow min-h-0;
&-container {
@apply flex-grow min-h-0 flex flex-col relative;
}
&-content {
@apply min-h-[calc(80vh-60px)];
}
}
}
.search-container {
@apply max-w-md;
:deep(.n-input) {
@apply bg-light-200 dark:bg-dark-200;
}
.icon {
@apply text-gray-500 dark:text-gray-400;
}
}
.no-result {
@apply text-center py-8 text-gray-500 dark:text-gray-400;
}
/* 虚拟列表样式 */
.song-virtual-list {
:deep(.n-virtual-list__scroll) {
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
@apply bg-gray-400 dark:bg-gray-600 rounded;
}
}
}
.mobile {
.music-page {
@apply px-4 overflow-hidden mr-0;
}
.music-content {
@apply flex-col;
}
.music-info {
@apply w-full pr-0 mb-2 flex flex-row;
.music-cover {
@apply w-[100px] h-[100px] rounded-lg overflow-hidden mb-4;
}
.music-detail {
@apply flex-1 ml-4;
}
}
.music-title {
@apply text-base;
}
.search-container {
@apply max-w-[50%];
}
}
.loading-more {
@apply text-center py-4 text-gray-500 dark:text-gray-400;
}
.double-item {
@apply mb-2 bg-light-200 bg-opacity-30 dark:bg-dark-200 dark:bg-opacity-20 rounded-3xl;
}
.mobile {
.music-info {
@apply hidden;
}
}
</style>

View File

@@ -63,7 +63,7 @@
class="playlist-item"
:class="setAnimationClass('animate__fadeInUp')"
:style="setAnimationDelay(index, 50)"
@click="showPlaylist(item.id, item.name)"
@click="openPlaylist(item)"
>
<div class="playlist-cover">
<n-image
@@ -120,14 +120,6 @@
<div class="pb-20"></div>
</div>
</n-scrollbar>
<music-list
v-model:show="isShowList"
:name="currentList?.name || ''"
:song-list="currentList?.tracks || []"
:list-info="currentList"
:loading="listLoading"
/>
</div>
</template>
@@ -140,7 +132,7 @@ import { useRoute, useRouter } from 'vue-router';
import { getListDetail } from '@/api/list';
import { getUserDetail, getUserPlaylist, getUserRecord } from '@/api/user';
import SongItem from '@/components/common/SongItem.vue';
import MusicList from '@/components/MusicList.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import { usePlayerStore } from '@/store/modules/player';
import type { Playlist } from '@/type/listDetail';
import type { IUserDetail } from '@/type/user';
@@ -166,7 +158,6 @@ const recordList = ref<any[]>([]);
const loading = ref(true);
// 歌单详情相关
const isShowList = ref(false);
const currentList = ref<Playlist>();
const listLoading = ref(false);
@@ -208,21 +199,23 @@ const loadUserData = async () => {
}
};
// 展示歌单
const showPlaylist = async (id: number, name: string) => {
isShowList.value = true;
// 替换显示歌单的方法
const openPlaylist = (item: any) => {
listLoading.value = true;
try {
currentList.value = { id, name } as Playlist;
const { data } = await getListDetail(id);
currentList.value = data.playlist;
} catch (error) {
console.error('加载歌单详情失败:', error);
message.error('加载歌单详情失败');
} finally {
getListDetail(item.id).then(res => {
currentList.value = res.data.playlist;
listLoading.value = false;
}
navigateToMusicList(router, {
id: item.id,
type: 'playlist',
name: item.name,
songList: res.data.playlist.tracks || [],
listInfo: res.data.playlist,
canRemove: false
});
});
};
// 播放歌曲

View File

@@ -34,7 +34,7 @@
v-for="(item, index) in playList"
:key="index"
class="play-list-item"
@click="showPlaylist(item.id, item.name)"
@click="openPlaylist(item)"
>
<n-image
:src="getImgUrl(item.coverImgUrl, '50y50')"
@@ -82,15 +82,6 @@
</n-scrollbar>
</div>
</div>
<music-list
v-model:show="isShowList"
:name="list?.name || ''"
:song-list="list?.tracks || []"
:list-info="list"
:loading="listLoading"
:can-remove="true"
@remove-song="handleRemoveFromPlaylist"
/>
</div>
</template>
@@ -101,11 +92,10 @@ import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getListDetail } from '@/api/list';
import { updatePlaylistTracks } from '@/api/music';
import { getUserDetail, getUserPlaylist, getUserRecord } from '@/api/user';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import MusicList from '@/components/MusicList.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import { usePlayerStore } from '@/store/modules/player';
import { useUserStore } from '@/store/modules/user';
import type { Playlist } from '@/type/listDetail';
@@ -125,7 +115,6 @@ const playList = ref<any[]>([]);
const recordList = ref();
const infoLoading = ref(false);
const mounted = ref(true);
const isShowList = ref(false);
const list = ref<Playlist>();
const listLoading = ref(false);
const message = useMessage();
@@ -234,47 +223,23 @@ onMounted(() => {
checkLoginStatus() && loadData();
});
// 展示歌单
const showPlaylist = async (id: number, name: string) => {
isShowList.value = true;
// 替换显示歌单的方法
const openPlaylist = (item: any) => {
listLoading.value = true;
list.value = {
name,
id
} as Playlist;
await loadPlaylistDetail(id);
listLoading.value = false;
};
// 加载歌单详情
const loadPlaylistDetail = async (id: number) => {
const { data } = await getListDetail(id);
list.value = data.playlist;
};
// 从歌单中删除歌曲
const handleRemoveFromPlaylist = async (songId: number) => {
if (!list.value?.id) return;
try {
const res = await updatePlaylistTracks({
op: 'del',
pid: list.value.id,
tracks: songId.toString()
getListDetail(item.id).then(res => {
list.value = res.data.playlist;
listLoading.value = false;
navigateToMusicList(router, {
id: item.id,
type: 'playlist',
name: item.name,
songList: res.data.playlist.tracks || [],
listInfo: res.data.playlist,
canRemove: true // 保留可移除功能
});
if (res.status === 200) {
message.success(t('user.message.deleteSuccess'));
// 重新加载歌单详情
await loadPlaylistDetail(list.value.id);
} else {
throw new Error(res.data?.msg || t('user.message.deleteFailed'));
}
} catch (error: any) {
console.error('删除歌曲失败:', error);
message.error(error.message || t('user.message.deleteFailed'));
}
});
};
const handlePlay = () => {