- 后端服务删除评论时通过WebSocket广播删除事件 - 前端音乐详情页接收删除消息并从列表中移除对应评论 - 前端歌单详情页支持删除事件处理并更新评论计数 - 前端歌手详情页实现删除事件处理和评论计数更新 - 支持父子评论层级结构的删除操作 - 统一WebSocket消息格式包含删除状态和评论ID信息
835 lines
22 KiB
Vue
835 lines
22 KiB
Vue
<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 { showErrorOnce } from '../utils/errorHandler'
|
||
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 || []
|
||
if (singer.value && res.data.total !== undefined) {
|
||
singer.value.commentCount = res.data.total
|
||
}
|
||
|
||
// 处理用户头像
|
||
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)
|
||
showErrorOnce(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)
|
||
showErrorOnce(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)
|
||
showErrorOnce(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 formattedCommentCount = computed(() => {
|
||
if (!singer.value || singer.value.commentCount === undefined) {
|
||
return 0;
|
||
}
|
||
if (singer.value.commentCount > 99) {
|
||
return '99+';
|
||
}
|
||
return singer.value.commentCount;
|
||
});
|
||
|
||
// 格式化收藏数
|
||
const formattedCollectCount = computed(() => {
|
||
if (!singer.value || singer.value.collectCount === undefined) {
|
||
return 0;
|
||
}
|
||
if (singer.value.collectCount > 99) {
|
||
return '99+';
|
||
}
|
||
return singer.value.collectCount;
|
||
});
|
||
|
||
// 格式化粉丝数
|
||
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, (message) => {
|
||
// 处理删除事件
|
||
if (message && message.deleted) {
|
||
const deletedId = message.id;
|
||
if (message.parentId) {
|
||
// 删除子评论
|
||
const parentComment = comments.value.find(c => c.id === message.parentId);
|
||
if (parentComment && parentComment.children) {
|
||
parentComment.children = parentComment.children.filter(c => c.id !== deletedId);
|
||
}
|
||
} else {
|
||
// 删除父评论
|
||
comments.value = comments.value.filter(c => c.id !== deletedId);
|
||
}
|
||
if (singer.value && singer.value.commentCount > 0) {
|
||
singer.value.commentCount--;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 处理新增评论
|
||
const newComment = message;
|
||
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">{{ formattedCollectCount }}</div>
|
||
<div class="stat-label">收藏数</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value">{{ formattedCommentCount }}</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="`评论(${formattedCommentCount})`">
|
||
<!-- 评论输入框 -->
|
||
<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>
|