feat(comment): 优化评论加载效率并添加回复功能
- 在 CommentRepository 中添加 findByParentIdInOrderByCreateTimeAsc 方法,实现批量查询子评论 - 重构 CommentServiceImpl 中的 findCommentsByMusicId 方法,提升评论加载效率 - 在前端 MusicDetail 组件中增加回复功能,支持二级评论和回复展开/收起 - 优化 WebSocket订阅逻辑,实现实时更新评论列表
This commit is contained in:
@@ -34,6 +34,14 @@ public interface CommentRepository extends JpaRepository<Comment, Long>, JpaSpec
|
|||||||
*/
|
*/
|
||||||
List<Comment> findByParentIdOrderByCreateTimeAsc(Long parentId);
|
List<Comment> findByParentIdOrderByCreateTimeAsc(Long parentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据父评论ID列表查询所有子评论
|
||||||
|
*
|
||||||
|
* @param parentIds 父评论ID列表
|
||||||
|
* @return 子评论列表
|
||||||
|
*/
|
||||||
|
List<Comment> findByParentIdInOrderByCreateTimeAsc(List<Long> parentIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据用户查询评论
|
* 根据用户查询评论
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -74,16 +74,28 @@ public class CommentServiceImpl implements CommentService {
|
|||||||
.map(this::convertToDTO)
|
.map(this::convertToDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
// 获取子评论
|
// 优化:一次性获取所有子评论
|
||||||
for (CommentDTO commentDTO : commentDTOs) {
|
// 1. 获取所有父评论的ID
|
||||||
List<Comment> children = commentRepository.findByParentIdOrderByCreateTimeAsc(commentDTO.getId());
|
List<Long> parentIds = commentDTOs.stream()
|
||||||
if (!children.isEmpty()) {
|
.map(CommentDTO::getId)
|
||||||
commentDTO.setChildren(children.stream()
|
.collect(Collectors.toList());
|
||||||
.map(this::convertToDTO)
|
|
||||||
.collect(Collectors.toList()));
|
// 2. 如果有父评论,则一次性查询出它们所有的子评论
|
||||||
} else {
|
if (!parentIds.isEmpty()) {
|
||||||
commentDTO.setChildren(new ArrayList<>());
|
List<Comment> allChildren = commentRepository.findByParentIdInOrderByCreateTimeAsc(parentIds);
|
||||||
}
|
|
||||||
|
// 3. 将子评论按其父ID分组
|
||||||
|
java.util.Map<Long, List<CommentDTO>> childrenMap = allChildren.stream()
|
||||||
|
.map(this::convertToDTO)
|
||||||
|
.collect(Collectors.groupingBy(CommentDTO::getParentId));
|
||||||
|
|
||||||
|
// 4. 将子评论列表设置到对应的父评论DTO中
|
||||||
|
commentDTOs.forEach(parentDTO -> parentDTO.setChildren(
|
||||||
|
childrenMap.getOrDefault(parentDTO.getId(), new ArrayList<>())
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// 如果没有父评论,确保children字段不是null
|
||||||
|
commentDTOs.forEach(parentDTO -> parentDTO.setChildren(new ArrayList<>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PageResult<>(
|
return new PageResult<>(
|
||||||
|
|||||||
@@ -129,8 +129,9 @@
|
|||||||
|
|
||||||
<div class="comment-list">
|
<div class="comment-list">
|
||||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||||
|
<!-- 一级评论 -->
|
||||||
<div class="comment-avatar">
|
<div class="comment-avatar">
|
||||||
<el-avatar :src="comment.user.avatar" :size="40" />
|
<el-avatar :src="processImageUrl(comment.user.avatar)" :size="40" />
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-content">
|
<div class="comment-content">
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
@@ -150,19 +151,69 @@
|
|||||||
<div class="comment-text">{{ comment.content }}</div>
|
<div class="comment-text">{{ comment.content }}</div>
|
||||||
<div class="comment-footer">
|
<div class="comment-footer">
|
||||||
<div class="comment-time">{{ comment.createTime }}</div>
|
<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>
|
||||||
<div v-if="replyingTo && replyingTo.id === comment.id" class="comment-input child-comment-input">
|
|
||||||
|
<!-- 回复输入框 -->
|
||||||
|
<div v-if="replyingTo === comment.id" class="comment-input child-comment-input">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="replyContent"
|
v-model="replyContent"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="2"
|
:rows="2"
|
||||||
:placeholder="`回复 @${replyingTo.user.username}`"
|
:placeholder="`回复 @${replyTo.user.username}`"
|
||||||
/>
|
/>
|
||||||
<div class="comment-submit-actions">
|
<div class="comment-submit-actions">
|
||||||
<el-button type="primary" size="small" @click="submitComment(true)">回复</el-button>
|
<el-button type="primary" size="small" @click="submitComment(true)">回复</el-button>
|
||||||
<el-button size="small" @click="cancelReply">取消</el-button>
|
<el-button size="small" @click="cancelReply">取消</el-button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!comments.length" description="还没有评论,快来抢沙发吧!" />
|
<el-empty v-if="!comments.length" description="还没有评论,快来抢沙发吧!" />
|
||||||
@@ -210,6 +261,7 @@ import { getCommentList, addComment, deleteComment as apiDeleteComment } from '.
|
|||||||
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket'
|
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { VideoPlay, VideoPause, Star, StarFilled, Plus } from '@element-plus/icons-vue'
|
import { VideoPlay, VideoPause, Star, StarFilled, Plus } from '@element-plus/icons-vue'
|
||||||
|
import { processImageUrl } from '../utils/imageUtils'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -226,6 +278,7 @@ const comments = ref([])
|
|||||||
const commentContent = ref('')
|
const commentContent = ref('')
|
||||||
const replyContent = ref('')
|
const replyContent = ref('')
|
||||||
const replyingTo = ref(null)
|
const replyingTo = ref(null)
|
||||||
|
const replyTo = ref(null)
|
||||||
const expandedComments = ref([])
|
const expandedComments = ref([])
|
||||||
let commentSubscription = null;
|
let commentSubscription = null;
|
||||||
|
|
||||||
@@ -398,15 +451,34 @@ const deleteComment = async (commentId) => {
|
|||||||
|
|
||||||
// 设置回复对象
|
// 设置回复对象
|
||||||
const setReplyTo = (comment) => {
|
const setReplyTo = (comment) => {
|
||||||
replyingTo.value = comment;
|
replyingTo.value = comment.id;
|
||||||
|
replyTo.value = comment;
|
||||||
|
if (!expandedComments.value.includes(comment.id)) {
|
||||||
|
expandedComments.value.push(comment.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消回复
|
// 取消回复
|
||||||
const cancelReply = () => {
|
const cancelReply = () => {
|
||||||
replyingTo.value = null;
|
replyingTo.value = null;
|
||||||
|
replyTo.value = null;
|
||||||
replyContent.value = '';
|
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 订阅
|
// WebSocket 订阅
|
||||||
const setupWebSocket = () => {
|
const setupWebSocket = () => {
|
||||||
if (!music.value) return;
|
if (!music.value) return;
|
||||||
@@ -414,10 +486,30 @@ const setupWebSocket = () => {
|
|||||||
const destination = `/topic/comments/1/${music.value.id}`; // 1 代表音乐类型
|
const destination = `/topic/comments/1/${music.value.id}`; // 1 代表音乐类型
|
||||||
commentSubscription = subscribe(destination, (newComment) => {
|
commentSubscription = subscribe(destination, (newComment) => {
|
||||||
if (newComment && newComment.id) {
|
if (newComment && newComment.id) {
|
||||||
// 简单处理:直接重新拉取整个评论列表
|
if (newComment.user && newComment.user.avatar) {
|
||||||
fetchComments();
|
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('评论服务连接失败,实时更新不可用');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user