记录一下博客系统中评论模块的设计与开发

299 阅读2分钟

1、先看最终实现的效果

image.png 这个评论的样式使用了SemanticUI实现,样式不重要,主要看数据库设计和代码实现。

2、数据库设计

image.png 解释一下cid和rid字段的含义,其他字段都好懂:

  1. 假如用户提交了一个评论A,此时这个A就是顶级评论是没有cid和rid的值
  2. 另一个用户回复了评论A,我们称这个回复为B,此时B的cid就是A的id,rid没有值
  3. 第三个用户回复了评论B,此时这是一条二级评论,称为C,C的cid就是A的id,rid是B的id

3、后端代码示例

后端为了快速开发使用的是:Springboot+Mybatis-plus

3.1 Comment实体类

@Data
public class Comment {

    private Integer id;
    private Integer uid;
    private Integer aid;
    private String content;
    private String username;
    private String avatarUrl;
    private Integer cid;
    private Integer rid;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime date;

    @TableField(exist = false)
    private List<Comment> replies;

    @TableField(exist = false)
    public Recipient recipient; // 被回复的人

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Recipient{
        private Integer id;
        private String username;
        private String avatar;
    }
}

3.2 用来接收前端传递数据的CommentRequest类

@Data
public class CommentRequest {

    private Integer aid;
    private Integer cid;
    private Integer rid;
    private Integer uid;
    private String content;

}

3.3 统一返回结果的Record对象

Record是JDK16新增的,低版本JDK请使用普通的Java对象

public record Result<T>(Integer code, String message, T data) {

    public static <T> Result<T> success(T data){
        return new Result<>(200,"success",data);
    }

    public static <T> Result<T> success(){
        return success(null);
    }

    public String asJsonString(){
        return JSONObject.toJSONString(this, JSONWriter.Feature.WriteNulls);
    }

}

3.4 CommentController

为了快速实现例子,代码全部写在Controller里

@Slf4j
@CrossOrigin
@RestController
@RequestMapping("/comment")
public class CommentController {

    @Autowired
    private CommentMapper commentMapper;

    @GetMapping
    public Result<List<Comment>> commentList() {
        // 获取所有评论
        LambdaQueryWrapper<Comment> wrapper = new LambdaQueryWrapper<>();
        wrapper.isNull(Comment::getCid);
        List<Comment> comments = commentMapper.selectList(wrapper);

        // 获取评论下的回复
        for (Comment comment : comments) {
            fetchReplies(comment);
        }

        return Result.success(comments);
    }

    @PostMapping
    public Result<String> save(@RequestBody CommentRequest request) {
        log.info("request:{}", request);
        Comment comment = new Comment();
        BeanUtils.copyProperties(request, comment);
        // 这两个字段根据实际情况让前端传递或者后端从当前登录用户的信息取出
        comment.setUsername("");
        comment.setAvatarUrl("");
        commentMapper.insert(comment);
        return Result.success();
    }

    private void fetchReplies(Comment comment) {
        LambdaQueryWrapper<Comment> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Comment::getCid, comment.getId());
        List<Comment> replies = commentMapper.selectList(wrapper);
        // 封装被回复人的信息
        for (Comment reply : replies) {
            Integer rid = reply.getRid();
            if (rid != null) {
                // 不为空则是一条二级回复
                LambdaQueryWrapper<Comment> queryWrapper = new LambdaQueryWrapper<>();
                queryWrapper.eq(Comment::getId, rid);
                // 这里只是为了快速实现功能,实际查询不可能查询全部然后只取第一条
                Comment recipient = commentMapper.selectList(queryWrapper).get(0);
                reply.setRecipient(new Comment.Recipient(recipient.getUid(), recipient.getUsername(), recipient.getAvatarUrl()));
            } else {
                reply.setRecipient(new Comment.Recipient(comment.getUid(), comment.getUsername(), comment.getAvatarUrl()));
            }
        }
        comment.setReplies(replies);
    }
}

4、前端代码示例

<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>comment</title>
    <link rel="stylesheet" type="text/css" href="semantic/semantic.min.css">
    <script src="js/jquery-3.7.0.js"></script>
    <script src="semantic/semantic.min.js"></script>
</head>
<body>
<div id="app">
    <div class="ui threaded comments" style="margin: 20px auto">
        <form class="ui reply form">
            <div class="field">
                <textarea v-model="content" id="content"></textarea>
            </div>
            <div class="ui blue labeled submit icon button" @click="addComment">
                <i class="icon edit"></i> 评论
            </div>
        </form>
        <h3 class="ui dividing header">评论</h3>
        <div class="comment" v-for="comment in comments" :key="comment.id">
            <a class="avatar">
                <img
                        :src="comment.avatarUrl" style="width: 45px;height: 45px"
                        alt="avatar">

            </a>
            <div class="content">
                <a class="author">{{comment.username}}</a>
                <div class="metadata">
                    <span class="date">{{comment.date}}</span>
                </div>
                <div class="text">
                    <p>{{comment.content}}</p>
                </div>
                <div class="actions">
                    <a class="reply" @click="addReply(comment.id)">回复</a>
                </div>
            </div>
            <div class="comments" v-if="comment.replies.length">
                <div class="comment" v-for="reply in comment.replies">
                    <a class="avatar">
                        <img
                                :src="reply.avatarUrl" style="width: 45px;height: 45px"
                                alt="avatar">
                    </a>
                    <div class="content">
                        <a class="author">{{reply.username}}</a>
                        <span v-if="reply.rid">@ {{reply.recipient.username}}</span>
                        <div class="metadata">
                            <span class="date">{{reply.date}}</span>
                        </div>
                        <div class="text">{{reply.content}}</div>
                        <div class="actions">
                            <a class="reply" @click="addSecondaryReply(comment.id,reply.id)">回复</a>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <!-- 回复评论弹框 -->
    <div class="ui modal">
        <div class="header">请输入回复内容</div>
        <div class="ui form">
            <div class="field">
                <textarea rows="2" v-model="replyContent"></textarea>
            </div>
        </div>
        <div class="actions">
            <div class="ui basic button Negative" @click="hide">取消</div>
            <div class="ui button primary" @click="addComment(1)">回复</div>
        </div>
    </div>
</div>
<script src="js/vue.js"></script>
<script src="js/axios.js"></script>
<script>
    const instance = axios.create({
        baseURL: 'http://localhost:8080'
    })
    instance.interceptors.response.use(function (response) {
        return response.data;
    }, function (error) {
        return Promise.reject(error);
    })
    new Vue({
        el: "#app",
        data: {
            comments: {},
            aid: 1, // 根据实际情况设置文章的id
            uid: 1000,    // 根据实际情况设置用户的id
            content: '',
            cid: '',
            rid: '',
            replyContent: ''
        },
        methods: {
            loadComment() {
                instance.get('/comment').then(res => {
                    this.comments = res.data
                })
            },
            // 添加评论
            addComment(val) {
                if (val !== 1) {
                    if (!this.content.trim()) {
                        alert('不能为空!')
                        return
                    }
                }
                if (val === 1) {
                    if (!this.replyContent.trim()) {
                        alert('不能为空!')
                        return
                    }
                    this.content = this.replyContent
                }
                instance.post('/comment', {
                    aid: this.aid,
                    uid: this.uid,
                    content: this.content,
                    cid: this.cid,
                    rid: this.rid
                }).then(res => {
                    this.loadComment()
                    this.clear()
                })
            },
            addReply(cid) {
                this.show()
                this.cid = cid;
            },
            // 二级回复
            addSecondaryReply(cid, rid) {
                this.show()
                this.cid = cid
                this.rid = rid
            },
            clear() {
                this.content = ''
                this.cid = ''
                this.rid = ''
                this.hide()
            },
            hide(){
                this.cid = ''
                this.rid = ''
                this.replyContent = ''
                $('.ui.modal')
                    .modal('hide')
                ;
            },
            show(){
                $('.ui.modal')
                    .modal('show')
                ;
            }
        },
        mounted() {
            this.loadComment();
        }
    })
</script>
</body>
</html>