906 lines
25 KiB
Vue
906 lines
25 KiB
Vue
<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>
|