feat(comment): 添加歌单详情页评论回复与展开功能 - 实现评论回复输入与提交功能,支持@用户名 - 添加回复展开与收起交互,支持查看子评论 - 使用 WebSocket 接收新评论与回复,并处理用户头像图片 - 优化评论展示结构,区分主评论与子评论

This commit is contained in:
ikmkj
2025-07-25 20:22:49 +08:00
parent 84ae421d80
commit f77e121335

View File

@@ -7,6 +7,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { getPlaylistById, toggleCollectPlaylist, deletePlaylist as apiDeletePlaylist } from '../api/playlist' import { getPlaylistById, toggleCollectPlaylist, deletePlaylist as apiDeletePlaylist } from '../api/playlist'
import { getCommentList, addComment, deleteComment as apiDeleteComment } from '../api/comment' import { getCommentList, addComment, deleteComment as apiDeleteComment } from '../api/comment'
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket' // 导入 WebSocket 工具 import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket' // 导入 WebSocket 工具
import { processImageUrl } from '../utils/imageUtils'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -25,6 +26,18 @@ const comments = ref([])
// 评论内容 // 评论内容
const commentContent = ref('') const commentContent = ref('')
// 回复内容
const replyContent = ref('')
// 回复对象
const replyTo = ref(null)
// 正在回复的评论
const replyingTo = ref(null)
// 展开的评论
const expandedComments = ref([])
// 加载状态 // 加载状态
const loading = ref(false) const loading = ref(false)
@@ -158,15 +171,48 @@ const deleteComment = async (commentId) => {
} }
} }
// 切换回复可见性
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;
replyContent.value = `@${comment.user.username} `;
if (!expandedComments.value.includes(comment.id)) {
expandedComments.value.push(comment.id);
}
}
// 取消回复
const cancelReply = () => {
replyingTo.value = null
replyTo.value = null
replyContent.value = ''
}
// 提交评论 // 提交评论
const submitComment = async () => { const submitComment = async (isReply = false) => {
if (!userStore.isLoggedIn) { if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录') ElMessage.warning('请先登录')
router.push('/login') router.push('/login')
return return
} }
if (!commentContent.value.trim()) { const content = isReply ? replyContent.value : commentContent.value
if (!content.trim()) {
ElMessage.warning('评论内容不能为空') ElMessage.warning('评论内容不能为空')
return return
} }
@@ -175,14 +221,17 @@ const submitComment = async () => {
const res = await addComment({ const res = await addComment({
targetId: playlistId.value, targetId: playlistId.value,
targetType: 2, // 2表示歌单 targetType: 2, // 2表示歌单
content: commentContent.value content: content,
parentId: isReply ? replyingTo.value : null
}) })
if (res.code === 200) { if (res.code === 200) {
// 添加成功,评论将通过 WebSocket 推送,无需手动刷新 if (isReply) {
// await fetchComments() // 移除手动刷新 replyContent.value = ''
} else {
commentContent.value = '' commentContent.value = ''
ElMessage.success('评论已发送') // 提示语可以调整 }
ElMessage.success('评论已发送')
} else { } else {
ElMessage.error(res.message || '评论失败') ElMessage.error(res.message || '评论失败')
} }
@@ -259,12 +308,23 @@ onMounted(() => {
connect(() => { connect(() => {
const destination = `/topic/comments/2/${playlistId.value}`; // 2 代表歌单类型 const destination = `/topic/comments/2/${playlistId.value}`; // 2 代表歌单类型
commentSubscription = subscribe(destination, (newComment) => { commentSubscription = subscribe(destination, (newComment) => {
// 收到新评论,添加到列表开头
if (newComment && newComment.id) { 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)) { if (!comments.value.some(c => c.id === newComment.id)) {
comments.value.unshift(newComment); // 添加到开头 comments.value.unshift(newComment);
console.log('New comment received via WebSocket:', newComment); }
} }
} else { } else {
console.warn("Received invalid comment data via WebSocket:", newComment); console.warn("Received invalid comment data via WebSocket:", newComment);
@@ -413,7 +473,9 @@ onUnmounted(() => {
:rows="3" :rows="3"
placeholder="发表你的评论..." placeholder="发表你的评论..."
/> />
<el-button type="primary" @click="submitComment">发表评论</el-button> <div class="comment-submit-actions">
<el-button type="primary" @click="submitComment(false)">发表评论</el-button>
</div>
</div> </div>
<!-- 评论列表 --> <!-- 评论列表 -->
@@ -426,6 +488,7 @@ onUnmounted(() => {
<div class="comment-header"> <div class="comment-header">
<div class="comment-user">{{ comment.user.username }}</div> <div class="comment-user">{{ comment.user.username }}</div>
<div class="comment-actions"> <div class="comment-actions">
<el-button type="text" size="small" @click="setReplyTo(comment)">回复</el-button>
<el-button <el-button
v-if="userStore.isAdmin || comment.user.id === userStore.user?.id" v-if="userStore.isAdmin || comment.user.id === userStore.user?.id"
type="danger" type="danger"
@@ -437,7 +500,55 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="comment-text">{{ comment.content }}</div> <div class="comment-text">{{ comment.content }}</div>
<div class="comment-footer">
<div class="comment-time">{{ comment.createTime }}</div> <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> </div>
</div> </div>