最近在设计oj系统时,需要设计一个评论模块,本文用于记录整个设计与实现过程。
数据库设计
二级评论系统不难想到可以使用一种类似于链表的存储方式,每一条评论除了包含评论的内容,用户等基本信息,还需要记录父评论用于回复。如果这条评论原本就是评论于此题目的,则父评论标记为-1。
后端系统设计
本项目后端使用了SpringBoot+MybatisPlus用来快速完成curd。
类设计
实体类设计在这里不过多赘述,是完全根据数据库表生成的,这里主要解释DTO层的类的设计。
在设计QuestionPostQueryRequest
中,除了原本的QuestionPost的基本数据,我们添加了private List<QuestionPostQueryRequest> childQuestionPostList;
这样的一个列表来保存该评论下的回复评论。
service层设计
首先,我们先循环获取所有的一级评论,也就是查询所有
fatherId
为-1的,并且是当前问题的评论,并将返回的结果进行封装到QuestionPostQueryRequest
使其包含用户的头像等信息。我在这使用了递归的算法来进行回复的查询。当然二级评论直接查询
fatherId
相等并且questionId
相等即可。但是为了之后在系统的博客上继续使用这套代码,我依然选择了递归的方式。
@Override
public List<QuestionPostQueryRequest> getQuestionPostByQuestionId(long questionId) {
// 查询所有一级评论(fatherId为-1)
List<QuestionPost> parentPosts = questionPostMapper.selectList(
new QueryWrapper<QuestionPost>()
.eq("questionId", questionId)
.eq("fatherId", -1)
);
// 将查询结果封装为 QuestionPostQueryRequest
List<QuestionPostQueryRequest> result = new ArrayList<>();
for (QuestionPost post : parentPosts) {
QuestionPostQueryRequest request = new QuestionPostQueryRequest();
BeanUtils.copyProperties(post, request);
User user = userService.getById(post.getUserId());
request.setUserAvatar(user.getUserAvatar());
request.setUserName(user.getUserName());
// 递归查询子评论并设置到request中
List<QuestionPostQueryRequest> childRequests = getChildPosts(post.getId());
request.setChildQuestionPostList(childRequests);
result.add(request);
}
return result;
}
/**
* 递归获取子评论
*/
private List<QuestionPostQueryRequest> getChildPosts(long fatherId) {
List<QuestionPost> childPosts = questionPostMapper.selectList(
new QueryWrapper<QuestionPost>().eq("fatherId", fatherId)
);
List<QuestionPostQueryRequest> childRequests = new ArrayList<>();
for (QuestionPost post : childPosts) {
QuestionPostQueryRequest request = new QuestionPostQueryRequest();
BeanUtils.copyProperties(post, request);
User user = userService.getById(post.getUserId());
request.setUserAvatar(user.getUserAvatar());
request.setUserName(user.getUserName());
// 继续递归获取子评论
List<QuestionPostQueryRequest> nestedChildRequests = getChildPosts(post.getId());
request.setChildQuestionPostList(nestedChildRequests);
childRequests.add(request);
}
return childRequests;
}
前端页面设计
前端页面使用了Vue3+Arco Design
在这里我直接使得一个输入框常驻,根据是否点击relpy按钮来更新提交的评论的fatherId
,并且点击取消回复时将fatherId
改为-1。个人认为这样还是比较符合用户直觉的。
const questionPost = ref<QuestionPostQueryRequest>();
const replyContent=ref();
const isReplying = ref(false);
const loadQuestionPost = async () => {
const res = await QuestionPostControllerService.getQuestionPostByQuestionIdUsingPost( Number(props.id));
if (res.code === 0) {
questionPost.value = res.data;
}else{
message.error("获取评论信息失败" + res.message);
}
}
const handleReply = (id: number, name: string) => {
replyContent.value = "";
replyContent.value="@"+name+" ";
commentFrom.value.fatherId = id;
isReplying.value = true;
}
const commentFrom = ref<QuestionPostAddRequest>({
content: "",
fatherId: -1,
questionId: props.id as any
});
const doCommentSubmit = async () => {
commentFrom.value.content = replyContent.value;
const res = await QuestionPostControllerService.addQuestionPostUsingPost(commentFrom.value);
if(res.code === 0){
message.success("评论成功");
loadQuestionPost();
}else{
message.error("评论失败"+res.message);
}
}
const cancelCommentSubmit=()=>{
isReplying.value = false;
commentFrom.value.content = "";
commentFrom.value.fatherId = -1;
replyContent.value = "";
}
<a-tab-pane key="post" title="评论">
<div id="comment-box"style=" height:60vh;">
<a-comment
v-for="comment in questionPost"
:author="comment!.userName"
:avatar="comment!.userAvatar"
:content="comment!.content"
:datetime=" formatDateTime(comment!.createTime)"
>
<template #actions>
<span class="action" @click="handleReply(comment!.id,comment!.userName)"> <IconMessage /> Reply </span>
</template>
<a-comment
v-for="child in comment!.childQuestionPostList"
:author="child!.userName"
:avatar="child!.userAvatar"
:content="child!.content"
:datetime=" formatDateTime(child!.createTime)"
>
<template #actions>
<span class="action" @click="handleReply(child.id,comment!.userName) "> <IconMessage /> Reply </span>
</template>
</a-comment>
</a-comment>
</div>
<a-space direction="vertical" size="large" fill>
<a-textarea
placeholder="Please enter something"
:max-length="{length:400, errorOnly:false}"
allow-clear
show-word-limit
v-model="replyContent"
/>
</a-space>
<br>
<a-space >
<a-button type="primary" @click="doCommentSubmit">{{ isReplying ? '提交回复' : '提交评论' }}</a-button>
<a-button type="outline" @click="cancelCommentSubmit">{{ isReplying ? '取消回复' : '取消评论' }}</a-button>
</a-space>
</a-tab-pane>