二级评论系统的设计与实现

最近在设计oj系统时,需要设计一个评论模块,本文用于记录整个设计与实现过程。

数据库设计

二级评论系统不难想到可以使用一种类似于链表的存储方式,每一条评论除了包含评论的内容,用户等基本信息,还需要记录父评论用于回复。如果这条评论原本就是评论于此题目的,则父评论标记为-1。

后端系统设计

本项目后端使用了SpringBoot+MybatisPlus用来快速完成curd。

类设计

实体类设计在这里不过多赘述,是完全根据数据库表生成的,这里主要解释DTO层的类的设计。

在设计QuestionPostQueryRequest中,除了原本的QuestionPost的基本数据,我们添加了private List<QuestionPostQueryRequest> childQuestionPostList; 这样的一个列表来保存该评论下的回复评论。

service层设计

  1. 首先,我们先循环获取所有的一级评论,也就是查询所有fatherId 为-1的,并且是当前问题的评论,并将返回的结果进行封装到QuestionPostQueryRequest 使其包含用户的头像等信息。

  2. 我在这使用了递归的算法来进行回复的查询。当然二级评论直接查询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>

Comment