Files
graduation-design/music-qianduan/src/views/MusicDetail.vue

906 lines
25 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.
<template>
<div class="music-detail-page">
<el-skeleton :loading="loading" animated>
<template #template>
<div class="skeleton-container">
<el-skeleton-item variant="image" style="width: 240px; height: 240px; border-radius: 8px;" />
<div class="skeleton-content">
<el-skeleton-item variant="h1" style="width: 50%;" />
<el-skeleton-item variant="text" style="width: 30%; margin-top: 16px;" />
<el-skeleton-item variant="text" style="width: 40%; margin-top: 16px;" />
<el-skeleton-item variant="text" style="width: 60%; margin-top: 16px;" />
</div>
</div>
</template>
<template #default>
<div v-if="music" class="music-info">
<div class="music-cover">
<img :src="music.cover || '/default-cover.jpg'" alt="封面">
</div>
<div class="music-content">
<h1 class="music-name">{{ music.name }}</h1>
<div class="music-meta">
<div class="meta-item">
<span class="label">歌手</span>
<span class="value">
<template v-if="music.singerNames && music.singerNames.length">
<span v-for="(singer, idx) in music.singerNames" :key="idx">
{{ singer }}{{ idx < music.singerNames.length - 1 ? '' : '' }}
</span>
</template>
<span v-else>{{ music.singer || '未知歌手' }}</span>
</span>
</div>
<div class="meta-item">
<span class="label">专辑</span>
<span class="value">{{ music.album || '-' }}</span>
</div>
<div class="meta-item">
<span class="label">类型</span>
<span class="value">{{ music.typeName || '-' }}</span>
</div>
<div class="meta-item">
<span class="label">时长</span>
<span class="value">{{ formatDuration(music.duration) }}</span>
</div>
<div class="meta-item">
<span class="label">发行日期</span>
<span class="value">{{ music.releaseDate || '-' }}</span>
</div>
</div>
<div class="music-actions">
<el-button
type="primary"
@click="playMusic"
:icon="isPlaying ? 'VideoPause' : 'VideoPlay'"
>
{{ isPlaying ? '暂停' : '播放' }}
</el-button>
<el-button
:type="music.collected ? 'danger' : 'default'"
@click="toggleCollect"
:icon="music.collected ? 'Star' : 'StarFilled'"
>
{{ music.collected ? `已收藏 (${music.collectCount ?? 0})` : `收藏 (${music.collectCount ?? 0})` }}
</el-button>
<el-button
:type="music.liked ? 'success' : 'default'"
@click="toggleLike"
>
<el-icon><component :is="music.liked ? 'Check' : 'Pointer'" /></el-icon>
{{ music.liked ? `已点赞 (${music.likeCount ?? 0})` : `点赞 (${music.likeCount ?? 0})` }}
</el-button>
<el-button
@click="addToPlaylist"
icon="Plus"
>
添加到播放列表
</el-button>
</div>
</div>
</div>
<div v-if="music && music.description" class="music-description">
<h2>歌曲简介</h2>
<p>{{ music.description }}</p>
</div>
<div class="content-toggle">
<el-tabs v-model="activeTab">
<el-tab-pane label="歌词" name="lyric"></el-tab-pane>
<el-tab-pane label="评论" name="comment"></el-tab-pane>
</el-tabs>
</div>
<div v-if="activeTab === 'lyric'">
<div v-if="music && music.lyric" class="music-lyric">
<h2>歌词</h2>
<pre>{{ displayedLyric }}</pre>
<div v-if="isLyricTooLong" class="lyric-toggle-button" @click="toggleLyricExpansion">
{{ isLyricExpanded ? '收起歌词' : '查看更多歌词' }}
</div>
</div>
<el-empty v-else description="暂无歌词" />
</div>
<div v-if="activeTab === 'comment'" class="comment-section">
<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="processImageUrl(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 type="text" size="small" @click="toggleReplies(comment)">
{{ expandedComments.includes(comment.id) ? '收起回复' : `查看回复 (${comment.children ? comment.children.length : 0})` }}
</el-button>
</div>
<!-- 回复输入框 -->
<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" size="small" @click="submitComment(true)">回复</el-button>
<el-button size="small" @click="cancelReply">取消</el-button>
</div>
</div>
<!-- 二级评论 -->
<div v-if="expandedComments.includes(comment.id) && comment.children && comment.children.length > 0" class="child-comments">
<div v-for="child in comment.children" :key="child.id" class="comment-item">
<div class="comment-avatar">
<el-avatar :src="processImageUrl(child.user.avatar)" :size="32" />
</div>
<div class="comment-content">
<div class="comment-header">
<div class="comment-user">{{ child.user.username }}</div>
<div class="comment-actions">
<el-button type="text" size="small" @click="setReplyTo(child)">回复</el-button>
<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">
<span v-if="child.parentCommentUser" class="reply-to">@{{ child.parentCommentUser.username }}</span>
{{ child.content }}
</div>
<div class="comment-footer">
<div class="comment-time">{{ child.createTime }}</div>
</div>
<!-- 二级回复输入框 -->
<div v-if="replyingTo === child.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" size="small" @click="submitComment(true, child.id)">回复</el-button>
<el-button size="small" @click="cancelReply">取消</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<el-empty v-if="!comments.length" description="还没有评论,快来抢沙发吧!" />
</div>
</div>
<div v-if="relatedMusic.length > 0 && activeTab === 'lyric'" class="related-music">
<h2>热门推荐</h2>
<el-row :gutter="20">
<el-col
v-for="item in relatedMusic"
:key="item.id"
:xs="12"
:sm="8"
:md="6"
:lg="4"
>
<div class="music-card" @click="goToMusic(item.id)">
<div class="card-cover">
<img :src="item.cover || item.singerAvatar || '/default-cover.jpg'" alt="封面">
<div class="play-icon" @click.stop="playRelatedMusic(item)">
<el-icon><VideoPlay /></el-icon>
</div>
</div>
<div class="card-info">
<div class="card-name">{{ item.name }}</div>
<div class="card-singer">{{ getSingerNames(item) }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
</el-skeleton>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePlayerStore } from '../stores/player'
import { useUserStore } from '../stores/user'
import { getMusicById, toggleCollectMusic, getHotMusic, toggleLikeMusic, getMusicStatus } from '../api/music'
import { getCommentList, addComment, deleteComment as apiDeleteComment } from '../api/comment'
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VideoPlay, VideoPause, Star, StarFilled, Plus } from '@element-plus/icons-vue'
import { processImageUrl } from '../utils/imageUtils'
const route = useRoute()
const router = useRouter()
const playerStore = usePlayerStore()
const userStore = useUserStore()
const music = ref(null)
const loading = ref(true)
const relatedMusic = ref([])
// 评论区相关状态
const activeTab = ref('lyric') // 'lyric' or 'comment'
const comments = ref([])
const commentContent = ref('')
const replyContent = ref('')
const replyingTo = ref(null)
const replyTo = ref(null)
const expandedComments = ref([])
let commentSubscription = null;
const isLyricExpanded = ref(false)
const LYRIC_PREVIEW_LINES = 10
const isLyricTooLong = computed(() => {
if (!music.value || !music.value.lyric) return false
return music.value.lyric.split('\n').length > LYRIC_PREVIEW_LINES
})
const displayedLyric = computed(() => {
if (!music.value || !music.value.lyric) return ''
if (isLyricExpanded.value || !isLyricTooLong.value) {
return music.value.lyric
}
return music.value.lyric.split('\n').slice(0, LYRIC_PREVIEW_LINES).join('\n')
})
const toggleLyricExpansion = () => {
isLyricExpanded.value = !isLyricExpanded.value
}
// 计算属性
const isPlaying = computed(() => {
return playerStore.getCurrentMusic?.id === music.value?.id && playerStore.getIsPlaying
})
// 获取音乐详情
const fetchMusicDetail = async () => {
loading.value = true
try {
const id = route.params.id
if (!id) {
ElMessage.error('无效的音乐ID')
router.push('/music')
return
}
const res = await getMusicById(id)
if (res.code === 200) {
if (res.data) {
music.value = {
...res.data,
likeCount: res.data.likeCount ?? 0,
collectCount: res.data.collectCount ?? 0
}
// 获取音乐状态
fetchMusicStatus()
} else {
// 如果音乐不存在,显示提示并返回音乐列表页
ElMessage.warning('音乐不存在或已被删除')
router.push('/music')
}
} else {
ElMessage.error(res.message || '获取音乐详情失败')
router.push('/music')
}
} catch (error) {
console.error('获取音乐详情失败:', error)
ElMessage.error('获取音乐详情失败,请稍后重试')
router.push('/music')
} finally {
loading.value = false
}
}
// 获取音乐状态
const fetchMusicStatus = async () => {
if (!music.value || !userStore.isLoggedIn) {
return
}
try {
const res = await getMusicStatus(music.value.id)
if (res.code === 200 && res.data) {
music.value.collected = res.data.collected
music.value.liked = res.data.liked
}
} catch (error) {
console.error('获取音乐状态失败:', error)
}
}
// 获取相关音乐
const fetchRelatedMusic = async () => {
try {
const res = await getHotMusic(6)
if (res.code === 200) {
// 过滤掉当前音乐
relatedMusic.value = (res.data || []).filter(item => item.id !== music.value?.id)
console.log('相关音乐:', relatedMusic.value)
}
} catch (error) {
console.error('获取相关音乐失败:', error)
}
}
// 获取评论列表
const fetchComments = async () => {
if (!music.value) return;
try {
const res = await getCommentList(music.value.id, 1, 1, 100); // targetType 1 表示音乐
if (res.code === 200) {
comments.value = res.data.list || res.data.records || [];
}
} 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: music.value.id,
targetType: 1, // 1表示音乐
content: content,
parentId: isReply ? replyingTo.value : null,
});
if (res.code === 200) {
if (isReply) {
replyContent.value = '';
replyingTo.value = null;
} else {
commentContent.value = '';
}
ElMessage.success('评论已发送');
} 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) {
// 重新获取评论以刷新列表
fetchComments();
ElMessage.success('评论已删除');
} else {
ElMessage.error(res.message || '删除失败');
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除评论失败:', error);
ElMessage.error('删除失败,请稍后重试');
}
}
};
// 设置回复对象
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 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);
}
};
// WebSocket 订阅
const setupWebSocket = () => {
if (!music.value) return;
connect(() => {
const destination = `/topic/comments/1/${music.value.id}`; // 1 代表音乐类型
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('评论服务连接失败,实时更新不可用');
});
};
// 断开 WebSocket
const teardownWebSocket = () => {
if (commentSubscription) {
unsubscribe(commentSubscription);
commentSubscription = null;
}
disconnect();
};
// 播放音乐
const playMusic = () => {
if (isPlaying.value) {
playerStore.togglePlay()
} else {
playerStore.setCurrentMusic(music.value)
}
}
// 播放相关音乐
const playRelatedMusic = (item) => {
playerStore.setCurrentMusic(item)
}
// 切换收藏状态
const toggleCollect = async () => {
try {
const res = await toggleCollectMusic(music.value.id)
if (res.code === 200) {
// 后端直接返回了最新的收藏状态 (true/false),直接赋值即可
music.value.collected = res.data
// 仅在操作成功时同步更新本地收藏数
if (music.value.collected) {
music.value.collectCount = (music.value.collectCount || 0) + 1
} else {
music.value.collectCount = Math.max(0, (music.value.collectCount || 0) - 1)
}
ElMessage.success(res.message || (music.value.collected ? '收藏成功' : '已取消收藏'))
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 切换点赞状态
const toggleLike = async () => {
try {
const res = await toggleLikeMusic(music.value.id);
if (res.code === 200) {
// 后端直接返回了最新的点赞状态 (true/false),直接赋值
music.value.liked = res.data;
// 更新点赞数
if (music.value.liked) {
music.value.likeCount = (music.value.likeCount || 0) + 1;
} else {
music.value.likeCount = Math.max(0, (music.value.likeCount || 0) - 1);
}
ElMessage.success(res.message || (music.value.liked ? '点赞成功' : '已取消点赞'));
} else {
ElMessage.error(res.message || '操作失败');
}
} catch (error) {
console.error('点赞操作失败:', error);
ElMessage.error('操作失败,请稍后重试');
}
};
// 添加到播放列表
const addToPlaylist = () => {
playerStore.addToPlaylist(music.value)
ElMessage.success('已添加到播放列表')
}
// 跳转到音乐详情
const goToMusic = (id) => {
router.push(`/music/${id}`)
}
// 获取歌手名称
const getSingerNames = (item) => {
if (!item) return ''
if (item.singerNames && item.singerNames.length > 0) {
return item.singerNames.join('、')
}
return item.singer || '未知歌手'
}
// 格式化时长
const formatDuration = (seconds) => {
if (!seconds) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 监听路由变化,重新获取音乐详情
watch(() => route.params.id, (newId, oldId) => {
if (newId !== oldId) {
teardownWebSocket();
fetchMusicDetail().then(() => {
fetchComments();
setupWebSocket();
});
fetchRelatedMusic();
}
})
onMounted(() => {
fetchMusicDetail().then(() => {
fetchComments();
setupWebSocket();
});
fetchRelatedMusic();
})
onUnmounted(() => {
teardownWebSocket();
})
</script>
<style scoped>
.lyric-toggle-button {
cursor: pointer;
color: var(--el-color-primary);
text-align: center;
margin-top: 10px;
font-size: 14px;
}
.lyric-toggle-button:hover {
color: var(--el-color-primary-light-3);
}
.music-detail-page {
padding: 20px;
}
.skeleton-container {
display: flex;
gap: 30px;
}
.skeleton-content {
flex: 1;
}
.music-info {
display: flex;
gap: 30px;
margin-bottom: 30px;
}
.music-cover {
width: 240px;
height: 240px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.music-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.music-content {
flex: 1;
}
.music-name {
font-size: 28px;
margin: 0 0 20px 0;
color: #303133;
}
.music-meta {
margin-bottom: 30px;
}
.meta-item {
margin-bottom: 10px;
font-size: 16px;
color: #606266;
}
.label {
font-weight: bold;
margin-right: 5px;
}
.music-actions {
display: flex;
gap: 15px;
}
.music-description {
margin-bottom: 30px;
}
.content-toggle {
margin-bottom: 20px;
}
.comment-section {
margin-top: 20px;
}
.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-input {
margin-top: 10px;
}
.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;
}
.music-description h2, .music-lyric h2, .related-music h2 {
font-size: 20px;
margin-bottom: 15px;
color: #303133;
}
.music-description p {
line-height: 1.6;
color: #606266;
}
.music-lyric pre {
white-space: pre-wrap;
line-height: 1.8;
color: #606266;
font-family: inherit;
}
.related-music {
margin-top: 30px;
}
.music-card {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.music-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.1);
}
.card-cover {
position: relative;
height: 0;
padding-bottom: 100%;
overflow: hidden;
}
.card-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.play-icon .el-icon {
font-size: 24px;
color: #fff;
}
.card-cover:hover .play-icon {
opacity: 1;
}
.card-info {
padding: 10px;
}
.card-name {
font-weight: bold;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-singer {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.music-info {
flex-direction: column;
}
.music-cover {
width: 100%;
height: auto;
aspect-ratio: 1;
max-width: 240px;
margin: 0 auto 20px;
}
}
</style>