稻草问答-问答评论

稻草问答-问答评论

1 问题答案列表功能

1.1 获取问答答案列表业务层

问题回复以后,问题应该出现在所有回答区域!

实现这个功的实现流程也和显示问题列表类似,首先在添加业务层IAnswerService方法获取一个问题的全部回复列表:

    /**
     * 查询一个问题的全部回复Answer
     */
List<Answer> getAnswersByQuestionId(Integer questionId);

在AnswerServiceImpI中实现这个方法,为了将最新的问题显示到最上方,这里按照时间的倒序进行排序:

@Override
public List<Answer> getAnswersByQuestionId(Integer questionId) {
    if(questionId==null){
        throw ServiceException.notFound("QuestionId为空");
    }
    List<Answer> answers=answerMapper.selectList(new QueryWrapper<Answer>()
                                                 .eq("quest_id",questionId)
                                                 .orderByAsc("createtime"));
    return answers;
}

在AnswerServiceTests中测试:

@Test
public void getAnswersByQuestionId(){
    List<Answer> answers=answerService.getAnswersByQuestionId(149);
    answers.forEach(answer -> log.debug("{}",answer));
}

1.2 在网页中显示问题答案列表

为了在浏览器页面中显示问题答案列表需要添加一个控制器方法,显示一个问题的全部答案:

/**
     * 请求路径:/v1/answers/question/149
     * 根据问题的编号获取全部回答列表
     * 其中 最后的数字是问题的id 
     * @param id 问题ID,从URL映射到方法参数
     * @return 一个问题ID下的全部答案列表
     */
@GetMapping("/question/{id}")
public R<List<Answer>> questionAnswers(@PathVariable Integer id){
    if (id==null){
        return R.invalidRequest("必须包含问题编号");
    }
    List<Answer> answers=answerService.getAnswersByQuestionId(id);
    return R.ok(answers);
}

在detail.html中声明VUE视图:

<!--列出所有的答案-->
<div class="row mt-5 ml-2" id="answersApp">
    <div class="col-12">
        <div class="well-sm"><p><span v-text="answers.length"></span>条回答</p></div>
        <div class="card card-default my-1" v-for="answer in answers">
            <!-- Default panel contents -->
            <div class="card-header">
                <div class="row">
                    <div class="col-1">
                        <img style="width: 50px;height: 50px;border-radius: 50%;"
                             src="../img/user.jpg">
                    </div>
                    <div class="col-8 ">
                        <div class="row">
                            <span class="ml-3" v-text="answer.userNickName">张三</span>
                        </div>
                        <div class="row">
                            <span class="ml-3" v-text="answer.duration">2天前</span>
                        </div>

                    </div>
                </div>
            </div>
            <div class="card-body ">
                <span class="question-content text-monospace" v-html="answer.content">
                    方法的重载是overloading,方法名相同,参数的类型或个数不同,对权限没有要求
                    方法的重写是overrding 方法名称和参数列表,参数类型,返回值类型全部相同,但是所实现的内容可以不同,一般发生在继承中
                </span>
                <a class="text-primary ml-2" style="font-size: small"
                   href="../answer/edit.html">
                    <i class="fa fa-edit"></i>编辑
                </a>

                <a class="ml-2  fa fa-close " style="font-size: small">
                    删除
                </a>

            </div>
            <div class="card-footer">
                <!-- 答案的评论 -->
                <p class="text-success fa fa-comment">
                    <span v-text="answer.comments.length">1</span>条评论</p>
                <ul class="list-unstyled mt-3">
                    <li class="media my-2" v-for="(comment, index) in answer.comments">
                        <img style="width: 50px;height: 50px;border-radius: 50%;"
                             src="../img/user.jpg" class="mr-3"
                             alt="...">
                        <div class="media-body">
                            <h6 class="mt-0 mb-1"><span v-text="comment.userNickName">李四</span>:</h6>
                            <p class="text-dark">
                                <span class="text-monospace" v-text="comment.content">
                                    明白了,谢谢老师!
                                </span>
                                <span class="font-weight-light text-info"
                                      style="font-size: small"></span>
                                <a class="text-primary ml-2"
                                   style="font-size: small" data-toggle="collapse" href="#editCommemt1"
                                   role="button" aria-expanded="false" aria-controls="collapseExample"
                                   v-bind:href="'#editComment'+comment.id">
                                    <i class="fa fa-edit"></i>编辑
                                </a>
                                <!--老师角色或者属于本用户的评论可以删除该评论-->
                                <a class="ml-2  fa fa-close " style="font-size: small"
                                   data-toggle="collapse"  role="button"
                                   aria-expanded="false" aria-controls="collapseExample"
                                   onclick="$(this).next().toggle(300)">
                                    删除
                                </a>
                                <a class="badge-pill badge bg-danger text-white"
                                   style="display: none; cursor: pointer;"
                                   @click="removeComment(comment.id, index, answer.comments)">
                                    <i class="fa fa-close"></i>
                                </a>

                            </p>
                            <div class="collapse" id="editCommemt1" v-bind:id="'editComment'+comment.id">
                                <div class="card card-body border-light">
                                    <form action="" method="post" class="needs-validation" novalidate
                                          @submit.prevent="updateComment(comment.id, answer.id, index, answer.comments)">
                                        <div class="form-group">
                                            <textarea class="form-control"
                                                      id="textareaComment1" name="content" rows="4"
                                                      required v-text="comment.content"></textarea>
                                            <div class="invalid-feedback">
                                                内容不能为空!
                                            </div>
                                        </div>
                                        <button type="submit" class="btn btn-primary my-1 float-right">提交修改</button>
                                    </form>
                                </div>
                            </div>
                        </div>
                    </li>

                </ul>
                <p class="text-left text-dark">
                    <a class="btn btn-primary mx-2 text-while"
                       onclick="$(this).next().toggle(300)">采纳答案</a>
                    <a class="badge-pill bg-success text-white"
                       style="display: none; cursor: pointer"
                       @click="answerSolved(answer.id,answer)">
                        <i class="fa fa-check-square"></i>
                    </a>
                    <a class="btn btn-outline-primary" data-toggle="collapse" href="#collapseExample1"
                       role="button" aria-expanded="false" aria-controls="collapseExample"
                       v-bind:href="'#addComment'+answer.id">
                        <i class="fa fa-edit"></i>添加评论
                    </a>
                </p>
                <div class="collapse" id="collapseExample1" v-bind:id="'addComment'+answer.id">
                    <div class="card card-body border-light">
                        <form action="#" method="post" class="needs-validation" novalidate
                              v-on:submit.prevent="postComment(answer.id)">
                            <div class="form-group">
                                <textarea class="form-control" name="content" rows="3" required></textarea>
                                <div class="invalid-feedback">
                                    评论内容不能为空!
                                </div>
                            </div>
                            <button type="submit" class="btn btn-primary my-1 float-right">提交评论</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
<!--列出所有的答案-end-->

在detail.js中声明视图模型:

let answersApp=new Vue({
    el:'#answersApp',
    data:{
        message:"",
        hasError:false,
      answers:[]
    },
    methods:{
        loadAnswers:function () {
            //拿到当前问题的id
            let questionId=location.search;
            if(! questionId){
                answersApp.message="必须有问题ID";
                answersApp.hasError=true;
                return;
            }
            questionId=questionId.substring(1);
            //利用Ajax从服务器读取Answers数据
            $.ajax({
                //v1/answers/question/149
                url:'/v1/answers/question/'+questionId,
                method:'GET',
                success:function (r) {
                    console.log(r);
                    if(r.code===OK){
                        answersApp.answers=r.data;
                       answersApp.updateDuration();
                    }else{
                        answersApp.message=r.message;
                        answersApp.hasError=true;
                    }
                }
            });
        },
        updateDuration:function () {
            let questions = this.questions;
            //duration持续时间
            for(let i=0; i<questions.length; i++){
                //转换文件创建时间为毫秒数
                let createtime=
                    new Date(questions[1].createtime).getTime();
                let now = new Date().getTime();
                //计算持续时间毫秒数
                let duration = now - createtime ;
                //为question对象增加持续时间属性
                if (duration<1000*60){
                    questions[i].duration = "刚刚" ;
                }else if (duration < 1000*60*60){
                    questions[i].duration =
                        (duration/1000/60).toFixed(0)+"分钟前";
                }else if (duration < 1000*60*60*24){
                    questions[i].duration =
                        questions[i].duration =
                        (duration/1000/60/60).toFixed(0)+"小时前";
                }else{
                    questions[i].duration =
                        (duration/1000/60/60/24).toFixed(0)+"天前";
                }
            }
        }
    },
    created:function(){
        console.log("执行了方法");
        this.loadQuestions(1);
    }
});

1.3 重构updateDuration方法

为了界面美观友好,detail.js也添加了方法 updateDuration() 用于显示问题发生的时间,显然其算法是冗余的,可以进行重构,消除冗余代码。

在utils.js中添加addDuration()方法,封装根据createtime属性创建duration属性的方法:

function addDuration(item) {
    if(! item || !item.createtime){
        return;
    }
    //创建问题时候的时间毫秒数
    let createtime = new Date(item.createtime).getTime();
    //当前时间毫秒数
    let now = new Date().getTime();
    let duration = now - createtime;
    if (duration < 1000*60){ //一分钟以内
        item.duration = "刚刚";
    }else if(duration < 1000*60*60){ //一小时以内
        item.duration =
            (duration/1000/60).toFixed(0)+"分钟以前";
    }else if (duration < 1000*60*60*24){
        item.duration =
            (duration/1000/60/60).toFixed(0)+"小时以前";
    }else {
        item.duration =
            (duration/1000/60/60/24).toFixed(0)+"天以前";
    }
}

然后更新details.js中的视图模型,复用addDuration方法为Question对象和Answer对象添加duration属性:

let questionApp=new Vue({
    el:'#questionApp',
    data:{
        question:{}
    },
    methods:{
        loadQuestions:function () {
            //通过地址栏获取问题ID
            let questionId=location.search;
            if(! questionId){
                alert('必须指定问题ID');
                return;
            }
            //去除查询参数中的?
            questionId=questionId.substring(1);
            $.ajax({
                url:'/v1/questions/'+questionId,
                method:'GET',
                success:function (r) {
                    console.log('得到问题对象:')
                    console.log(r);
                    if(r.code===OK){
                        //let q=r.data;
                        //addDuration(q);
                        //questionApp.question=q;
                        questionApp.question=r.data;
                        questionApp.updateDuration();
                        //问题持续时间
                    }else{
                        alert(r.message);
                    }
                }
            });
        }
    },
    created:function () {
        this.loadQuestions();
    }
});
let postAnswerApp = new Vue({
    el:'#postAnswerApp',
    data: {
        message:'',
        hasError: false
    },
    methods:{
        postAnswer:function(){
            postAnswerApp.hasError=false;
            let questionId = location.search;
            if(! questionId ){
                postAnswerApp.message = '没有问题ID';
                postAnswerApp.hasError = true;
                return;
            }
            //去除参数上的间号
            questionId = questionId.substring(1);
            let content = $('#summernote').val();
            if (! content){
                postAnswerApp.message =必须填写回答内容'; 
                postAnswerApp.hasError = true ;
                return;
            }
            let form = {
                questionId: questionId,
                content: content
            }
            $.ajax({
                url:'/v1/answers',
                method:'POST', 
                data: form,
                success:function(r){
                    if(r.code === CREATED){
                        postAnswerApp.message = r.message;
                        postAnswerApp.hasError = true;
                    }else {
                        postAnswerApp.message = r.message;
                        postAnswerApp.hasError = true;
                    }
                }
            });
        }
    }
});
let answersApp=new Vue({
    el:'#answersApp',
    data:{
        message:"",
        hasError:false,
        answers:[]
    },
    methods:{
        loadAnswers:function () {
            //拿到当前问题的id
            let questionId=location.search;
            if(! questionId){
                answersApp.message="必须有问题ID";
                answersApp.hasError=true;
                return;
            }
            questionId=questionId.substring(1);
            //利用Ajax从服务器读取Answers数据
            $.ajax({
                //v1/answers/question/149
                url:'/v1/answers/question/'+questionId,
                method:'GET',
                success:function (r) {
                    console.log(r);
                    if(r.code===OK){
                        answersApp.answers=r.data;
                        answersApp.updateDuration();
                    }else{
                        answersApp.message=r.message;
                        answersApp.hasError=true;
                    }
                }
            });
        },
        updateDuration:function (answer) {
            for(let i=0; i<this.answers.length; i++){
                addDuration(this.answers[i]);
            }
        }
    },
    created:function(){
        console.log("执行了方法");
        this.loadQuestions(1);
    }
});      

还可以重构index_teacher.js和index.js脚本,使用addDuration方法增加question对象的属性。

1.4 将新答案插入到答案列表

老师回复答案以后,新答案应该排列到全部答案列表之后,实现这个功能第一步要更新控制器,在创建成功以后,返回新创建答案对象。

具体步骤是首先更新对象R,增加一个返回新创建对象的create方法:

public static R created(Object data){
    return new R().setCode(CREATED).setData(data).setMessage("创建成功!");
}

然后重构控制器,在创建answer对象以后,将对象返回给浏览器:

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author tedu.cn
 * @since 2021-07-29
 */
@RestController
@RequestMapping("/v1/answers")
@Slf4j
public class 
 {
    @Resource
    public IAnswerService answerService;
    
    @PreAuthorize("hasRole('TEACHER')")

    @PostMapping("")
    public R<Answer> postAnswer(
        @Validated AnswerVo answerVo, BindingResult result, @AuthenticationPrincipal User user){
        //log.debug("收到表单{}",answerVo);
        if (result.hasErrors()){
            String message=result.getFieldError().toString();
            return R.unprocesabelEntity(message);
        }
        log.debug("表单数据{}",answerVo);
        Answer answer=answerService.saveAnswer(answerVo, user.getUsername());
        return R.created(answer);
    }
}

重构detail.js,答案创建成功以后,将得到的answer对象插入到answersApp中的answers数组前面:

let postAnswerApp = new Vue({
    el:'#postAnswerApp',
    data: {
        message:'',
        hasError: false
    },
    methods:{
        postAnswer:function(){
            postAnswerApp.hasError=false;
            let questionId = location.search;
            if(! questionId ){
                postAnswerApp.message = '没有问题ID';
                postAnswerApp.hasError = true;
                return;
            }
            //去除参数上的间号
            questionId = questionId.substring(1);
            let content = $('#summernote').val();
            if (! content){
                postAnswerApp.message =必须填写回答内容'; 
                postAnswerApp.hasError = true ;
                return;
            }
            let form = {
                questionId: questionId,
                content: content
            }
            $.ajax({
                url:'/v1/answers',
                method:'POST', 
                data: form,
                success:function(r){
                    if(r.code === CREATED){
                        let answer=r.data;
                        //插入到answer数组的后面
                        answersApp.answers.push(answer);
                        //清空summernote区域
                        $('#summernote').summernote('reset');
                        //显示成功消息
                        postAnswerApp.message = r.message;
                        postAnswerApp.hasError = true;
                        //消息显示一秒后消失
                        setTimeout(function(){
                            postAnswerApp.hasError=false;
                            ,1000})
                    }else {
                        postAnswerApp.message = r.message;
                        postAnswerApp.hasError = true;
                    }
                }
            });
        }
    }
});

2 评论答案

2.1 添加对答案的评论

老师给了问题答案以后,老师和学员需要都可以针对这个答案进行进一步的讨论,这样采用充分的解决问题,所以要提供老师和学生相互提交讨论的功能。

一个问题(question)会有多个回答(answer)而每个回答(answer)会有多个评论(comment)

2.2 为comment表增加user_nick_name属性

为了增加实战体验,稻草问答项目设计了修改表的环节,问表增加一个列。comment表缺少了一个冗余属性user_nick_name。没有这个属性,在展示数据时候可以通过关联查询获得这个昵称属性,但在关联查询性能没有单表查询性能好。考虑到昵称很少修改,并且经常被查询展示,为了提高性能,现在为comment表增加一个昵称属性:

ALTER TABLE comment ADD COLUIMN user_nick_name VARCHAR(255)) AFTER user_id;
UPDATE comment c SET user_nick_name =(SELECT nickname FROM user u WHERE c.user_id = u.id);
SELECT user_id, user_nick_name FROM comment;

说明:

  • ALTER TABLE语句用于更改表格,其后是表的名称;

  • ADD COLUMN表示为表格添加列,其后是列名和数量类型;

  • AFTER说明在那个列后面插入,这里表示在user_id列后面插入user_nick_name列;

  • UPDATE语句更新comment表格user_nick_name列;

    • 采用子查询从user表中获取nickname的值;

    • 子查询的条件是comment表的user_id值与user表的id值相等;

  • 最后使用SELECT语句检查comment表格user_nick_name的内容。

更新了comment表,就需要更新其对应的实体类型:Comment.java添加userNickName属性,这样MyBatisPlus就会自动将表和属性映射上:

    /**
     * 用户昵称
     */
@TableField("user_nick_name")
private String userNickName;

创建测试类CommentMapperTests验证,在输出结果中应该出现userNickName属性:

package cn.tedu.straw.portal;

import cn.tedu.straw.portal.mapper.CommentMapper;
import cn.tedu.straw.portal.model.Comment;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;

@SpringBootTest
@Slf4j
public class CommentMapperTests {
    @Resource
    CommentMapper commentMapper;

    @Test
    public void selectById(){
        Comment comment=commentMapper.selectById(11);
        log.debug("{}",comment);
    }
}

2.3 添加评论功能

添加评论功能与添加问题,添加答案功能类似,都行将表单中的数据通过控制器业务层最后最终存储到数据库。

首先声明CommentVo代表从表单传递过来的数据,其中包含answerld,表示这个评论是真对那个答案的,content是评论的详细内容:

package cn.tedu.straw.portal.vo;

import lombok.Data;
import lombok.experimental.Accessors;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;

@Data
@Accessors(chain = true)
public class CommentVo implements Serializable{

    @NotNull(message = "答案ID不能为空!")
    private Integer answerId;

    @NotBlank(message = "必须填写正文!")
    private String content;
}

然后编写控制器方法接收表单数据,控制器URL按照REST风格设计:

@RestController
@RequestMapping("/v1/comments")
@Slf4j
public class CommentController {

    @Resource
    private ICommentService commentService;

    @PostMapping
    public  R<Comment> postComment(@Validated CommentVo commentVo,
                                   BindingResult result){
        if (result.hasErrors()){
            String message=result.getFieldError().getDefaultMessage();
            return R.unproecsableEntity(message);
        }
        log.debug("{}",commentVo);
        return R.created("OK");
    }
}

在detail.html页面中添加VUE视图脚本,配合Bootstrap实现展开/折叠评论表单功能:

<p class="text-left text-dark">
    <a class="btn btn-primary mx-2 text-while"
       onclick="$(this).next().toggle(300)">采纳答案</a>
    <a class="badge-pill bg-success text-white"
       style="display: none; cursor: pointer"
       @click="answerSolved(answer.id,answer)">
        <i class="fa fa-check-square"></i>
    </a>
    <a class="btn btn-outline-primary" data-toggle="collapse" href="#collapseExample1"
       role="button" aria-expanded="false" aria-controls="collapseExample"
       v-bind:href="'#addComment'+answer.id">
        <i class="fa fa-edit"></i>添加评论
    </a>
</p>
<div class="collapse" id="collapseExample1" v-bind:id="'addComment'+answer.id">
    <div class="card card-body border-light">
        <form action="#" method="post" class="needs-validation" novalidate
              v-on:submit.prevent="postComment(answer.id)">
            <div class="form-group">
                <textarea class="form-control" name="content" rows="3" required></textarea>
                <div class="invalid-feedback">
                    评论内容不能为空!
                </div>
            </div>
            <button type="submit" class="btn btn-primary my-1 float-right">提交评论</button>
        </form>
    </div>
</div>

在detail.js的answersApp视图模型中声明事件处理方法postComment方法,处理添加评论表单事件,事件方法的参数就是当前answer的ID:

postComment:function (answerId) {
    let textarea=$('#addComment'+answerId+' textarea');
    let content=textarea.val();
    if(! content){
        return;
    }
    let form = {
        answerId: answerId,
        content: content
    };
    $.ajax({
        url:'/v1/comments',
        method:'POST',
        data:form,
        success:function (r) {
            console.log(r);
            if(r.code===CREATED){
                alert(r.message);
            }else{
                alert(r.message);
            }
        }
    });
}

启动SpringBoot程序进行测试,检查是否成功的将评论表单数据传递到服务器上。

与添加Answer类似,保存Comment也需要在业务层ICommentService中声明方法保存评论数据:

public interface ICommentService extends IService<Comment> {
    /**
     * 将用户发起的评论信息保存起来
     * @param commentVo 用户发起到评论表单
     * @param username 发起评论的用户
     * @return 创建好的评论对象
     */
    Comment saveComment(CommentVo commentVo, String username);
}

在CommentServicelmpl中实现保存Comment方法:

@Service
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements ICommentService {

    @Resource
    private UserMapper userMapper;

    @Resource
    private CommentMapper commentMapper;

    @Override
    @Transactional
    public Comment saveComment(CommentVo commentVo, String username) {
        User user=userMapper.findUserByUsername(username);
        Comment comment=new Comment()
                .setUserNickName(user.getNickname())
                .setUserId(user.getId())
                .setCreatetime(LocalDateTime.now())
                .setContent(commentVo.getContent())
                .setAnswerId(commentVo.getAnswerId());
       int rows = commentMapper.insert(comment);
        if(rows !=1){
            throw ServiceException.busy();
        }
        return comment;
    }
}

为了便于处理数据库异常,在ServiceException中添加静态方法busy(),这样就可以简化后续的数据库异常的编码了:

public static ServiceException busy(){
    return new ServiceException("数据库繁忙",R.INTERNAL_SERVER_ERROR);
}

编写测试案例CommentServiceTests:

@SpringBootTest
@Slf4j
public class CommentServiceTests {

    @Resource
    ICommentService commentService;

    @Test
    public void saveComment(){
        CommentVo commentVo=new CommentVo()
            .setContent("这是一个内部测试!")
            .setAnswerId(84);
        String username="st2";
        Comment comment=commentService.saveComment(commentVo,username);
        log.debug("{}",comment);
    }
}

重构控制器CommentController, 利用@AuthenticationPrincipal UserDetails userDetails注入登录用户信息,然后将控制器收到的表单数据存储到数据库中:

@RestController
@RequestMapping("/v1/comments")
@Slf4j
public class CommentController {

    @Resource
    private ICommentService commentService;

    @PostMapping
    public  R<Comment> postComment(@Validated CommentVo commentVo,
                                   BindingResult result,
                                   @AuthenticationPrincipal UserDetails userDetails){
        if (result.hasErrors()){
            String message=result.getFieldError().getDefaultMessage();
            return R.invalidRequest(message);
        }
        log.debug("{}",commentVo);
        Comment comment=commentService.saveComment(commentVo, userDetails.getUsername());

        return R.created(comment);
    }

整合测试......

2.4 利用关联查询获得评论列表

刚刚完成提交了评论,显然需要将全部评论显示出来,现在如果采用现在加载全部答案,再加载每个答案的讨论,这样就会发生太量的数据库访问,不仅显示会有延迟,数据库也会承受很多压力。

解决的办法是一次查询出全部的问题答案和相关的评论列表。MyBatis提供了这种关联查询功能。

首先更新Answer类,添加comments属性, 代表当前答案相关的全部Comment评论:

package cn.tedu.straw.portal.model;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
 * <p>
 *
 * </p>
 *
 * @author tedu.cn
 * @since 2021-07-29
 */
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("answer")
public class Answer implements Serializable {

    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
     * 回答内容
     */
    @TableField("content")
    private String content;

    /**
     * 点赞数量
     */
    @TableField("like_count")
    private Integer likeCount;

    /**
     * 回答问题的用户id
     */
    @TableField("user_id")
    private Integer userId;

    /**
     * 回答者用户名
     */
    @TableField("user_nick_name")
    private String userNickName;

    /**
     * 对应的问题id
     */
    @TableField("quest_id")
    private Integer questId;

    /**
     * 回答时间
     */
    @TableField("createtime")
    private LocalDateTime createtime;

    /**
     * 是否采纳
     */
    @TableField("accept_status")
    private Integer acceptStatus;

    /**
     * 当前回复的答案跟随的全部评论
     */
    @TableField(exist = false)
    private List<Comment> comments=new ArrayList<>();
}

然后在AnswerMapper中添加方法,查询问题的全部答案列表answer:

@Repository
public interface AnswerMapper extends BaseMapper<Answer> {
    /**
     * 根据问题的ID查询出全部的答案
     * 同时利用关联查询,查询出每个答案附带的
     * 全部评论
     * @param questionId 问题的ID
     * @return 问题的全部答案,附带全部评论
     */
    List<Answer> findAnswersByQuestionId(Integer questionId);
}

这个查询方法对应的SQL应该是这个样子:

select
a.id,
a.content,
a.like_count,
a.user_nick_name,
a.quest_id,
a.createtime,
a.accept_status,
c.id as comment_id,
c.user_id as comment_user_id,
c.answer_id as comment_answer_id,
c.user_nick_name as comment_user_nick_name,
c.content as comment_content,
c.createtime as comment_createtime
from answer a
left join comment c on a.id=c.answer_id
where a.quest_id=149
order by a.createtime, c.createtime

这个查询方法使用复杂关联查询,不适合使用@Select注解声明SQL语句,这种复杂的关联查询需要使用强大的XML文件映射SQL与对象的关系:

resource/mapper/AnswerMapper.xml参考代码:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.tedu.straw.portal.mapper.AnswerMapper">
    <resultMap id="answersMap" type="cn.tedu.straw.portal.model.Answer">
        <id column="id" property="id" />
        <result column="content" property="content" />
        <result column="like_count" property="likeCount" />
        <result column="user_id" property="userId" />
        <result column="user_nick_name" property="userNickName" />
        <result column="quest_id" property="questId" />
        <result column="createtime" property="createtime" />
        <result column="accept_status" property="acceptStatus" />

        <collection javaType="java.util.List" property="comments"
                    ofType="cn.tedu.straw.portal.model.Comment">
            <id column="comment_id" property="id"/>
            <result column="comment_user_id" property="userId"/>
            <result column="comment_answer_id" property="answerId"/>
            <result column="comment_user_nick_name" property="userNickName"/>
            <result column="comment_content" property="content"/>
            <result column="comment_createtime" property="createtime"/>
        </collection>

    </resultMap>
    <select id="findAnswersByQuestionId" resultMap="answersMap">
       select
            a.id,
            a.content,
            a.like_count,
            a.user_nick_name,
            a.quest_id,
            a.createtime,
            a.accept_status,
            c.id as comment_id,
            c.user_id as comment_user_id,
            c.user_nick_name as comment_user_nick_name,
            c.answer_id as comment_answer_id,
            c.content as comment_content,
            c.createtime as comment_createtime
            from answer a
            left join comment c on a.id=c.answer_id
            where a.quest_id=#{questionId}
      order by a.createtime, c.createtime
    </select>
</mapper>

测试......

@SpringBootTest
@Slf4j
public class AnswerMapperTests {
    @Resource
    AnswerMapper answerMapper;

    @Test
    public void findAnswersByQuestionId(){
        List<Answer> answers=answerMapper.findAnswersByQuestionId(149);
        answers.forEach(answer -> log.debug("{}",answer));
    }
}

2.5 利用VUE显示评论列表

更新AnswerServiceImpI中的getAnswersByQuestionId方法:

@Override
public List<Answer> getAnswersByQuestionId(Integer questionId) {
    if(questionId==null){
        throw ServiceException.notFound("QuestionId为空");
    }
    List<Answer> answers=answerMapper.findAnswersByQuestionId(questionId);

    return answers;
}

测试......

更新detail.html中的VUE视图,显示评论列表

<!-- 答案的评论 -->
<p class="text-success fa fa-comment">
    <span v-text="answer.comments.length">1</span>条评论</p>
<ul class="list-unstyled mt-3">
    <li class="media my-2" v-for="(comment, index) in answer.comments">
        <img style="width: 50px;height: 50px;border-radius: 50%;"
             src="../img/user.jpg" class="mr-3"
             alt="...">
        <div class="media-body">
            <h6 class="mt-0 mb-1"><span v-text="comment.userNickName">李四</span>:</h6>
            <p class="text-dark">
                <span class="text-monospace" v-text="comment.content">
                    明白了,谢谢老师!
                </span>
                <span class="font-weight-light text-info"
                      style="font-size: small"></span>
                <a class="text-primary ml-2"
                   style="font-size: small" data-toggle="collapse" href="#editCommemt1"
                   role="button" aria-expanded="false" aria-controls="collapseExample"
                   v-bind:href="'#editComment'+comment.id">
                    <i class="fa fa-edit"></i>编辑
                </a>
                <!--老师角色或者属于本用户的评论可以删除该评论-->
                <a class="ml-2  fa fa-close " style="font-size: small"
                   data-toggle="collapse"  role="button"
                   aria-expanded="false" aria-controls="collapseExample"
                   onclick="$(this).next().toggle(300)">
                    删除
                </a>
                <a class="badge-pill badge bg-danger text-white"
                   style="display: none; cursor: pointer;"
                   @click="removeComment(comment.id, index, answer.comments)">
                    <i class="fa fa-close"></i>
                </a>

            </p>
            <div class="collapse" id="editCommemt1" v-bind:id="'editComment'+comment.id">
                <div class="card card-body border-light">
                    <form action="" method="post" class="needs-validation" novalidate
                          @submit.prevent="updateComment(comment.id, answer.id, index, answer.comments)">
                        <div class="form-group">
                            <textarea class="form-control"
                                      id="textareaComment1" name="content" rows="4"
                                      required v-text="comment.content"></textarea>
                            <div class="invalid-feedback">
                                内容不能为空!
                            </div>
                        </div>
                        <button type="submit" class="btn btn-primary my-1 float-right">提交修改</button>
                    </form>
                </div>
            </div>
        </div>
    </li>
</ul>

测试.....

更新添加评论事件,得到新评论时候,将新评论添加到评论列表的最后位置上:

postComment:function (answerId) {
    if(! answerId){
        return;
    }
    let textarea=$('#addComment'+answerId+' textarea');
    let content=textarea.val();
    if(! content){
        return;
    }
    let form = {
        answerId: answerId,
        content: content
    };
    $.ajax({
        url:'/v1/comments',
        method:'POST',
        data:form,
        success:function (r) {
            console.log(r);
            if(r.code===CREATED){
                let comment=r.data;
                //找到当前问题的全部评论列表,然后将评论插入到列表最后
                let answers = answersApp.answers;
                for(let i=0; i<answers.length; i++){
                    if(answers[i].id === answerId){
                        answers[i].comments.push(comment);
                        break;
                    }
                }
            }else{
                alert(r.message);
            }
        }
    });
},

说明:根据answerId找到answer对象,然后在其comments数组最后的位置追加comment对象。

3 删除、修改评论

3.1 删除评论

尽管可以添加评论,但是人难免会有失误,完全有可能出现填写错误的评论,如果需要更正错误,就需要删除或者修改功能。

这里我们定义如下规则:

  • 评论的发布者和回答问题的老师可以删除评论;
  • 评论的发布者和回答问题的老师可以修改评论。

能够完成添加评论,那么删除评论功能,也就不难了。思路是先在界面上让用户确认一下删除,确认后就将删除请求发生到服务器,服务器需要检验一下权限,然后删除评论信息。

首先我们在界面上设计一个删除确认功能,点击删除按钮时候弹出一个确认删除按钮,点击确认删除按钮时候,再进行真正的删除动作,并在删除按钮上绑定点击事件:

<!--老师角色或者属于本用户的评论可以删除该评论-->
<a class="ml-2  fa fa-close " style="font-size: small"
   data-toggle="collapse"  role="button"
   aria-expanded="false" aria-controls="collapseExample"
   onclick="$(this).next().toggle(300)">
    删除
</a>
<a class="badge-pill badge bg-danger text-white"
   style="display: none; cursor: pointer;"
   @click="removeComment(comment.id)">
    <i class="fa fa-close"></i>
</a>

上述代码中@click 是v-on:click的缩写形式,removeComment(comment.id)是处理删除事件的方法,需要在VUE视图模型中定义,参数comment.id是被删除评论的ID.

在detail.js中answersApp中添加事件处理方法:

removeComment:function (commentId, index, comments) {
    console.log("commentId:" + commentId);
    if (! commentId) return;
    $.ajax({
        url:'/v1/comments/'+commentId +'/delete',
        method:'GET',
        success:function (r) {
            console.log(r);
            if(r.code===GONE){
                alert(r.message);
            }else{
                alert(r.message);
            }
        }
    });
}

然后在CommentController中填写控制方法,处理器删除请求/v1/comments/{id}/delete

    /**
     * 请求路径:/v1/comments/29/delete
     */
@GetMapping("/{id}/delete")
public R removeComment(@PathVariable Integer id){
    log.debug("CommentId:{}",id);
    return R.gone("删除了!");
}

测试,检查服务器是否收到CommentID。

在ICommentSerivce类中编写控制器方法完成删除动作,因为要检验用户身份是否有权限删除评论,所以需要一个用户名作为参数:

    /**
     * 根据评论的id删除掉评论,老师可以删除任何评论
     * 学生只能删除自己的评论
     * @param commentId 评论的id
     * @param username 正在执行删除功能的用户名
     * @return true 表示删除了
     */
boolean removeComment(Integer commentId, String username);

在CommentServiceTests中测试删除方法:

@Test
public void removeComment(){
    Integer commentId=29;
    String username="wangkj";
    boolean b=commentService.removeComment(commentId,username);
    log.debug("del {}", b);
}

重构控制器,调用业务层的删除方法:

/**
     * 请求路径:/v1/comments/29/delete
     */
@GetMapping("/{id}/delete")
public R removeComment(@PathVariable Integer id,
                       @AuthenticationPrincipal UserDetails user
                      ){
    log.debug("CommentId:{}",id);
    boolean del=commentService.removeComment(id,user.getUsername());
    if(del){
        return R.gone("删除了!");
    }else{
        return R.notFound("没有找到记录!");
    }
}

3.2 网页上同步更新删除结果

为了达到直观的用户体验,在成功删除评论以后,需要将页面中的对应评论删除掉。VUE提供了数据和界面元素绑定功能,只需要从数组中删除相应的评论数据,界面上的DOM自动的同步删除了。为了删除数据需要找到数据再删除它,但找到数据的位置比较繁琐过程。有个简单的变通方法就是在显示数据时候将数据数组引用变量传递到事件方法中,这样就不需要查找了,在发生删除时候同时从数组中删除数据就行了。

VUE数组原生的操作方法会触发视图的改变:

push()数组末端添加项
pop()数组末端删除项
shift()数组头部添加项
unshift()数组头部删除项
splice(index, num) 删除,num删除数量
sort() 排序
reverse()反转
filter()、concat() 和slice()可以赋值到愿数据 也会引起视图更新

这样有重构detail.html中的事件调用方法,将comments数组, 被删除元素在数组中的位置index传递到事件方法中:

<a class="badge-pill badge bg-danger text-white"
   style="display: none; cursor: pointer;"
   @click="removeComment(comment.id, index, answer.comments)">
    <i class="fa fa-close"></i>
</a>

重构detail. js中answersApp事件处理方法,在删除成功时候,从数组中删除comment元素:

removeComment:function (commentId, index, comments) {
    console.log("commentId:" + commentId);
    if (! commentId) return;
    $.ajax({
        url:'/v1/comments/'+commentId +'/delete',
        method:'GET',
        success:function (r) {
            console.log(r);
            if(r.code===GONE){
                //alert(r.message);
                //从评论数组里删除,已经被删除的评论
                comments.splice(index, 1);
            }else{
                alert(r.message);
            }
        }
    });
},

测试......

3.3 修改评论内容

如果评论内容不尽人意,自然就需要修改功能,修改的思路是将评论内容放到编辑框中进行编辑,在点击保存按钮时候,将修改后的评论发送到服务器中,服务器控制器配合业务层将数据更新到数据库。

具体步骤如下:

首先更新在detil.html的页面模板,在点击修改功能时候,展开修改编辑框:

<!--触发打开编辑区域的按钮-->
<a class="text-primary ml-2" data-toggle="collapse" href=" #editComment1"
   role="button" aria-expanded="false" aria-controls="collapseExample"
   v-bind:href="'#editComment' +comment.id">
    <i class="fa fa-edit"></i>
    <smalL>编辑</small>
</a>

编辑评论区域,这个区域默认是折叠起来的,并且预先将需要编辑的内容已经填充到这个textarea元素中了:

<div class="collapse" id="editCommemt1" v-bind:id="'editComment'+comment.id">
    <div class="card card-body border-light">
        <form action="" method="post" class="needs-validation" novalidate
              @submit.prevent="updateComment(comment.id, answer.id, index, answer.comments)">
            <div class="form-group">
                <textarea class="form-control"
                          id="textareaComment1" name="content" rows="4"
                          required v-text="comment.content"></textarea>
                <div class="invalid-feedback">
                    内容不能为空!
                </div>
            </div>
            <button type="submit" class="btn btn-primary my-1 float-right">提交修改</button>
        </form>
    </div>
</div>

在detail.js 中编写事件处理方法,由于VUE无法监听使用方括号comments[index]=comment更改数组内容,所以需要使用Vue. set(comments, index, r.data)主动触发VUE视图更新:

updateComment:function (commentId, answerId, index, comments) {
    let textarea = $('#editComment'+commentId+' textarea');
    let content=textarea.val();
    if(! content) return;
    let  form={
        answerId:answerId,
        content:content
    };
    $.ajax({
        url:'/v1/comments/'+commentId+'/update',
        method:'POST',
        data:form,
        success:function (r) {
            console.log(r);
            if (r.code===OK){
                //更新 comments 数组中的数据,自动刷新界面
                Vue.set(comments, index,r.data);
                $('#editComment'+commentId).collapse("hide");
                //alert(r.message);
            }else{
                alert(r.message);
            }
        }
    });
},

在CommentController编写控制器方法处理更新请求:

@PostMapping("/{id}/update")
public R updateComment(@PathVariable Integer id,
                       @Validated CommentVo commentVo, BindingResult result,
                       @AuthenticationPrincipal UserDetails user){
    if(result.hasErrors()) {
        String message = result.getFieldError().getDefaultMessage();
        return R.unprocesabelEntity(message);
    }
    log.debug("{}",commentVo);
    return R.ok("修改完成");
}

测试......

编写业务层实现更新功能,首先在ICommentSerivce声明业务层方法:

    /**
     * 更新一个评论
     * @param commentId 评论的id
     * @param commentVo 评论的内容
     * @param username 发起修改评论的用户
     * @return 修改好的评论
     */
Comment updateComment(Integer commentId,CommentVo commentVo, String username);

定义一个Keys.java类,封装系统中经常使用的常量,这样可以增强程序代码的可读性:

package cn.tedu.straw.portal.util;

public class Keys {
    /**
     * 声明用户的type,1的时候是回答问题老师
     */
    public static final Integer MASTER=1;
}

然后在ComnentSericempl中实现方法:

@Override
public boolean removeComment(Integer commentId, CommentVo commentVo, String username) {
    User user = userMapper.finduserByUsername(username);
    Comment comment = commentMapper.selectById(commentId);
    //是讲师或者自己,就可以更新内容
    if(user.getType().equals(Keys.MASTER)||
       comment.getUserId().equals(user. getId())){
        comment.setContent(commentVo.getContent());
        int rows = commentMapper.updateById(comment) ;
        if (rows != 1){
            throw ServiceException.busy();
        }
        return comment;
    }
    throw new ServiceException("权限不足");
}

更新控制器CommentController

@PostMapping("/{id}/update")
public R updateComment(@PathVariable Integer id,
                       @Validated CommentVo commentVo, BindingResult result,
                       @AuthenticationPrincipal UserDetails user){
    if(result.hasErrors()) {
        String message = result.getFieldError().getDefaultMessage();
        return R.unprocesabelEntity(message);
    }
    log.debug("CommentVo{}",commentVo);
    Comment  comment=commentService.updateComment(id, commentVo, user.getUsername());
    return R.ok(comment);
}

整合测试......

posted @ 2022-04-12 00:04  指尖上的未来  阅读(21)  评论(0)    收藏  举报