feat: 用户页面添加收藏专辑展示

This commit is contained in:
alger
2025-10-22 21:50:20 +08:00
parent bee5445b6e
commit a9adb6be36
10 changed files with 328 additions and 58 deletions

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count} tracks',
playCount: 'Played {count} times'
},
tabs: {
created: 'Created',
favorite: 'Favorite',
album: 'Album'
},
ranking: {
title: 'Listening History',
playCount: '{count} times'

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}曲',
playCount: '{count}回再生'
},
tabs: {
created: '作成',
favorite: 'お気に入り',
album: 'アルバム'
},
ranking: {
title: '聴取ランキング',
playCount: '{count}回'

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}곡',
playCount: '{count}회 재생'
},
tabs: {
created: '생성',
favorite: '즐겨찾기',
album: '앨범'
},
ranking: {
title: '음악 청취 순위',
playCount: '{count}회'

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}首',
playCount: '播放{count}次'
},
tabs: {
created: '创建',
favorite: '收藏',
album: '专辑'
},
ranking: {
title: '听歌排行',
playCount: '{count}次'

View File

@@ -10,6 +10,11 @@ export default {
trackCount: '{count}首',
playCount: '播放{count}次'
},
tabs: {
created: '建立',
favorite: '收藏',
album: '專輯'
},
ranking: {
title: '聽歌排行',
playCount: '{count}次'

View File

@@ -176,3 +176,15 @@ export function subscribePlaylist(params: { t: number; id: number }) {
params
});
}
/**
* 收藏/取消收藏专辑
* @param params t: 1 收藏, 2 取消收藏; id: 专辑id
*/
export function subscribeAlbum(params: { t: number; id: number }) {
return request({
url: '/album/sub',
method: 'post',
params
});
}

View File

@@ -72,3 +72,15 @@ export const getUserPlaylists = (params: { uid: string | number }) => {
params
});
};
// 获取已收藏专辑列表
export const getUserAlbumSublist = (params?: { limit?: number; offset?: number }) => {
return request({
url: '/album/sublist',
method: 'get',
params: {
limit: params?.limit || 25,
offset: params?.offset || 0
}
});
};

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue';
import { logout } from '@/api/login';
import { getLikedList } from '@/api/music';
import { getUserAlbumSublist } from '@/api/user';
import { clearLoginStatus } from '@/utils/auth';
interface UserData {
@@ -27,6 +28,8 @@ export const useUserStore = defineStore('user', () => {
);
const searchValue = ref('');
const searchType = ref(1);
// 收藏的专辑 ID 列表
const collectedAlbumIds = ref<Set<number>>(new Set());
// 方法
const setUser = (userData: UserData) => {
@@ -69,6 +72,39 @@ export const useUserStore = defineStore('user', () => {
searchType.value = type;
};
// 初始化收藏的专辑列表
const initializeCollectedAlbums = async () => {
if (!user.value || !localStorage.getItem('token')) {
collectedAlbumIds.value.clear();
return;
}
try {
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} 个收藏专辑`);
} catch (error) {
console.error('获取收藏专辑列表失败:', error);
collectedAlbumIds.value.clear();
}
};
// 添加收藏专辑
const addCollectedAlbum = (albumId: number) => {
collectedAlbumIds.value.add(albumId);
};
// 移除收藏专辑
const removeCollectedAlbum = (albumId: number) => {
collectedAlbumIds.value.delete(albumId);
};
// 检查专辑是否已收藏
const isAlbumCollected = (albumId: number) => {
return collectedAlbumIds.value.has(albumId);
};
// 初始化
const initializeUser = async () => {
const savedUser = getLocalStorageItem<UserData | null>('user', null);
@@ -77,6 +113,9 @@ export const useUserStore = defineStore('user', () => {
// 如果用户已登录,获取收藏列表
if (localStorage.getItem('token')) {
try {
// 同时初始化收藏专辑列表
await initializeCollectedAlbums();
const { data } = await getLikedList(savedUser.userId);
return data?.ids || [];
} catch (error) {
@@ -94,6 +133,7 @@ export const useUserStore = defineStore('user', () => {
loginType,
searchValue,
searchType,
collectedAlbumIds,
// 方法
setUser,
@@ -101,6 +141,10 @@ export const useUserStore = defineStore('user', () => {
handleLogout,
setSearchValue,
setSearchType,
initializeUser
initializeUser,
initializeCollectedAlbums,
addCollectedAlbum,
removeCollectedAlbum,
isAlbumCollected
};
});

View File

@@ -153,7 +153,12 @@
object-fit="cover"
/>
</div>
<div v-if="listInfo?.creator" class="creator-info">
<!-- 歌单显示创建者专辑显示艺术家 -->
<div v-if="isAlbum && listInfo?.artist" class="creator-info">
<n-avatar round :size="24" :src="getImgUrl(listInfo.artist.picUrl, '50y50')" />
<span class="creator-name">{{ listInfo.artist.name }}</span>
</div>
<div v-else-if="!isAlbum && 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>
@@ -226,12 +231,16 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { subscribePlaylist, updatePlaylistTracks } from '@/api/music';
import { getMusicDetail } from '@/api/music';
import {
getMusicDetail,
subscribeAlbum,
subscribePlaylist,
updatePlaylistTracks
} from '@/api/music';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import { useDownload } from '@/hooks/useDownload';
import { useMusicStore, usePlayerStore, useRecommendStore } from '@/store';
import { useMusicStore, usePlayerStore, useRecommendStore, useUserStore } from '@/store';
import { SongResult } from '@/types/music';
import { getImgUrl, isElectron, isMobile, setAnimationClass } from '@/utils';
import { getLoginErrorMessage, hasPermission } from '@/utils/auth';
@@ -241,11 +250,13 @@ const route = useRoute();
const playerStore = usePlayerStore();
const musicStore = useMusicStore();
const recommendStore = useRecommendStore();
const userStore = useUserStore();
const message = useMessage();
// 从路由参数或状态管理获取数据
const loading = ref(false);
const isDailyRecommend = computed(() => route.query.type === 'dailyRecommend');
const isAlbum = computed(() => route.query.type === 'album');
const name = computed(() => {
if (isDailyRecommend.value) {
return t('comp.recommendSinger.songlist'); // 日推的标题
@@ -333,7 +344,7 @@ onMounted(() => {
});
const getCoverImgUrl = computed(() => {
const coverImgUrl = listInfo.value?.coverImgUrl;
const coverImgUrl = listInfo.value?.coverImgUrl || listInfo.value?.picUrl;
if (coverImgUrl) {
return coverImgUrl;
}
@@ -801,19 +812,29 @@ const toggleLayout = () => {
localStorage.setItem('musicListLayout', isCompactLayout.value ? 'compact' : 'normal');
};
// 初始化歌单收藏状态
// 初始化收藏状态(支持歌单和专辑)
const checkCollectionStatus = () => {
// 只有歌单类型才能收藏
if (route.query.type === 'playlist' && listInfo.value?.id) {
const type = route.query.type as string;
// 歌单类型的收藏检查
if (type === 'playlist' && listInfo.value?.id) {
canCollect.value = true;
// 检查是否已收藏
isCollected.value = listInfo.value.subscribed || false;
} else {
}
// 专辑类型的收藏检查 - 使用 store 判断
else if (type === 'album' && listInfo.value?.id) {
canCollect.value = true;
// 从 userStore 中判断是否已收藏
isCollected.value = userStore.isAlbumCollected(listInfo.value.id);
}
// 其他类型不支持收藏
else {
canCollect.value = false;
isCollected.value = false;
}
};
// 切换收藏状态
// 切换收藏状态(支持歌单和专辑)
const toggleCollect = async () => {
if (!listInfo.value?.id) return;
@@ -823,13 +844,23 @@ const toggleCollect = async () => {
return;
}
const type = route.query.type as string;
try {
loadingList.value = true;
const tVal = isCollected.value ? 2 : 1; // 1:收藏, 2:取消收藏
const response = await subscribePlaylist({
t: tVal,
id: listInfo.value.id
});
// 根据类型调用不同的API
const response =
type === 'album'
? await subscribeAlbum({
t: tVal,
id: listInfo.value.id
})
: await subscribePlaylist({
t: tVal,
id: listInfo.value.id
});
// 假设API返回格式是 { data: { code: number, msg?: string } }
const res = response.data;
@@ -840,13 +871,25 @@ const toggleCollect = async () => {
? 'comp.musicList.collectSuccess'
: 'comp.musicList.cancelCollectSuccess';
message.success(t(msgKey));
// 更新歌单信息
listInfo.value.subscribed = isCollected.value;
// 更新收藏状态
if (type === 'album') {
// 专辑:更新 store 中的收藏状态
if (isCollected.value) {
userStore.addCollectedAlbum(listInfo.value.id);
} else {
userStore.removeCollectedAlbum(listInfo.value.id);
}
(listInfo.value as any).isSub = isCollected.value;
} else {
// 歌单:更新 listInfo 的状态
listInfo.value.subscribed = isCollected.value;
}
} else {
throw new Error(res.msg || t('comp.musicList.operationFailed'));
}
} catch (error) {
console.error('收藏歌单失败:', error);
console.error(`收藏${type === 'album' ? '专辑' : '歌单'}失败:`, error);
message.error(t('comp.musicList.operationFailed'));
} finally {
loadingList.value = false;

View File

@@ -32,41 +32,50 @@
</div>
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class="title">
<div>{{ t('user.playlist.created') }}</div>
<div class="import-btn" @click="goToImportPlaylist" v-if="isElectron">
{{ t('comp.playlist.import.button') }}
</div>
<div class="tab-container">
<n-tabs v-model:value="currentTab" type="segment" animated>
<n-tab v-for="tab in tabs" :key="tab.key" :name="tab.key" :tab="t(tab.label)">
</n-tab>
</n-tabs>
</div>
<n-scrollbar>
<div
v-for="(item, index) in playList"
:key="index"
class="play-list-item"
@click="openPlaylist(item)"
>
<n-image
:src="getImgUrl(item.coverImgUrl, '50y50')"
class="play-list-item-img"
lazy
preview-disabled
/>
<div class="play-list-item-info">
<div class="play-list-item-name">
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
<div v-if="item.creator.userId === user.userId" class="playlist-creator-tag">
{{ t('user.playlist.mine') }}
<div class="mt-4">
<button
class="play-list-item"
@click="goToImportPlaylist"
v-if="isElectron && currentTab === 'created'"
>
<div class="play-list-item-img"><i class="icon iconfont ri-add-line"></i></div>
<div class="play-list-item-info">
<div class="play-list-item-name">
{{ t('comp.playlist.import.button') }}
</div>
</div>
<div class="play-list-item-count">
{{ t('user.playlist.trackCount', { count: item.trackCount }) }}{{
t('user.playlist.playCount', { count: item.playCount })
}}
</button>
<div
v-for="(item, index) in currentList"
:key="index"
class="play-list-item"
@click="handleItemClick(item)"
>
<n-image
:src="getImgUrl(getCoverUrl(item), '50y50')"
class="play-list-item-img"
lazy
preview-disabled
/>
<div class="play-list-item-info">
<div class="play-list-item-name">
<n-ellipsis :line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="play-list-item-count">
{{ getItemDescription(item) }}
</div>
</div>
</div>
<div class="pb-20"></div>
<play-bottom />
</div>
<div class="pb-20"></div>
<play-bottom />
</n-scrollbar>
</div>
</div>
@@ -114,7 +123,7 @@ import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { getListDetail } from '@/api/list';
import { getUserDetail, getUserPlaylist, getUserRecord } from '@/api/user';
import { getUserAlbumSublist, getUserDetail, getUserPlaylist, getUserRecord } from '@/api/user';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
@@ -143,8 +152,70 @@ const list = ref<Playlist>();
const listLoading = ref(false);
const message = useMessage();
// Tab 相关
const tabs = [
{ key: 'created', label: 'user.tabs.created' },
{ key: 'favorite', label: 'user.tabs.favorite' },
{ 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);
});
// 收藏的歌单(非当前用户创建的)
const favoritePlaylists = computed(() => {
if (!user.value) return [];
return playList.value.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 [];
}
});
// 获取封面图片 URL
const getCoverUrl = (item: any) => {
return item.coverImgUrl || item.picUrl || '';
};
// 获取列表项描述
const getItemDescription = (item: any) => {
if (currentTab.value === 'album') {
// 专辑:显示艺术家和歌曲数量
const artist = item.artist?.name || '';
const size = item.size ? ` · ${item.size}` : '';
return `${artist}${size}`;
} else {
// 歌单:显示曲目数和播放量
return `${t('user.playlist.trackCount', { count: item.trackCount })}${t('user.playlist.playCount', { count: item.playCount })}`;
}
};
// 统一处理列表项点击
const handleItemClick = (item: any) => {
if (currentTab.value === 'album') {
openAlbum(item);
} else {
openPlaylist(item);
}
};
const goToImportPlaylist = () => {
router.push('/playlist/import');
};
@@ -231,6 +302,23 @@ const loadData = async () => {
}
};
// 加载专辑列表
const loadAlbumList = async () => {
try {
infoLoading.value = true;
const res = await getUserAlbumSublist({ limit: 100, offset: 0 });
if (!mounted.value) return;
albumList.value = res.data.data || [];
} catch (error: any) {
console.error('加载专辑列表失败:', error);
message.error('加载专辑列表失败');
} finally {
if (mounted.value) {
infoLoading.value = false;
}
}
};
// 监听路由变化
watch(
() => router.currentRoute.value.path,
@@ -255,6 +343,18 @@ watch(
}
);
// 监听 tab 切换
watch(currentTab, async (newTab) => {
if (newTab === 'album') {
// 刷新收藏专辑列表到 store
await userStore.initializeCollectedAlbums();
// 如果本地列表为空,则加载
if (albumList.value.length === 0) {
loadAlbumList();
}
}
});
// 页面挂载时检查登录状态
onMounted(() => {
checkLoginStatus() && loadData();
@@ -279,6 +379,38 @@ const openPlaylist = (item: any) => {
});
};
// 打开专辑
const openAlbum = async (item: any) => {
// 使用专辑 API 获取专辑详情
try {
listLoading.value = true;
const { getAlbumDetail } = await import('@/api/music');
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((item) => ({
...item,
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('加载专辑失败');
} finally {
listLoading.value = false;
}
};
const handlePlay = () => {
const tracks = recordList.value || [];
playerStore.setPlayList(tracks);
@@ -321,13 +453,7 @@ const currentLoginType = computed(() => userStore.loginType);
.title {
@apply text-lg font-bold flex items-center justify-between;
@apply text-gray-900 dark:text-white;
.import-btn {
@apply bg-light-100 font-normal rounded-lg px-2 py-1 text-opacity-70 text-sm hover:bg-light-200 hover:text-green-500 dark:bg-dark-200 dark:hover:bg-dark-300 dark:hover:text-green-400;
@apply cursor-pointer;
@apply transition-all duration-200;
}
}
.user-name {
@apply text-xl font-bold mb-4 flex justify-between;
@apply text-white text-opacity-70;
@@ -393,14 +519,15 @@ const currentLoginType = computed(() => userStore.loginType);
}
&-item {
@apply flex items-center px-2 py-1 rounded-xl cursor-pointer;
@apply flex items-center px-2 py-2 rounded-xl cursor-pointer w-full;
@apply transition-all duration-200;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
&-img {
width: 60px;
height: 60px;
@apply rounded-xl;
@apply flex items-center justify-center rounded-xl text-[40px] w-[60px] h-[60px] bg-light-300 dark:bg-dark-300;
.iconfont {
@apply text-[40px];
}
}
&-info {
@@ -442,4 +569,11 @@ const currentLoginType = computed(() => userStore.loginType);
@apply flex justify-center items-center h-full w-full;
}
}
:deep(.n-tabs-rail) {
@apply rounded-xl overflow-hidden !important;
.n-tabs-capsule {
@apply rounded-xl !important;
}
}
</style>