mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-07 09:00:50 +08:00
✨ feat: 增加用户关注列表 和 用户详情页
This commit is contained in:
@@ -13,6 +13,27 @@ export default {
|
||||
title: 'Listening History',
|
||||
playCount: '{count} times'
|
||||
},
|
||||
follow: {
|
||||
title: 'Follow List',
|
||||
viewPlaylist: 'View Playlist',
|
||||
noFollowings: 'No Followings',
|
||||
loadMore: 'Load More',
|
||||
noSignature: 'This guy is lazy, nothing left'
|
||||
},
|
||||
follower: {
|
||||
title: 'Follower List',
|
||||
noFollowers: 'No Followers',
|
||||
loadMore: 'Load More'
|
||||
},
|
||||
detail: {
|
||||
playlists: 'Playlists',
|
||||
records: 'Listening History',
|
||||
noPlaylists: 'No Playlists',
|
||||
noRecords: 'No Listening History',
|
||||
artist: 'Artist',
|
||||
noSignature: 'This guy is lazy, nothing left',
|
||||
invalidUserId: 'Invalid User ID'
|
||||
},
|
||||
message: {
|
||||
loadFailed: 'Failed to load user page',
|
||||
deleteSuccess: 'Successfully deleted',
|
||||
|
||||
@@ -13,6 +13,27 @@ export default {
|
||||
title: '听歌排行',
|
||||
playCount: '{count}次'
|
||||
},
|
||||
follow: {
|
||||
title: '关注列表',
|
||||
viewPlaylist: '查看歌单',
|
||||
noFollowings: '暂无关注',
|
||||
loadMore: '加载更多',
|
||||
noSignature: '这个家伙很懒,什么都没留下'
|
||||
},
|
||||
follower: {
|
||||
title: '粉丝列表',
|
||||
noFollowers: '暂无粉丝',
|
||||
loadMore: '加载更多'
|
||||
},
|
||||
detail: {
|
||||
playlists: '歌单',
|
||||
records: '听歌排行',
|
||||
noPlaylists: '暂无歌单',
|
||||
noRecords: '暂无听歌记录',
|
||||
artist: '歌手',
|
||||
noSignature: '这个人很懒,什么都没留下',
|
||||
invalidUserId: '用户ID无效'
|
||||
},
|
||||
message: {
|
||||
loadFailed: '加载用户页面失败',
|
||||
deleteSuccess: '删除成功',
|
||||
|
||||
@@ -26,9 +26,7 @@ const api = {
|
||||
},
|
||||
// 语言相关
|
||||
onLanguageChanged: (callback: (locale: string) => void) => {
|
||||
console.log('注册语言变更监听器');
|
||||
ipcRenderer.on('language-changed', (_event, locale) => {
|
||||
console.log('收到语言变更事件:', locale);
|
||||
callback(locale);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IUserDetail, IUserFollow } from '@/type/user';
|
||||
import request from '@/utils/request';
|
||||
|
||||
// /user/detail
|
||||
@@ -6,8 +7,8 @@ export function getUserDetail(uid: number) {
|
||||
}
|
||||
|
||||
// /user/playlist
|
||||
export function getUserPlaylist(uid: number) {
|
||||
return request.get('/user/playlist', { params: { uid } });
|
||||
export function getUserPlaylist(uid: number, limit: number = 30, offset: number = 0) {
|
||||
return request.get('/user/playlist', { params: { uid, limit, offset } });
|
||||
}
|
||||
|
||||
// 播放历史
|
||||
@@ -15,3 +16,56 @@ export function getUserPlaylist(uid: number) {
|
||||
export function getUserRecord(uid: number, type: number = 0) {
|
||||
return request.get('/user/record', { params: { uid, type } });
|
||||
}
|
||||
|
||||
// 获取用户关注列表
|
||||
// /user/follows?uid=32953014
|
||||
export function getUserFollows(uid: number, limit: number = 30, offset: number = 0) {
|
||||
return request.get('/user/follows', { params: { uid, limit, offset } });
|
||||
}
|
||||
|
||||
// 获取用户粉丝列表
|
||||
export function getUserFollowers(uid: number, limit: number = 30, offset: number = 0) {
|
||||
return request.post('/user/followeds', { uid, limit, offset });
|
||||
}
|
||||
|
||||
// 获取用户账号信息
|
||||
export const getUserAccount = () => {
|
||||
return request<any>({
|
||||
url: '/user/account',
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
// 获取用户详情
|
||||
export const getUserDetailInfo = (params: { uid: string | number }) => {
|
||||
return request<IUserDetail>({
|
||||
url: '/user/detail',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
};
|
||||
|
||||
// 获取用户关注列表
|
||||
export const getUserFollowsInfo = (params: {
|
||||
uid: string | number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => {
|
||||
return request<{
|
||||
follow: IUserFollow[];
|
||||
more: boolean;
|
||||
}>({
|
||||
url: '/user/follows',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
};
|
||||
|
||||
// 获取用户歌单
|
||||
export const getUserPlaylists = (params: { uid: string | number }) => {
|
||||
return request({
|
||||
url: '/user/playlist',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="search-box flex">
|
||||
<div v-if="showBackButton" class="back-button" @click="goBack">
|
||||
<i class="ri-arrow-left-line"></i>
|
||||
</div>
|
||||
<div class="search-box-input flex-1">
|
||||
<n-input
|
||||
v-model:value="searchValue"
|
||||
@@ -127,6 +130,16 @@ const userStore = useUserStore();
|
||||
const userSetOptions = ref(USER_SET_OPTIONS);
|
||||
const { t } = useI18n();
|
||||
|
||||
// 显示返回按钮
|
||||
const showBackButton = computed(() => {
|
||||
return router.currentRoute.value.meta.back === true;
|
||||
});
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 推荐热搜词
|
||||
const hotSearchKeyword = ref(t('comp.searchBar.searchPlaceholder'));
|
||||
const hotSearchValue = ref('');
|
||||
@@ -262,6 +275,15 @@ const toGithubRelease = () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.back-button {
|
||||
@apply mr-2 flex items-center justify-center text-xl cursor-pointer;
|
||||
@apply w-9 h-9 rounded-full;
|
||||
@apply bg-light-100 dark:bg-dark-100 text-gray-900 dark:text-white;
|
||||
@apply border dark:border-gray-600 border-gray-200;
|
||||
@apply hover:bg-light-200 dark:hover:bg-dark-200;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.user-box {
|
||||
@apply ml-4 flex text-lg justify-center items-center rounded-full transition-colors duration-200;
|
||||
@apply border dark:border-gray-600 border-gray-200 hover:border-gray-400 dark:hover:border-gray-400;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
|
||||
import AppLayout from '@/layout/AppLayout.vue';
|
||||
import homeRouter from '@/router/home';
|
||||
import otherRouter from '@/router/other';
|
||||
|
||||
const loginRouter = {
|
||||
path: '/login',
|
||||
@@ -29,7 +30,7 @@ const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
children: [...homeRouter, loginRouter, setRouter]
|
||||
children: [...homeRouter, loginRouter, setRouter, ...otherRouter]
|
||||
},
|
||||
{
|
||||
path: '/lyric',
|
||||
|
||||
36
src/renderer/router/other.ts
Normal file
36
src/renderer/router/other.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const otherRouter = [
|
||||
{
|
||||
path: '/user/follows',
|
||||
name: 'userFollows',
|
||||
meta: {
|
||||
title: '关注列表',
|
||||
keepAlive: true,
|
||||
showInMenu: false,
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/user/follows.vue')
|
||||
},
|
||||
{
|
||||
path: '/user/followers',
|
||||
name: 'userFollowers',
|
||||
meta: {
|
||||
title: '粉丝列表',
|
||||
keepAlive: true,
|
||||
showInMenu: false,
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/user/followers.vue')
|
||||
},
|
||||
{
|
||||
path: '/user/detail/:uid',
|
||||
name: 'userDetail',
|
||||
meta: {
|
||||
title: '用户详情',
|
||||
keepAlive: true,
|
||||
showInMenu: false,
|
||||
back: true
|
||||
},
|
||||
component: () => import('@/views/user/detail.vue')
|
||||
}
|
||||
];
|
||||
export default otherRouter;
|
||||
@@ -14,6 +14,20 @@ export interface IUserDetail {
|
||||
profileVillageInfo: ProfileVillageInfo;
|
||||
}
|
||||
|
||||
export interface IUserFollow {
|
||||
followed: boolean;
|
||||
follows: boolean;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
userId: number;
|
||||
gender: number;
|
||||
signature: string;
|
||||
backgroundUrl: string;
|
||||
vipType: number;
|
||||
userType: number;
|
||||
accountType: number;
|
||||
}
|
||||
|
||||
interface ProfileVillageInfo {
|
||||
title: string;
|
||||
imageUrl?: any;
|
||||
|
||||
@@ -27,7 +27,8 @@ const baseURL = window.electron
|
||||
|
||||
const request = axios.create({
|
||||
baseURL,
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
// 最大重试次数
|
||||
@@ -54,8 +55,13 @@ request.interceptors.request.use(
|
||||
timestamp: Date.now()
|
||||
};
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
if (token && config.method !== 'post') {
|
||||
config.params.cookie = config.params.cookie !== undefined ? config.params.cookie : token;
|
||||
} else if (token && config.method === 'post') {
|
||||
config.data = {
|
||||
...config.data,
|
||||
cookie: token
|
||||
};
|
||||
}
|
||||
if (isElectron) {
|
||||
const proxyConfig = setData?.proxyConfig;
|
||||
|
||||
376
src/renderer/views/user/detail.vue
Normal file
376
src/renderer/views/user/detail.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="user-detail-page">
|
||||
<n-scrollbar class="content-scrollbar">
|
||||
<div v-loading="loading" class="content-wrapper">
|
||||
<template v-if="userDetail">
|
||||
<!-- 用户信息部分 -->
|
||||
<div class="user-info-section" :class="setAnimationClass('animate__fadeInDown')">
|
||||
<div
|
||||
class="user-info-bg"
|
||||
:style="{ backgroundImage: `url(${getImgUrl(userDetail.profile.backgroundUrl)})` }"
|
||||
>
|
||||
<div class="user-info-content">
|
||||
<n-avatar
|
||||
round
|
||||
:size="80"
|
||||
:src="getImgUrl(userDetail.profile.avatarUrl, '80y80')"
|
||||
/>
|
||||
<div class="user-info-detail">
|
||||
<div class="user-info-name">
|
||||
{{ userDetail.profile.nickname }}
|
||||
<n-tooltip v-if="isArtist(userDetail.profile)" trigger="hover">
|
||||
<template #trigger>
|
||||
<i class="ri-verified-badge-fill artist-icon"></i>
|
||||
</template>
|
||||
{{ t('user.detail.artist') }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
<div class="user-info-stats">
|
||||
<div class="user-info-stat-item">
|
||||
<div class="label">{{ userDetail.profile.followeds }}</div>
|
||||
<div>{{ t('user.profile.followers') }}</div>
|
||||
</div>
|
||||
<div class="user-info-stat-item">
|
||||
<div class="label">{{ userDetail.profile.follows }}</div>
|
||||
<div>{{ t('user.profile.following') }}</div>
|
||||
</div>
|
||||
<div class="user-info-stat-item">
|
||||
<div class="label">{{ userDetail.level }}</div>
|
||||
<div>{{ t('user.profile.level') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-info-signature">
|
||||
{{ userDetail.profile.signature || t('user.detail.noSignature') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-tabs type="line" animated>
|
||||
<!-- 歌单列表 -->
|
||||
<n-tab-pane name="playlists" :tab="t('user.detail.playlists')">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<n-spin size="medium" />
|
||||
</div>
|
||||
<div v-else-if="playList.length === 0" class="empty-message">
|
||||
{{ t('user.detail.noPlaylists') }}
|
||||
</div>
|
||||
<div v-else class="playlist-grid" :class="setAnimationClass('animate__fadeInUp')">
|
||||
<div
|
||||
v-for="(item, index) in playList"
|
||||
:key="index"
|
||||
class="playlist-item"
|
||||
:class="setAnimationClass('animate__fadeInUp')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
@click="showPlaylist(item.id, item.name)"
|
||||
>
|
||||
<div class="playlist-cover">
|
||||
<n-image
|
||||
:src="getImgUrl(item.coverImgUrl, '200y200')"
|
||||
lazy
|
||||
preview-disabled
|
||||
class="cover-img"
|
||||
/>
|
||||
<div class="play-count">
|
||||
<i class="ri-play-fill"></i>
|
||||
{{ formatNumber(item.playCount) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-name">{{ item.name }}</div>
|
||||
<div class="playlist-stats">
|
||||
{{ t('user.playlist.trackCount', { count: item.trackCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<!-- 听歌排行 -->
|
||||
<n-tab-pane name="records" :tab="t('user.detail.records')">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<n-spin size="medium" />
|
||||
</div>
|
||||
<div v-else-if="!recordList || recordList.length === 0" class="empty-message">
|
||||
{{ t('user.detail.noRecords') }}
|
||||
</div>
|
||||
<div v-else class="record-list">
|
||||
<div
|
||||
v-for="(item, index) in recordList"
|
||||
:key="item.id"
|
||||
class="record-item"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="setAnimationDelay(index, 25)"
|
||||
>
|
||||
<div class="play-score">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<song-item class="song-item" :item="item" mini @play="handlePlay" />
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
<div v-else-if="!loading" class="empty-message">
|
||||
{{ t('user.message.loadFailed') }}
|
||||
</div>
|
||||
|
||||
<!-- 底部留白 -->
|
||||
<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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 { usePlayerStore } from '@/store/modules/player';
|
||||
import type { Playlist } from '@/type/listDetail';
|
||||
import type { IUserDetail } from '@/type/user';
|
||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserDetail'
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const message = useMessage();
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
// 获取路由参数中的用户ID
|
||||
const userId = ref<number>(Number(route.params.uid));
|
||||
|
||||
// 用户数据
|
||||
const userDetail = ref<IUserDetail>();
|
||||
const playList = ref<any[]>([]);
|
||||
const recordList = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
// 歌单详情相关
|
||||
const isShowList = ref(false);
|
||||
const currentList = ref<Playlist>();
|
||||
const listLoading = ref(false);
|
||||
|
||||
// 加载用户数据
|
||||
const loadUserData = async () => {
|
||||
if (!userId.value) {
|
||||
message.error(t('user.detail.invalidUserId'));
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
// 使用 Promise.all 并行请求提高效率
|
||||
const [userDetailRes, playlistRes, recordRes] = await Promise.all([
|
||||
getUserDetail(userId.value),
|
||||
getUserPlaylist(userId.value),
|
||||
getUserRecord(userId.value)
|
||||
]);
|
||||
|
||||
userDetail.value = userDetailRes.data;
|
||||
playList.value = playlistRes.data.playlist;
|
||||
|
||||
if (recordRes.data && recordRes.data.allData) {
|
||||
recordList.value = recordRes.data.allData.map((item: any) => ({
|
||||
...item,
|
||||
...item.song,
|
||||
picUrl: item.song.al.picUrl
|
||||
}));
|
||||
} else {
|
||||
recordList.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户数据失败:', error);
|
||||
message.error('加载用户数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 展示歌单
|
||||
const showPlaylist = async (id: number, name: string) => {
|
||||
isShowList.value = true;
|
||||
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 {
|
||||
listLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 播放歌曲
|
||||
const handlePlay = () => {
|
||||
if (!recordList.value || recordList.value.length === 0) return;
|
||||
|
||||
const tracks = recordList.value;
|
||||
playerStore.setPlayList(tracks);
|
||||
};
|
||||
|
||||
// 判断是否为歌手
|
||||
const isArtist = (profile: any) => {
|
||||
return profile.userType === 4 || profile.userType === 2 || profile.accountType === 2;
|
||||
};
|
||||
|
||||
// 页面挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadUserData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-detail-page {
|
||||
@apply h-full flex flex-col;
|
||||
|
||||
.content-scrollbar {
|
||||
@apply flex-1 overflow-hidden;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
@apply flex flex-col;
|
||||
@apply pr-4 pb-4;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
@apply mb-4;
|
||||
|
||||
.user-info-bg {
|
||||
@apply rounded-xl overflow-hidden bg-cover bg-center relative;
|
||||
height: 200px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
@apply absolute inset-0 bg-black bg-opacity-40;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-content {
|
||||
@apply absolute inset-0 flex items-center p-6;
|
||||
}
|
||||
|
||||
.user-info-detail {
|
||||
@apply ml-4 text-white;
|
||||
|
||||
.user-info-name {
|
||||
@apply text-xl font-bold flex items-center;
|
||||
|
||||
.artist-icon {
|
||||
@apply ml-2 text-blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-stats {
|
||||
@apply flex mt-2;
|
||||
|
||||
.user-info-stat-item {
|
||||
@apply mr-6 text-center;
|
||||
|
||||
.label {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-signature {
|
||||
@apply mt-2 text-sm text-gray-200;
|
||||
@apply line-clamp-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
@apply grid gap-4 w-full py-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
@apply flex flex-col rounded-xl overflow-hidden cursor-pointer;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:scale-105;
|
||||
|
||||
.playlist-cover {
|
||||
@apply relative;
|
||||
aspect-ratio: 1;
|
||||
|
||||
.cover-img {
|
||||
@apply w-full h-full object-cover rounded-xl;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
@apply absolute top-2 right-2 px-2 py-1 rounded-full text-xs;
|
||||
@apply bg-black bg-opacity-50 text-white flex items-center;
|
||||
|
||||
i {
|
||||
@apply mr-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
@apply mt-2 px-1;
|
||||
|
||||
.playlist-name {
|
||||
@apply text-gray-900 dark:text-white font-medium;
|
||||
@apply line-clamp-2 text-sm;
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
@apply text-gray-500 dark:text-gray-400 text-xs mt-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.record-list {
|
||||
@apply p-4;
|
||||
|
||||
.record-item {
|
||||
@apply flex items-center mb-2 rounded-2xl;
|
||||
@apply bg-light-100 dark:bg-dark-100;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-light-200 dark:hover:bg-dark-200;
|
||||
}
|
||||
|
||||
.play-score {
|
||||
@apply text-gray-500 dark:text-gray-400 mr-2 text-lg w-10 h-10 rounded-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
@apply flex-1;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@apply flex justify-center items-center p-8;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
@apply flex justify-center items-center p-8;
|
||||
}
|
||||
</style>
|
||||
241
src/renderer/views/user/followers.vue
Normal file
241
src/renderer/views/user/followers.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="followers-page">
|
||||
<div class="content-wrapper">
|
||||
<n-spin v-if="followerListLoading && followerList.length === 0" size="large" />
|
||||
<n-scrollbar v-else class="scrollbar-container">
|
||||
<div v-if="followerList.length === 0" class="empty-follower">
|
||||
{{ t('user.follower.noFollowers') }}
|
||||
</div>
|
||||
<div class="follower-grid" :class="setAnimationClass('animate__fadeInUp')">
|
||||
<div
|
||||
v-for="(item, index) in followerList"
|
||||
:key="index"
|
||||
class="follower-item"
|
||||
:class="setAnimationClass('animate__fadeInUp')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
@click="viewUserDetail(item.userId, item.nickname)"
|
||||
>
|
||||
<div class="follower-item-inner">
|
||||
<div class="follower-avatar">
|
||||
<n-avatar round :size="70" :src="getImgUrl(item.avatarUrl, '70y70')" />
|
||||
<div v-if="isArtist(item)" class="artist-badge">
|
||||
<i class="ri-verified-badge-fill"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="follower-info">
|
||||
<div class="follower-name" :class="{ 'is-artist': isArtist(item) }">
|
||||
{{ item.nickname }}
|
||||
<n-tooltip v-if="isArtist(item)" trigger="hover">
|
||||
<template #trigger>
|
||||
<i class="ri-verified-badge-fill artist-icon"></i>
|
||||
</template>
|
||||
歌手
|
||||
</n-tooltip>
|
||||
</div>
|
||||
<div class="follower-signature">
|
||||
{{ item.signature || '这个人很懒,什么都没留下' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-space v-if="followerListLoading" justify="center" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
</n-space>
|
||||
|
||||
<n-button
|
||||
v-else-if="hasMoreFollowers"
|
||||
class="load-more-btn"
|
||||
secondary
|
||||
block
|
||||
@click="loadMoreFollowers"
|
||||
>
|
||||
{{ t('user.follower.loadMore') }}
|
||||
</n-button>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getUserFollowers } from '@/api/user';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import type { IUserFollow } from '@/type/user';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserFollowers'
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
// 粉丝列表相关
|
||||
const followerList = ref<IUserFollow[]>([]);
|
||||
const followerOffset = ref(0);
|
||||
const followerLimit = ref(30);
|
||||
const hasMoreFollowers = ref(false);
|
||||
const followerListLoading = ref(false);
|
||||
|
||||
const user = computed(() => userStore.user);
|
||||
|
||||
// 检查登录状态
|
||||
const checkLoginStatus = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const userData = localStorage.getItem('user');
|
||||
|
||||
if (!token || !userData) {
|
||||
router.push('/login');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果store中没有用户数据,但localStorage中有,则恢复用户数据
|
||||
if (!userStore.user && userData) {
|
||||
userStore.setUser(JSON.parse(userData));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 加载粉丝列表
|
||||
const loadFollowerList = async () => {
|
||||
if (!user.value) return;
|
||||
|
||||
try {
|
||||
followerListLoading.value = true;
|
||||
const { data } = await getUserFollowers(
|
||||
user.value.userId,
|
||||
followerLimit.value,
|
||||
followerOffset.value
|
||||
);
|
||||
|
||||
if (!data || !data.followeds) {
|
||||
hasMoreFollowers.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newFollowers = data.followeds as IUserFollow[];
|
||||
followerList.value = [...followerList.value, ...newFollowers];
|
||||
|
||||
// 判断是否还有更多粉丝
|
||||
hasMoreFollowers.value = newFollowers.length >= followerLimit.value;
|
||||
} catch (error) {
|
||||
console.error('加载粉丝列表失败:', error);
|
||||
message.error('加载粉丝列表失败');
|
||||
} finally {
|
||||
followerListLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多粉丝
|
||||
const loadMoreFollowers = async () => {
|
||||
followerOffset.value += followerLimit.value;
|
||||
await loadFollowerList();
|
||||
};
|
||||
|
||||
// 查看用户详情
|
||||
const viewUserDetail = (userId: number, nickname: string) => {
|
||||
router.push({
|
||||
path: `/user/detail/${userId}`,
|
||||
query: { name: nickname }
|
||||
});
|
||||
};
|
||||
|
||||
// 判断是否为歌手
|
||||
const isArtist = (user: IUserFollow) => {
|
||||
// 根据用户类型判断是否为歌手,userType 为 4 表示是官方认证的音乐人
|
||||
return user.userType === 4 || user.userType === 2 || user.accountType === 2;
|
||||
};
|
||||
|
||||
// 页面挂载时加载数据
|
||||
onMounted(() => {
|
||||
if (checkLoginStatus()) {
|
||||
loadFollowerList();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.followers-page {
|
||||
@apply h-full flex flex-col;
|
||||
|
||||
.content-wrapper {
|
||||
@apply flex-1 overflow-hidden p-4;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.scrollbar-container {
|
||||
@apply h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.follower-grid {
|
||||
@apply grid gap-4 w-full;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.follower-item {
|
||||
@apply rounded-xl overflow-hidden cursor-pointer;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:scale-105;
|
||||
|
||||
&-inner {
|
||||
@apply flex flex-col items-center p-4 h-full;
|
||||
@apply bg-light-100 dark:bg-dark-100;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-light-200 dark:hover:bg-dark-200;
|
||||
}
|
||||
|
||||
.follower-avatar {
|
||||
@apply relative;
|
||||
|
||||
.artist-badge {
|
||||
@apply absolute bottom-0 right-0;
|
||||
@apply text-blue-500 text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.follower-info {
|
||||
@apply mt-3 text-center w-full;
|
||||
|
||||
.follower-name {
|
||||
@apply text-gray-900 dark:text-white text-base font-medium;
|
||||
@apply flex items-center justify-center;
|
||||
|
||||
&.is-artist {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
|
||||
.artist-icon {
|
||||
@apply ml-1 text-blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.follower-signature {
|
||||
@apply text-gray-500 dark:text-gray-400 text-xs mt-1;
|
||||
@apply line-clamp-2 text-center;
|
||||
max-height: 2.4em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-follower {
|
||||
@apply text-center py-8 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
@apply mt-4 mb-8;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply my-4;
|
||||
}
|
||||
</style>
|
||||
237
src/renderer/views/user/follows.vue
Normal file
237
src/renderer/views/user/follows.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="follows-page">
|
||||
<div class="content-wrapper">
|
||||
<n-spin v-if="followListLoading && followList.length === 0" size="large" />
|
||||
<n-scrollbar v-else class="scrollbar-container">
|
||||
<div v-if="followList.length === 0" class="empty-follow">
|
||||
{{ t('user.follow.noFollowings') }}
|
||||
</div>
|
||||
<div class="follow-grid" :class="setAnimationClass('animate__fadeInUp')">
|
||||
<div
|
||||
v-for="(item, index) in followList"
|
||||
:key="index"
|
||||
class="follow-item"
|
||||
:class="setAnimationClass('animate__fadeInUp')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
@click="viewUserDetail(item.userId, item.nickname)"
|
||||
>
|
||||
<div class="follow-item-inner">
|
||||
<div class="follow-avatar">
|
||||
<n-avatar round :size="70" :src="getImgUrl(item.avatarUrl, '70y70')" />
|
||||
<div v-if="isArtist(item)" class="artist-badge">
|
||||
<i class="ri-verified-badge-fill"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="follow-info">
|
||||
<div class="follow-name" :class="{ 'is-artist': isArtist(item) }">
|
||||
{{ item.nickname }}
|
||||
<n-tooltip v-if="isArtist(item)" trigger="hover">
|
||||
<template #trigger>
|
||||
<i class="ri-verified-badge-fill artist-icon"></i>
|
||||
</template>
|
||||
歌手
|
||||
</n-tooltip>
|
||||
</div>
|
||||
<div class="follow-signature">
|
||||
{{ item.signature || t('user.follow.noSignature') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-space v-if="followListLoading" justify="center" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
</n-space>
|
||||
|
||||
<n-button
|
||||
v-else-if="hasMoreFollows"
|
||||
class="load-more-btn"
|
||||
secondary
|
||||
block
|
||||
@click="loadMoreFollows"
|
||||
>
|
||||
{{ t('user.follow.loadMore') }}
|
||||
</n-button>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getUserFollows } from '@/api/user';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import type { IUserFollow } from '@/type/user';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserFollows'
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
|
||||
// 关注列表相关
|
||||
const followList = ref<IUserFollow[]>([]);
|
||||
const followOffset = ref(0);
|
||||
const followLimit = ref(30);
|
||||
const hasMoreFollows = ref(false);
|
||||
const followListLoading = ref(false);
|
||||
|
||||
const user = computed(() => userStore.user);
|
||||
|
||||
// 检查登录状态
|
||||
const checkLoginStatus = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const userData = localStorage.getItem('user');
|
||||
|
||||
if (!token || !userData) {
|
||||
router.push('/login');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果store中没有用户数据,但localStorage中有,则恢复用户数据
|
||||
if (!userStore.user && userData) {
|
||||
userStore.setUser(JSON.parse(userData));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 加载关注列表
|
||||
const loadFollowList = async () => {
|
||||
if (!user.value) return;
|
||||
|
||||
try {
|
||||
followListLoading.value = true;
|
||||
const { data } = await getUserFollows(user.value.userId, followLimit.value, followOffset.value);
|
||||
|
||||
if (!data || !data.follow) {
|
||||
hasMoreFollows.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newFollows = data.follow as IUserFollow[];
|
||||
followList.value = [...followList.value, ...newFollows];
|
||||
|
||||
// 判断是否还有更多关注
|
||||
hasMoreFollows.value = newFollows.length >= followLimit.value;
|
||||
} catch (error) {
|
||||
console.error('加载关注列表失败:', error);
|
||||
message.error('加载关注列表失败');
|
||||
} finally {
|
||||
followListLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多关注
|
||||
const loadMoreFollows = async () => {
|
||||
followOffset.value += followLimit.value;
|
||||
await loadFollowList();
|
||||
};
|
||||
|
||||
// 查看用户详情
|
||||
const viewUserDetail = (userId: number, nickname: string) => {
|
||||
router.push({
|
||||
path: `/user/detail/${userId}`,
|
||||
query: { name: nickname }
|
||||
});
|
||||
};
|
||||
|
||||
// 判断是否为歌手
|
||||
const isArtist = (user: IUserFollow) => {
|
||||
// 根据用户类型判断是否为歌手,userType 为 4 表示是官方认证的音乐人
|
||||
return user.userType === 4 || user.userType === 2 || user.accountType === 2;
|
||||
};
|
||||
|
||||
// 页面挂载时加载数据
|
||||
onMounted(() => {
|
||||
if (checkLoginStatus()) {
|
||||
loadFollowList();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.follows-page {
|
||||
@apply h-full flex flex-col;
|
||||
|
||||
.content-wrapper {
|
||||
@apply flex-1 overflow-hidden p-4;
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.scrollbar-container {
|
||||
@apply h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.follow-grid {
|
||||
@apply grid gap-4 w-full;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.follow-item {
|
||||
@apply rounded-xl overflow-hidden cursor-pointer;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:scale-105;
|
||||
|
||||
&-inner {
|
||||
@apply flex flex-col items-center p-4 h-full;
|
||||
@apply bg-light-100 dark:bg-dark-100;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-light-200 dark:hover:bg-dark-200;
|
||||
}
|
||||
|
||||
.follow-avatar {
|
||||
@apply relative;
|
||||
|
||||
.artist-badge {
|
||||
@apply absolute bottom-0 right-0;
|
||||
@apply text-blue-500 text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.follow-info {
|
||||
@apply mt-3 text-center w-full;
|
||||
|
||||
.follow-name {
|
||||
@apply text-gray-900 dark:text-white text-base font-medium;
|
||||
@apply flex items-center justify-center;
|
||||
|
||||
&.is-artist {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
|
||||
.artist-icon {
|
||||
@apply ml-1 text-blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.follow-signature {
|
||||
@apply text-gray-500 dark:text-gray-400 text-xs mt-1;
|
||||
@apply line-clamp-2 text-center;
|
||||
max-height: 2.4em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-follow {
|
||||
@apply text-center py-8 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
@apply mt-4 mb-8;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply my-4;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="label">{{ userDetail.profile.followeds }}</div>
|
||||
<div>{{ t('user.profile.followers') }}</div>
|
||||
</div>
|
||||
<div class="user-info-item">
|
||||
<div class="user-info-item" @click="showFollowList">
|
||||
<div class="label">{{ userDetail.profile.follows }}</div>
|
||||
<div>{{ t('user.profile.following') }}</div>
|
||||
</div>
|
||||
@@ -73,10 +73,10 @@
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="setAnimationDelay(index, 25)"
|
||||
>
|
||||
<song-item class="song-item" :item="item" @play="handlePlay" />
|
||||
<div class="play-count">
|
||||
{{ t('user.ranking.playCount', { count: item.playCount }) }}
|
||||
<div class="play-score">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<song-item class="song-item" :item="item" mini @play="handlePlay" />
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -223,6 +223,7 @@ watch(
|
||||
(newUser) => {
|
||||
if (!mounted.value) return;
|
||||
if (newUser) {
|
||||
checkLoginStatus();
|
||||
loadPage();
|
||||
}
|
||||
}
|
||||
@@ -280,6 +281,18 @@ const handlePlay = () => {
|
||||
const tracks = recordList.value || [];
|
||||
playerStore.setPlayList(tracks);
|
||||
};
|
||||
|
||||
// 显示关注列表
|
||||
const showFollowList = () => {
|
||||
if (!user.value) return;
|
||||
router.push('/user/follows');
|
||||
};
|
||||
|
||||
// // 显示粉丝列表
|
||||
// const showFollowerList = () => {
|
||||
// if (!user.value) return;
|
||||
// router.push('/user/followers');
|
||||
// };
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -319,6 +332,10 @@ const handlePlay = () => {
|
||||
@apply text-xl font-bold text-white;
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,16 +348,15 @@ const handlePlay = () => {
|
||||
height: calc(100% - 100px);
|
||||
|
||||
.record-item {
|
||||
@apply flex items-center px-4;
|
||||
@apply flex items-center px-2 mb-2 rounded-2xl bg-light-100 dark:bg-dark-100;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.play-count {
|
||||
@apply ml-4;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
.play-score {
|
||||
@apply text-gray-500 dark:text-gray-400 mr-2 text-lg w-10 h-10 rounded-full flex items-center justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user