Files
graduation-design/music-qianduan/src/views/SingerDetail.vue
ikmkj 35db37ca12 feat(music): 优化音乐和歌手相关数据结构和功能
- 在 MusicDTO 中添加 typeName 和 releaseDate 字段
- 在 SingerDTO 中将 type 替换为 typeId 和 typeName
- 更新 MusicServiceImpl 和 SingerServiceImpl 中的相关逻辑
- 在前端 SingerDetail 组件中使用 typeName 替换类型代码
2025-07-26 22:12:39 +08:00

788 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue' // 添加 onUnmounted
import { useRoute, useRouter } from 'vue-router'
import { usePlayerStore } from '../stores/player'
import { useUserStore } from '../stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSingerById, isSingerCollected, collectSinger, uncollectSinger } from '../api/singer'
import { getCommentList, addComment, deleteComment as apiDeleteComment } from '../api/comment'
import { processImageUrl } from '../utils/imageUtils'
import { processMusicUrl, getMusicBySinger, getMusicStatus } from '../api/music'
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket' // 导入 WebSocket 工具
import MusicList from '../components/MusicList.vue'
const route = useRoute()
const router = useRouter()
const playerStore = usePlayerStore()
const userStore = useUserStore()
// 歌手ID
const singerId = computed(() => route.params.id)
// 歌手详情
const singer = ref(null)
// 歌手的音乐列表
const songs = ref([])
// 歌手的音乐列表
// 评论列表
const comments = ref([])
// 评论内容
const commentContent = ref('')
// 回复内容
const replyContent = ref('')
// 回复对象
const replyTo = ref(null)
// 正在回复的评论
const replyingTo = ref(null)
// 展开的评论
const expandedComments = ref([])
// 加载状态
const loading = ref(false)
// 收藏状态
const isCollected = ref(false)
// 获取歌手详情
const fetchSingerDetail = async () => {
loading.value = true
try {
const res = await getSingerById(singerId.value)
if (res.code === 200) {
singer.value = res.data
// 处理头像图片URL
if (singer.value.avatar) {
singer.value.avatar = processImageUrl(singer.value.avatar)
}
// 获取歌手的音乐列表
await fetchSingerMusic()
// 如果用户已登录,则检查是否收藏
if (userStore.isLoggedIn) {
checkIfCollected()
}
} else {
ElMessage.error(res.message || '获取歌手详情失败')
}
} catch (error) {
console.error('获取歌手详情失败:', error)
ElMessage.error('获取歌手详情失败')
} finally {
loading.value = false
}
}
// 检查是否收藏
const checkIfCollected = async () => {
try {
const res = await isSingerCollected(singerId.value)
if (res.code === 200) {
isCollected.value = res.data
}
} catch (error) {
console.error('检查收藏状态失败:', error)
}
}
// 获取歌手的音乐列表
const fetchSingerMusic = async () => {
try {
const res = await getMusicBySinger(singerId.value, 1, 20)
if (res.code === 200) {
songs.value = res.data.list || res.data.records || []
// 处理音乐URL和封面URL
songs.value = songs.value.map(song => ({
...song,
cover: processMusicUrl(song.cover),
url: processMusicUrl(song.url)
}))
// 更新歌曲状态
updateMusicStatuses(songs.value)
} else {
ElMessage.error(res.message || '获取歌手音乐失败')
}
} catch (error) {
console.error('获取歌手音乐失败:', error)
ElMessage.error('获取歌手音乐失败')
}
}
// 获取评论列表
const fetchComments = async () => {
try {
const res = await getCommentList(singerId.value, 3, 1, 20) // targetType 3 表示歌手
if (res.code === 200) {
comments.value = res.data.list || res.data.records || []
// 处理用户头像
comments.value.forEach(comment => {
if (comment.user && comment.user.avatar) {
comment.user.avatar = processImageUrl(comment.user.avatar)
}
})
} else {
ElMessage.error(res.message || '获取评论列表失败')
}
} catch (error) {
console.error('获取评论列表失败:', error)
ElMessage.error('获取评论列表失败')
}
}
// 播放全部
const playAll = () => {
if (songs.value.length === 0) {
ElMessage.warning('没有可播放的音乐')
return
}
// 清空当前播放列表
playerStore.clearPlaylist()
// 添加所有音乐到播放列表
songs.value.forEach(song => {
const musicObj = {
...song,
singer: singer.value.name
}
playerStore.addToPlaylist(musicObj)
})
// 播放第一首
playerStore.setCurrentMusic({
...songs.value,
singer: singer.value.name
})
ElMessage.success('开始播放全部歌曲')
}
// 播放音乐
const playMusic = (song) => {
playerStore.setCurrentMusic({
...song,
singer: singer.value.name
})
}
// 添加到播放列表
const addToPlaylist = (song) => {
playerStore.addToPlaylist({
...song,
singer: singer.value.name
})
ElMessage.success('已添加到播放列表')
}
// 收藏/取消收藏歌手
const toggleCollect = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
let res
if (isCollected.value) {
res = await uncollectSinger(singerId.value)
} else {
res = await collectSinger(singerId.value)
}
if (res.code === 200) {
isCollected.value = !isCollected.value
// 更新收藏数
if (isCollected.value) {
singer.value.collectCount++
} else {
singer.value.collectCount--
}
ElMessage.success(isCollected.value ? '收藏成功' : '已取消收藏')
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 切换回复可见性
const toggleReplies = (comment) => {
const commentId = comment.id;
const index = expandedComments.value.indexOf(commentId);
if (index > -1) {
expandedComments.value.splice(index, 1);
if (replyingTo.value === commentId) {
cancelReply();
}
} else {
expandedComments.value.push(commentId);
setReplyTo(comment);
}
};
// 设置回复
const setReplyTo = (comment) => {
replyingTo.value = comment.id;
replyTo.value = comment;
if (!expandedComments.value.includes(comment.id)) {
expandedComments.value.push(comment.id);
}
}
// 取消回复
const cancelReply = () => {
replyingTo.value = null
replyTo.value = null
replyContent.value = ''
}
// 更新音乐状态
const updateMusicStatuses = async (list) => {
if (!userStore.isLoggedIn || !list || list.length === 0) {
return
}
// 为列表中的每首歌曲获取状态
const promises = list.map(music => getMusicStatus(music.id))
try {
const results = await Promise.all(promises)
results.forEach((res, index) => {
if (res.code === 200 && res.data) {
list[index].collected = res.data.collected
list[index].liked = res.data.liked
}
})
} catch (error) {
console.error('批量获取音乐状态失败:', error)
}
}
// 提交评论
const submitComment = async (isReply = false) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
const content = isReply ? replyContent.value : commentContent.value
if (!content.trim()) {
ElMessage.warning('评论内容不能为空')
return
}
try {
const res = await addComment({
targetId: singerId.value,
targetType: 3, // 3表示歌手
content: content,
parentId: isReply ? replyingTo.value : null
})
if (res.code === 200) {
if (isReply) {
replyContent.value = ''
} else {
commentContent.value = ''
}
ElMessage.success('评论已发送')
// 更新评论数
singer.value.commentCount++
} else {
ElMessage.error(res.message || '评论失败')
}
} catch (error) {
console.error('提交评论失败:', error)
ElMessage.error('评论失败,请稍后重试')
}
}
// 删除评论
const deleteComment = async (commentId) => {
try {
await ElMessageBox.confirm('确定要删除这条评论吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await apiDeleteComment(commentId)
if (res.code === 200) {
comments.value = comments.value.filter(c => c.id !== commentId)
// 更新评论数
singer.value.commentCount--
ElMessage.success('评论已删除')
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除评论失败:', error)
ElMessage.error('删除失败,请稍后重试')
}
}
}
// 处理下拉菜单命令
const handleCommand = (command, music) => {
switch (command) {
case 'addToPlaylist':
addToPlaylist(music)
break
case 'playNext':
const currentIndex = playerStore.getCurrentIndex
if (currentIndex !== -1) {
const playlist = [...playerStore.getPlaylist]
const existingIndex = playlist.findIndex(item => item.id === music.id)
if (existingIndex !== -1) {
playlist.splice(existingIndex, 1)
}
playlist.splice(currentIndex + 1, 0, music)
playerStore.$patch({ playlist })
ElMessage.success('已添加到下一首播放')
} else {
playerStore.addToPlaylist(music)
ElMessage.success('已添加到播放列表')
}
break
case 'downloadMusic':
downloadFile('music', music)
break
case 'downloadLyric':
downloadFile('lyric', music)
break
}
}
// 下载文件
const downloadFile = (type, music) => {
const typeName = type === 'music' ? '音乐' : '歌词'
ElMessageBox.confirm(`确定要下载《${music.name}》的${typeName}吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
const url = `/api/file/download/${type}/${music.id}`
window.open(url, '_blank')
ElMessage.success(`${typeName}开始下载...`)
}).catch(() => {
ElMessage.info(`已取消下载${typeName}`)
})
}
// 格式化时间
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 格式化粉丝数
const formatFans = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
// WebSocket 订阅实例
let commentSubscription = null;
onMounted(() => {
fetchSingerDetail()
fetchComments()
// 连接 WebSocket 并订阅评论
connect(() => {
const destination = `/topic/comments/3/${singerId.value}`; // 3 代表歌手类型
commentSubscription = subscribe(destination, (newComment) => {
if (newComment && newComment.id) {
if (newComment.user && newComment.user.avatar) {
newComment.user.avatar = processImageUrl(newComment.user.avatar);
}
if (newComment.parentId) {
const parentComment = comments.value.find(c => c.id === newComment.parentId);
if (parentComment) {
if (!parentComment.children) {
parentComment.children = [];
}
parentComment.children.push(newComment);
}
} else {
if (!comments.value.some(c => c.id === newComment.id)) {
comments.value.unshift(newComment);
}
}
} else {
console.warn("Received invalid comment data via WebSocket:", newComment);
}
});
}, (error) => {
console.error("WebSocket connection error:", error);
// ElMessage.error('评论服务连接失败,实时更新不可用');
});
})
onUnmounted(() => {
// 取消订阅并断开连接
if (commentSubscription) {
const destination = `/topic/comments/3/${singerId.value}`;
unsubscribe(destination);
commentSubscription = null; // 清理订阅实例
}
disconnect();
})
</script>
<template>
<div class="singer-detail-container" v-loading="loading">
<template v-if="singer">
<!-- 歌手信息 -->
<div class="singer-info">
<div class="singer-avatar">
<img :src="singer.avatar" :alt="singer.name">
</div>
<div class="singer-meta">
<h1 class="singer-name">{{ singer.name }}</h1>
<div class="singer-type">类型{{ singer.typeName }}</div>
<div class="singer-stats">
<div class="stat-item">
<div class="stat-value">{{ singer.musicCount }}</div>
<div class="stat-label">单曲数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ formatFans(singer.fansCount || 0) }}</div>
<div class="stat-label">粉丝数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ singer.collectCount || 0 }}</div>
<div class="stat-label">收藏数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ singer.commentCount || 0 }}</div>
<div class="stat-label">评论数</div>
</div>
</div>
<div class="singer-intro">{{ singer.introduction }}</div>
<div class="singer-actions">
<el-button type="primary" @click="playAll" :disabled="!songs.length">
<el-icon><video-play /></el-icon>
播放全部
</el-button>
<el-button
:type="isCollected ? 'warning' : 'default'"
@click="toggleCollect"
>
<el-icon><star /></el-icon>
{{ isCollected ? '已收藏' : '收藏' }}
</el-button>
</div>
</div>
</div>
<!-- 歌曲列表专辑和评论 -->
<el-tabs class="singer-tabs">
<el-tab-pane label="热门歌曲">
<MusicList
:list="songs"
:loading="loading"
:show-pagination="false"
:detail-in-more="true"
/>
</el-tab-pane>
<el-tab-pane :label="`评论(${singer.commentCount || 0})`">
<!-- 评论输入框 -->
<div class="comment-input">
<el-input
v-model="commentContent"
type="textarea"
:rows="3"
placeholder="发表你的评论..."
/>
<div class="comment-submit-actions">
<el-button type="primary" @click="submitComment(false)">发表评论</el-button>
</div>
</div>
<!-- 评论列表 -->
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">
<el-avatar :src="comment.user.avatar" :size="40" />
</div>
<div class="comment-content">
<div class="comment-header">
<div class="comment-user">{{ comment.user.username }}</div>
<div class="comment-actions">
<el-button type="text" size="small" @click="setReplyTo(comment)">回复</el-button>
<el-button
v-if="userStore.isAdmin || comment.user.id === userStore.user?.id"
type="danger"
size="small"
@click="deleteComment(comment.id)"
>
删除
</el-button>
</div>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-footer">
<div class="comment-time">{{ comment.createTime }}</div>
<el-button
v-if="comment.children && comment.children.length"
type="text"
size="small"
@click="toggleReplies(comment)"
>
{{ expandedComments.includes(comment.id) ? '收起回复' : `查看 ${comment.children.length} 条回复` }}
</el-button>
</div>
<!-- 子评论 -->
<div v-if="expandedComments.includes(comment.id)" class="child-comment-list">
<!-- 回复输入框 -->
<div v-if="replyingTo === comment.id" class="comment-input child-comment-input">
<el-input
v-model="replyContent"
type="textarea"
:rows="2"
:placeholder="`回复 @${replyTo.user.username}`"
/>
<div class="comment-submit-actions">
<el-button type="primary" @click="submitComment(true)">回复</el-button>
</div>
</div>
<div v-for="child in [...(comment.children || [])].reverse()" :key="child.id" class="comment-item">
<div class="comment-avatar">
<el-avatar :src="child.user.avatar" :size="30" />
</div>
<div class="comment-content">
<div class="comment-header">
<div class="comment-user">{{ child.user.username }}</div>
<div class="comment-actions">
<el-button
v-if="userStore.isAdmin || child.user.id === userStore.user?.id"
type="danger"
size="small"
@click="deleteComment(child.id)"
>
删除
</el-button>
</div>
</div>
<div class="comment-text">{{ child.content }}</div>
<div class="comment-time">{{ child.createTime }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<style scoped>
.singer-detail-container {
padding: 20px;
min-height: calc(100vh - 140px);
background-color: transparent;
border-radius: 15px;
}
.singer-info {
display: flex;
margin-bottom: 30px;
padding: 30px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.singer-avatar {
width: 180px;
height: 180px;
margin-right: 30px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.singer-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.singer-meta {
flex: 1;
display: flex;
flex-direction: column;
}
.singer-name {
font-size: 2rem;
margin: 0 0 15px 0;
color: #333;
}
.singer-type {
margin-bottom: 15px;
color: #666;
font-size: 1rem;
}
.singer-stats {
display: flex;
margin-bottom: 15px;
}
.stat-item {
margin-right: 30px;
text-align: center;
}
.stat-value {
font-size: 1.2rem;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 0.8rem;
color: #999;
}
.singer-intro {
margin-bottom: 20px;
color: #666;
font-size: 1rem;
line-height: 1.6;
max-height: 120px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
}
.singer-actions {
margin-top: auto;
display: flex;
gap: 10px;
}
.singer-tabs {
margin: 0 0 30px;
padding: 20px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.song-cover {
width: 50px;
height: 50px;
border-radius: 4px;
object-fit: cover;
}
.comment-input {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.comment-submit-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.comment-list {
margin-top: 20px;
}
.comment-item {
display: flex;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.comment-avatar {
margin-right: 15px;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.comment-user {
font-weight: bold;
}
.child-comment-list {
margin-top: 15px;
padding-left: 15px;
border-left: 2px solid #eee;
}
.child-comment-input {
margin-top: 15px;
}
.comment-text {
margin-bottom: 5px;
color: #333;
line-height: 1.5;
}
.comment-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #999;
}
</style>