> 文章列表 > vue.js实现带表情评论功能前后端实现(仿B站评论)

vue.js实现带表情评论功能前后端实现(仿B站评论)

vue.js实现带表情评论功能前后端实现(仿B站评论)

文章目录

    • 学习链接
    • 效果图
    • 后台
      • 建表
        • 评论表(重要)
        • 用户
      • 实体类
        • Comment
        • User
        • CommentDTO(重要)
      • WebConfig配置
        • 配置跨域和静态资源文件夹
      • Mybatisplus相关类
        • MyBatisPlusConfig 配置分页插件
        • CommentMapper
        • CommentMapper.xml(非常重要,含分析)
        • CommentServiceImpl
      • 评论接口
        • CommentController
        • PageUtils
        • Result
        • getCommentListByPage接口返回示例
        • getReplyListByPage接口返回示例
      • 其它相关类和配置
        • 启动类
        • application.yml
        • pom.xml
    • 前台
      • 项目配置相关
        • pakcage.json
        • main.js
        • router.js
        • request.js
        • commentApi.js
      • EmojiText.vue组件
        • emoji.json
        • EmojiText.vue
      • Comment.vue组件
        • Comment.vue
      • Reply.vue组件
        • Reply.vue
      • App.vue组件

学习链接

程序员老罗B站论坛项目视频
JS操作文本域获取光标/指定位置插入
vue.js支持表情输入
vue.js表情文本输入框组件
ttkwsd博客
风宇博客(链接已挂)

效果图

vue.js实现带表情评论功能前后端实现(仿B站评论)

后台

建表

评论表(重要)

  • 评论分为 评论对评论的回复对评论的回复的回复,它们都放在Comment表中
  • 评论分为一级评论(它是对某个模块的顶级评论,比如对某篇文章的评论),和 二级评论(它是对一级评论的回复,也可以是对一级评论的回复的回复)
  • 反正就是只有2级评论,一级评论的parent为null,一级评论下的所有回复的parentId就是该回复所对应的一级评论的id
  • 二级评论可能是对顶级评论的评论(这个时候它是没有reply_comment_id,它只有parentId,这样来记录它是对顶级评论的评论),也可能是对二级评论的一个回复(这个时候要记录该回复是对哪个评论进行的回复,即reply_comment_id。和在哪个一级评论下的,即parentId)。
    -简而言之: parentId记录的是在哪个一级评论下,reply_comment_id记录里的是对哪个二级评论进行的回复
CREATE TABLE `comment` (`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '评论id',`parent_id` int(11) DEFAULT NULL COMMENT '父级评论id',`reply_user_id` int(11) DEFAULT NULL COMMENT '回复用户id',`reply_comment_id` int(11) DEFAULT NULL COMMENT '回复的评论的id',`user_id` int(11) DEFAULT NULL COMMENT '评论用户id',`comment_content` longtext COMMENT '评论内容',`create_time` datetime DEFAULT NULL COMMENT '创建时间',`like_num` int(11) DEFAULT NULL COMMENT '点赞量',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=69 DEFAULT CHARSET=utf8mb4;
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (28, NULL, NULL, NULL, 3, '<img class=\\"emoji-pic\\" src=\\"/emoji/jingxi.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/jingxi.png\\"  style=\\"width:20px;height:20\\"/>快来,快来,沙发哦', '2023-04-14 19:59:50', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (30, NULL, NULL, NULL, 1, '没人来,我可要撤了<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/yihuo.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/yihuo.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/yihuo.png\\"  style=\\"width:20px;height:20\\"/>', '2023-04-14 20:01:36', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (31, 30, 1, 30, 3, '别没事瞎逼逼ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooohhhhhhhhhhhhhhhhh~', '2023-04-14 20:02:18', 4);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (32, 30, 3, 31, 1, '<a href=\\"#\\" class=\\"reply-to-user\\">@zj :</a>你在搞什么<img class=\\"emoji-pic\\" src=\\"/emoji/koubi.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/koubi.png\\" />新花样', '2023-04-14 20:02:53', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (33, 30, 1, 32, 2, '<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a>你写的代码可真棒(๑•̀ㅂ•́)و✧<img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" />', '2023-04-14 20:05:55', 2);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (34, 30, 2, 33, 1, '<a href=\\"#\\" class=\\"reply-to-user\\">@ls :</a>怎么?你有意见吗<img class=\\"emoji-pic\\" src=\\"/emoji/xusheng.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/xusheng.png\\" />', '2023-04-14 20:06:37', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (35, 30, 1, 30, 3, '<img class=\\"emoji-pic\\" src=\\"/emoji/jiayou.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/jiayou.png\\" />', '2023-04-14 20:39:09', 7);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (36, 30, 3, 35, 3, '<a href=\\"#\\" class=\\"reply-to-user\\">@zj :</a>aa', '2023-04-14 20:39:46', 3);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (37, 28, 3, 28, 1, '你真可爱<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\" />', '2023-04-14 20:41:14', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (38, 28, 1, 37, 3, '<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a><img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" />别这么说嘛', '2023-04-14 20:44:08', 12);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (39, NULL, NULL, NULL, 2, '来个热评??<img class=\\"emoji-pic\\" src=\\"/emoji/xxy.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/xxy.png\\"  style=\\"width:20px;height:20\\"/>', '2023-04-14 20:44:42', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (40, 39, 2, 39, 1, 'ojdk<img class=\\"emoji-pic\\" src=\\"/emoji/tiaopi.png\\" />', '2023-04-14 20:45:07', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (41, NULL, NULL, NULL, 3, '现在<img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/>好像没什么人了吧', '2023-04-14 20:45:31', 6);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (50, 41, 3, 41, 1, '原来是没重启呀,mybatis它不帮我影射了<img class=\\"emoji-pic\\" src=\\"/emoji/wuyu.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/wuyu.png\\" />', '2023-04-15 20:48:01', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (51, 41, 1, 50, 2, '<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a>还是你太菜了呀<img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" />', '2023-04-15 20:48:27', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (52, 41, 2, 51, 1, '<a href=\\"#\\" class=\\"reply-to-user\\">@ls :</a><img class=\\"emoji-pic\\" src=\\"/emoji/daku.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/daku.png\\" />', '2023-04-15 20:48:50', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (53, 41, 1, 52, 3, '<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a><img class=\\"emoji-pic\\" src=\\"/emoji/yinxian.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/yinxian.png\\" />摸摸头', '2023-04-15 20:49:07', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (54, 41, 3, 41, 1, '还在吗,亲<img class=\\"emoji-pic\\" src=\\"/emoji/geixx.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/geixx.png\\" />', '2023-04-15 20:49:48', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (55, 41, 1, 54, 3, '<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a><img class=\\"emoji-pic\\" src=\\"/emoji/sikao.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/sikao.png\\" />干哈', '2023-04-15 20:50:06', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (56, 39, 1, 40, 3, '<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a><img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" />说啥呢', '2023-04-15 21:00:07', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (57, 30, 2, 33, 1, '<a href=\\"#\\" class=\\"reply-to-user\\">@ls :</a>就说你不信吧<img class=\\"emoji-pic\\" src=\\"/emoji/fanby.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/fanby.png\\" />', '2023-04-15 08:36:14', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (58, 30, 3, 31, 2, '<a href=\\"#\\" class=\\"reply-to-user\\">@zj :</a>子评论超过一页,如果在子评论的第一页评论的话,当前用户的评论会添加到第一页的末尾,此时,第一页数据超过5个子评论的数量,这是为了让用户能够直观的看到自己的评论<img class=\\"emoji-pic\\" src=\\"/emoji/hqian.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/hqian.png\\" />,但实际上,用户的评论不应该在第一页,而应该排在最后面。当用户翻页的时候,就是正常的排序了,每页5条,按时间升序<img class=\\"emoji-pic\\" src=\\"/emoji/sikao.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/sikao.png\\" />', '2023-04-15 08:42:07', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (59, NULL, NULL, NULL, 3, '怪不得这两天降温呢,原来冰冰更新了<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/>', '2023-04-15 08:47:35', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (60, NULL, NULL, NULL, 2, '大宋四大雅事:\\n高粱河畔驴车坐;\\n靖康年间东京呆;\\n风波亭外莫须有;\\n襄阳城墙望援兵。<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/>', '2023-04-15 08:48:47', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (61, NULL, NULL, NULL, 3, '更新啦<img class=\\"emoji-pic\\" src=\\"/emoji/geixx.png\\"  style=\\"width:20px;height:20\\"/>', '2023-04-15 08:52:51', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (62, NULL, NULL, NULL, 3, '刚刚的bug怎么复现呢<img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/>', '2023-04-15 09:02:12', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (63, 59, 3, 59, 1, '高粱河畔驴车坐; \\n靖康年间东京呆; \\n风波亭外莫须有; \\n襄阳城墙望援兵。\\n-- 好湿好湿<img class=\\"emoji-pic\\" src=\\"/emoji/lianhong.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/lianhong.png\\" />', '2023-04-15 09:02:59', NULL);
INSERT INTO `vue-springboot`.`comment` (`id`, `parent_id`, `reply_user_id`, `reply_comment_id`, `user_id`, `comment_content`, `create_time`, `like_num`) VALUES (68, 60, 2, 60, 1, '你的咋没换行呢,真low<img class=\\"emoji-pic\\" src=\\"/emoji/guaji.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/guaji.png\\" /><br/>宋四大雅事: <br/>高粱河畔驴车坐; <br/>靖康年间东京呆; <br/>风波亭外莫须有; <br/>襄阳城墙望援兵。', '2023-04-15 09:32:51', NULL);

用户表

用户头像地址,默认放在了 resource/avatar/ 目录下,使用springMvc做静态资源映射。也可以使用nginx将该目录作为静态资源目录。

CREATE TABLE `user` (`id` int(11) NOT NULL AUTO_INCREMENT,`nickname` varchar(20) DEFAULT NULL,`is_v` int(11) DEFAULT NULL COMMENT '0,1',`avatar_url` varchar(255) DEFAULT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4;
INSERT INTO `vue-springboot`.`user` (`id`, `nickname`, `is_v`, `avatar_url`) VALUES (1, 'zzhua195', 1, 'http://localhost:8084/avatar/fl4.png');
INSERT INTO `vue-springboot`.`user` (`id`, `nickname`, `is_v`, `avatar_url`) VALUES (2, 'ls', 0, 'http://localhost:8084/avatar/fl7_60.png');
INSERT INTO `vue-springboot`.`user` (`id`, `nickname`, `is_v`, `avatar_url`) VALUES (3, 'zj', 0, 'http://localhost:8084/avatar/fl9.png');

实体类

Comment

package com.zzhua.entity;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 java.util.Date;import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;@Data
@TableName(value = "`comment`")
public class Comment {/*** 评论id*/@TableId(type = IdType.AUTO)private Integer id;/*** 父级评论id(顶级评论为null)*/@TableField(value = "parent_id")private Integer parentId;@TableField(value = "reply_comment_id")private Integer replyCommentId;/*** 回复用户id*/@TableField(value = "reply_user_id")private Integer replyUserId;/*** 评论人id*/@TableField(value = "user_id")private Integer userId;/*** 评论内容*/@TableField(value = "comment_content")private String commentContent;/*** 创建时间*/@TableField(value = "create_time")@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")private Date createTime;/*** 点赞量*/@TableField(value = "like_num")private Integer likeNum;
}

User

package com.zzhua.entity;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;@Data
@TableName(value = "`user`")
public class User {@TableId(type = IdType.AUTO)private Integer id;@TableField(value = "nickname")private String nickname;/*** 0,1*/@TableField(value = "is_v")private Integer isV;@TableField(value = "avatar_url")private Integer avatarUrl;
}

CommentDTO(重要)

package com.zzhua.dto;import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;import java.util.Date;
import java.util.List;@Data
public class CommentDTO {private Integer replyTotalCount; // 一级评论下的回复数量private List<CommentDTO> children; // 一级评论下的所有回复private String nickname; // 用户昵称private String isV; // 是否V认证private String replyUserNickname; // 回复的是哪个用户(ta的昵称)private Integer replyCommentId; // 对那条评论进行的回复(对一级评论作回复, 不记录该replyCommentId)/*** 评论id*/private Integer id;/*** 父级评论id(顶级评论为null)*/private Integer parentId;/*** 回复用户id*/private Integer replyUserId;/*** 评论人id*/private Integer userId;/*** 评论内容*/private String commentContent;/*** 创建时间*/@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")private Date createTime;/*** 点赞量*/private Integer likeNum;private String avatarUrl; // 用户头像地址}

WebConfig配置

配置跨域和静态资源文件夹

package com.zzhua.config;import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration
public class WebConfig implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").maxAge(3600).allowCredentials(true).allowedOrigins("*").allowedMethods("*").allowedHeaders("*").exposedHeaders("token","Authorization");}@Overridepublic void addResourceHandlers(ResourceHandlerRegistry registry) {registry.addResourceHandler("/img/**").addResourceLocations("file:/D:\\\\Projects\\\\vue-springboot\\\\src\\\\main\\\\resources\\\\static\\\\img\\\\","file:/D:\\\\Projects\\\\vue-springboot\\\\src\\\\main\\\\resources\\\\static\\\\avatar\\\\");}
}

Mybatisplus相关类

MyBatisPlusConfig 配置分页插件

@Configuration
public class MyBatisPlusConfig {@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));return interceptor;}}

CommentMapper

package com.zzhua.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.zzhua.dto.CommentDTO;
import com.zzhua.entity.Comment;
import org.apache.ibatis.annotations.Param;import java.util.List;public interface CommentMapper extends BaseMapper<Comment> {IPage<CommentDTO> queryPage(@Param("page") IPage<Comment> page);List<CommentDTO> queryChildrenByPage(@Param("startIndex") Integer startIndex, @Param("count") Integer count, @Param("commentId") Integer commentId);CommentDTO getSingleComment(@Param("id") Integer id);
}

CommentMapper.xml(非常重要,含分析)

  • queryPage 先分页查询顶级评论,然后通过嵌套的select查询,来查询每一条顶级评论下的回复数量,和 该顶级评论下的第一页的评论(默认页大小是5),但是前端只展示第一页的前3条,当用户点击查看更多回复时,由于前端已经知道了一共有多少条数据,所以前端能正确展示分页(其实就是前端在拿到后台返回的数据后,以前是根据返回的数据通过js操作dom->根据数据组装dom然后把dom插入到容器里面以替换原先的dom。但是现在只需要给到vue,由vue去操作dom,现在->拿到后台返回的数据后,修改vue组件data中的数据,它会拦截这个修改,去重新编译模板,它里面可能有优化,并且vue肯定知道模板的哪些地方用到了这个数据(响应式数据),然后再更新dom,这是vue帮助我们完成的),并且此时是不需要查询后台的,把第一页的数据的后两条展示出来就行了(前端的计算属性比较适合做这件事),后面,把顶级评论的id传过来,按照分页查询条件来查询即可
  • queryChildrenByPage 分页查询某个顶级评论下的回复
  • getSingleComment 前端调用完添加完评论这个接口之后,这个接口应当把添加的这条评论返回给前端(前端提交的新增评论,肯定是没有id的,就需要后台添加好之后,把id和设置的创建时间设置进去,返回给前端,前端需要这个评论的id!!!),前端拿到这个评论后,直接就添加在评论的最后面(必须要新增评论的id),而不是重新发起请求-来请求这一页的数据,当然,这样就会是前端的分页参数是当前有5条,但是却显示了6条,但这是无关紧要的,B站就是这么做的,因为下一次点击分页,只要把分页参数传过来,就依然按照分页参数来查询。这样做的目的就是让用户评论的时候,能够直观的看到,刚刚发表的评论展现出来了。但是点击分页之后,再返回之前所在分页发现自己的评论刚刚还在,现在却没了,跑到最后那页的最后一条数据了。
<?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="com.zzhua.mapper.CommentMapper"><resultMap id="BaseResultMap" type="com.zzhua.entity.Comment"><!--@mbg.generated--><!--@Table `comment`--><result column="id" jdbcType="INTEGER" property="id" /><result column="parent_id" jdbcType="INTEGER" property="parentId" /><result column="reply_user_id" jdbcType="INTEGER" property="replyUserId" /><result column="user_id" jdbcType="INTEGER" property="userId" /><result column="comment_content" jdbcType="LONGVARCHAR" property="commentContent" /><result column="create_time" jdbcType="TIMESTAMP" property="createTime" /><result column="like_num" jdbcType="INTEGER" property="likeNum" /></resultMap><sql id="Base_Column_List"><!--@mbg.generated-->id, parent_id, reply_user_id, user_id, comment_content, create_time, like_num</sql><resultMap id="commentDTOMap" type="com.zzhua.dto.CommentDTO"><!-- mybatis下面用了id之后, 他就不会帮我们自动映射了, 需要手动添加这个, 否则返回的数据里面id为null --><result property="id" column="id"/><association property="replyTotalCount" javaType="java.lang.Integer"select="selectReplyTotalCount" column="id"/><collection property="children" ofType="com.zzhua.dto.CommentDTO"select="queryChildrenByPage" column="{commentId=id,startIndex=startIndex,count=count}"/></resultMap><select id="selectReplyTotalCount" resultType="int">select count(*) from comment c where c.parent_id = #{id}</select><select id="queryPage" resultMap="commentDTOMap">SELECT  c.id, parent_id, reply_user_id, user_id, comment_content, create_time, like_num ,u.avatar_url,c.reply_comment_id,u.is_v,u.nickname , u2.nickname as reply_user_nickname,0 as startIndex, 5 as `count`from comment cLeft join user u on c.user_id = u.idLeft join user u2 on c.reply_user_id = u2.idwhere c.parent_id is nullorderby c.create_time DESC</select><select id="queryChildrenByPage" resultType="com.zzhua.dto.CommentDTO">SELECT  c.id, parent_id, reply_user_id, user_id, comment_content, create_time, like_num ,c.reply_comment_id,u.is_v,u.nickname , u2.nickname as reply_user_nickname,u.avatar_urlfrom comment cLeft join user u on c.user_id = u.idLeft join user u2 on c.reply_user_id = u2.idwhere c.parent_id = #{commentId} order by c.create_time asc limit ${startIndex},${count}</select><select id="getSingleComment" resultType="com.zzhua.dto.CommentDTO">SELECT  c.id, parent_id, reply_user_id, user_id, comment_content, create_time, like_num ,u.avatar_url,c.reply_comment_id,u.is_v,u.nickname , u2.nickname as reply_user_nicknamefrom comment cLeft join user u on c.user_id = u.idLeft join user u2 on c.reply_user_id = u2.idwhere c.id = #{id}</select></mapper>

CommentServiceImpl

package com.zzhua.service.impl;import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.zzhua.dto.CommentDTO;
import com.zzhua.utils.PageUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zzhua.mapper.CommentMapper;
import com.zzhua.entity.Comment;
import com.zzhua.service.CommentService;
@Service
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService{@Overridepublic PageUtils<CommentDTO> getCommentListByPage(Integer pageNum, Integer pageSize) {IPage<CommentDTO> page = this.baseMapper.queryPage(new Page<>(pageNum, pageSize));return new PageUtils<>(page);}@Overridepublic CommentDTO addComment(Comment comment) {if (comment.getUserId() == null) {throw new RuntimeException("未设置用户di");}comment.setCreateTime(new Date());this.baseMapper.insert(comment);CommentDTO commentDTO = this.baseMapper.getSingleComment(comment.getId());return commentDTO;}@Overridepublic PageUtils<CommentDTO> getReplyListByPage(Integer pageNum, Integer pageSize, Integer commentId) {long count = this.count(new QueryWrapper<Comment>().lambda().eq(Comment::getParentId, commentId));PageUtils<CommentDTO> pageUtils = new PageUtils<>();pageUtils.setPageNum(pageNum);pageUtils.setPageSize(pageSize);pageUtils.setTotalCount(count);List<CommentDTO> commentDTOS = this.baseMapper.queryChildrenByPage((pageNum - 1) * pageSize, pageSize, commentId);pageUtils.setList(commentDTOS);return pageUtils;}
}

评论接口

CommentController

package com.zzhua.controller;import com.zzhua.dto.CommentDTO;
import com.zzhua.entity.Comment;
import com.zzhua.service.CommentService;
import com.zzhua.utils.PageUtils;
import com.zzhua.utils.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;import java.util.List;@RequestMapping("comment")
@RestController
public class CommentController {@Autowiredprivate CommentService commentService;@GetMapping("getCommentListByPage")public Result<PageUtils<CommentDTO>> getCommentListByPage(@RequestParam Integer pageNum, @RequestParam Integer pageSize) {return Result.ok(commentService.getCommentListByPage(pageNum, pageSize));}@GetMapping("getReplyListByPage")public Result<PageUtils<CommentDTO>> getReplyListByPage(@RequestParam Integer pageNum, @RequestParam Integer pageSize, @RequestParam Integer commentId) {return Result.ok(commentService.getReplyListByPage(pageNum, pageSize,commentId));}@PostMapping("addComment")public Result<CommentDTO> addComment(@RequestBody Comment comment) {return Result.ok(commentService.addComment(comment));}}

PageUtils

package com.zzhua.utils;import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;import java.util.List;@Data
public class PageUtils<T> {private long pageNum;private long pageSize;private long totalCount;private List<T> list;public PageUtils(IPage<T> page) {this.list = page.getRecords();this.totalCount = page.getTotal();this.pageNum = page.getCurrent();this.pageSize = page.getSize();}public PageUtils() {}
}

Result

package com.zzhua.utils;import lombok.Data;
import org.apache.commons.codec.digest.DigestUtils;import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;@Data
public class Result<T> {private Integer code;private String msg;private T data;public static <T> Result ok(T data) {Result r = new Result();r.setCode(0);r.setData(data);return r;}public static Result fail(String msg ,Integer code) {Result r = new Result();r.setCode(code);r.setMsg(msg);return r;}}

getCommentListByPage接口返回示例

{"code":0,"msg":null,"data":{"pageNum":1,"pageSize":100,"totalCount":8,"list":[{"replyTotalCount":0,"children":[],"nickname":"zj","isV":"0","replyUserNickname":null,"replyCommentId":null,"id":62,"parentId":null,"replyUserId":null,"userId":3,"commentContent":"刚刚的bug怎么复现呢<img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/>","createTime":"2023-04-15 09:02:12","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl9.png"},{"replyTotalCount":0,"children":[],"nickname":"zj","isV":"0","replyUserNickname":null,"replyCommentId":null,"id":61,"parentId":null,"replyUserId":null,"userId":3,"commentContent":"更新啦<img class=\\"emoji-pic\\" src=\\"/emoji/geixx.png\\"  style=\\"width:20px;height:20\\"/>","createTime":"2023-04-15 08:52:51","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl9.png"},{"replyTotalCount":1,"children":[{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"ls","replyCommentId":60,"id":68,"parentId":60,"replyUserId":2,"userId":1,"commentContent":"你的咋没换行呢,真low<img class=\\"emoji-pic\\" src=\\"/emoji/guaji.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/guaji.png\\" /><br/>宋四大雅事: <br/>高粱河畔驴车坐; <br/>靖康年间东京呆; <br/>风波亭外莫须有; <br/>襄阳城墙望援兵。","createTime":"2023-04-15 09:32:51","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"}],"nickname":"ls","isV":"0","replyUserNickname":null,"replyCommentId":null,"id":60,"parentId":null,"replyUserId":null,"userId":2,"commentContent":"大宋四大雅事:\\n高粱河畔驴车坐;\\n靖康年间东京呆;\\n风波亭外莫须有;\\n襄阳城墙望援兵。<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/>","createTime":"2023-04-15 08:48:47","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl7_60.png"},{"replyTotalCount":1,"children":[{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"zj","replyCommentId":59,"id":63,"parentId":59,"replyUserId":3,"userId":1,"commentContent":"高粱河畔驴车坐; \\n靖康年间东京呆; \\n风波亭外莫须有; \\n襄阳城墙望援兵。\\n-- 好湿好湿<img class=\\"emoji-pic\\" src=\\"/emoji/lianhong.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/lianhong.png\\" />","createTime":"2023-04-15 09:02:59","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"}],"nickname":"zj","isV":"0","replyUserNickname":null,"replyCommentId":null,"id":59,"parentId":null,"replyUserId":null,"userId":3,"commentContent":"怪不得这两天降温呢,原来冰冰更新了<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/>","createTime":"2023-04-15 08:47:35","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl9.png"},{"replyTotalCount":6,"children":[{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"zj","replyCommentId":41,"id":50,"parentId":41,"replyUserId":3,"userId":1,"commentContent":"原来是没重启呀,mybatis它不帮我影射了<img class=\\"emoji-pic\\" src=\\"/emoji/wuyu.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/wuyu.png\\" />","createTime":"2023-04-15 20:48:01","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":null,"children":null,"nickname":"ls","isV":"0","replyUserNickname":"zzhua195","replyCommentId":50,"id":51,"parentId":41,"replyUserId":1,"userId":2,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a>还是你太菜了呀<img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" />","createTime":"2023-04-15 20:48:27","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl7_60.png"},{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"ls","replyCommentId":51,"id":52,"parentId":41,"replyUserId":2,"userId":1,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@ls :</a><img class=\\"emoji-pic\\" src=\\"/emoji/daku.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/daku.png\\" />","createTime":"2023-04-15 20:48:50","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":null,"children":null,"nickname":"zj","isV":"0","replyUserNickname":"zzhua195","replyCommentId":52,"id":53,"parentId":41,"replyUserId":1,"userId":3,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a><img class=\\"emoji-pic\\" src=\\"/emoji/yinxian.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/yinxian.png\\" />摸摸头","createTime":"2023-04-15 20:49:07","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl9.png"},{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"zj","replyCommentId":41,"id":54,"parentId":41,"replyUserId":3,"userId":1,"commentContent":"还在吗,亲<img class=\\"emoji-pic\\" src=\\"/emoji/geixx.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/geixx.png\\" />","createTime":"2023-04-15 20:49:48","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"}],"nickname":"zj","isV":"0","replyUserNickname":null,"replyCommentId":null,"id":41,"parentId":null,"replyUserId":null,"userId":3,"commentContent":"现在<img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/xiaoku.png\\"  style=\\"width:20px;height:20\\"/>好像没什么人了吧","createTime":"2023-04-14 20:45:31","likeNum":6,"avatarUrl":"http://localhost:8084/avatar/fl9.png"},{"replyTotalCount":2,"children":[{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"ls","replyCommentId":39,"id":40,"parentId":39,"replyUserId":2,"userId":1,"commentContent":"ojdk<img class=\\"emoji-pic\\" src=\\"/emoji/tiaopi.png\\" />","createTime":"2023-04-14 20:45:07","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":null,"children":null,"nickname":"zj","isV":"0","replyUserNickname":"zzhua195","replyCommentId":40,"id":56,"parentId":39,"replyUserId":1,"userId":3,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a><img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/ganga.png\\" />说啥呢","createTime":"2023-04-15 21:00:07","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl9.png"}],"nickname":"ls","isV":"0","replyUserNickname":null,"replyCommentId":null,"id":39,"parentId":null,"replyUserId":null,"userId":2,"commentContent":"来个热评??<img class=\\"emoji-pic\\" src=\\"/emoji/xxy.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/xxy.png\\"  style=\\"width:20px;height:20\\"/>","createTime":"2023-04-14 20:44:42","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl7_60.png"},{"replyTotalCount":8,"children":[{"replyTotalCount":null,"children":null,"nickname":"zj","isV":"0","replyUserNickname":"zzhua195","replyCommentId":30,"id":31,"parentId":30,"replyUserId":1,"userId":3,"commentContent":"别没事瞎逼逼ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooohhhhhhhhhhhhhhhhh~","createTime":"2023-04-14 20:02:18","likeNum":4,"avatarUrl":"http://localhost:8084/avatar/fl9.png"},{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"zj","replyCommentId":31,"id":32,"parentId":30,"replyUserId":3,"userId":1,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zj :</a>你在搞什么<img class=\\"emoji-pic\\" src=\\"/emoji/koubi.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/koubi.png\\" />新花样","createTime":"2023-04-14 20:02:53","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":null,"children":null,"nickname":"ls","isV":"0","replyUserNickname":"zzhua195","replyCommentId":32,"id":33,"parentId":30,"replyUserId":1,"userId":2,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a>你写的代码可真棒(๑•̀ㅂ•́)و✧<img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" />","createTime":"2023-04-14 20:05:55","likeNum":2,"avatarUrl":"http://localhost:8084/avatar/fl7_60.png"},{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"ls","replyCommentId":33,"id":34,"parentId":30,"replyUserId":2,"userId":1,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@ls :</a>怎么?你有意见吗<img class=\\"emoji-pic\\" src=\\"/emoji/xusheng.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/xusheng.png\\" />","createTime":"2023-04-14 20:06:37","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":null,"children":null,"nickname":"zj","isV":"0","replyUserNickname":"zzhua195","replyCommentId":30,"id":35,"parentId":30,"replyUserId":1,"userId":3,"commentContent":"<img class=\\"emoji-pic\\" src=\\"/emoji/jiayou.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/jiayou.png\\" />","createTime":"2023-04-14 20:39:09","likeNum":7,"avatarUrl":"http://localhost:8084/avatar/fl9.png"}],"nickname":"zzhua195","isV":"1","replyUserNickname":null,"replyCommentId":null,"id":30,"parentId":null,"replyUserId":null,"userId":1,"commentContent":"没人来,我可要撤了<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/yihuo.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/yihuo.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/yihuo.png\\"  style=\\"width:20px;height:20\\"/>","createTime":"2023-04-14 20:01:36","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":2,"children":[{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"zj","replyCommentId":28,"id":37,"parentId":28,"replyUserId":3,"userId":1,"commentContent":"你真可爱<img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/doge.png\\" />","createTime":"2023-04-14 20:41:14","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":null,"children":null,"nickname":"zj","isV":"0","replyUserNickname":"zzhua195","replyCommentId":37,"id":38,"parentId":28,"replyUserId":1,"userId":3,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zzhua195 :</a><img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/wuyan.png\\" />别这么说嘛","createTime":"2023-04-14 20:44:08","likeNum":12,"avatarUrl":"http://localhost:8084/avatar/fl9.png"}],"nickname":"zj","isV":"0","replyUserNickname":null,"replyCommentId":null,"id":28,"parentId":null,"replyUserId":null,"userId":3,"commentContent":"<img class=\\"emoji-pic\\" src=\\"/emoji/jingxi.png\\"  style=\\"width:20px;height:20\\"/><img class=\\"emoji-pic\\" src=\\"/emoji/jingxi.png\\"  style=\\"width:20px;height:20\\"/>快来,快来,沙发哦","createTime":"2023-04-14 19:59:50","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl9.png"}]}
}

getReplyListByPage接口返回示例

{"code":0,"msg":null,"data":{"pageNum":2,"pageSize":5,"totalCount":8,"list":[{"replyTotalCount":null,"children":null,"nickname":"zj","isV":"0","replyUserNickname":"zj","replyCommentId":35,"id":36,"parentId":30,"replyUserId":3,"userId":3,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zj :</a>aa","createTime":"2023-04-14 20:39:46","likeNum":3,"avatarUrl":"http://localhost:8084/avatar/fl9.png"},{"replyTotalCount":null,"children":null,"nickname":"zzhua195","isV":"1","replyUserNickname":"ls","replyCommentId":33,"id":57,"parentId":30,"replyUserId":2,"userId":1,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@ls :</a>就说你不信吧<img class=\\"emoji-pic\\" src=\\"/emoji/fanby.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/fanby.png\\" />","createTime":"2023-04-15 08:36:14","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl4.png"},{"replyTotalCount":null,"children":null,"nickname":"ls","isV":"0","replyUserNickname":"zj","replyCommentId":31,"id":58,"parentId":30,"replyUserId":3,"userId":2,"commentContent":"<a href=\\"#\\" class=\\"reply-to-user\\">@zj :</a>子评论超过一页,如果在子评论的第一页评论的话,当前用户的评论会添加到第一页的末尾,此时,第一页数据超过5个子评论的数量,这是为了让用户能够直观的看到自己的评论<img class=\\"emoji-pic\\" src=\\"/emoji/hqian.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/hqian.png\\" />,但实际上,用户的评论不应该在第一页,而应该排在最后面。当用户翻页的时候,就是正常的排序了,每页5条,按时间升序<img class=\\"emoji-pic\\" src=\\"/emoji/sikao.png\\" /><img class=\\"emoji-pic\\" src=\\"/emoji/sikao.png\\" />","createTime":"2023-04-15 08:42:07","likeNum":null,"avatarUrl":"http://localhost:8084/avatar/fl7_60.png"}]}
}

其它相关类和配置

启动类

package com.zzhua;import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
@MapperScan("com.zzhua.mapper")
public class VueApp {public static void main(String[] args) {SpringApplication.run(VueApp.class);}
}

application.yml

server:port: 8084spring:datasource:type: com.zaxxer.hikari.HikariDataSourceurl: jdbc:mysql://localhost:3306/vue-springboot?serverTimezone=Asia/Shanghai&useSSL=falseusername: rootpassword: rootdriver-class-name: com.mysql.jdbc.Driverservlet:multipart:max-file-size: 50MBmax-request-size: 50MBmybatis-plus:mapper-locations: classpath:/mapper/**.xml

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.1.8.RELEASE</version></parent><groupId>com.zzhua</groupId><artifactId>vue-springboot</artifactId><version>1.0-SNAPSHOT</version><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>5.1.47</version></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.1</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><dependency><groupId>junit</groupId><artifactId>junit</artifactId><version>4.11</version><scope>test</scope></dependency></dependencies></project>

前台

项目配置相关

pakcage.json

{"name": "vue-prism","version": "0.1.0","private": true,"scripts": {"serve": "vue-cli-service serve","build": "vue-cli-service build"},"dependencies": {"@wangeditor/editor": "^5.1.23","@wangeditor/editor-for-vue": "^1.0.2","animate.css": "^4.1.1","axios": "^1.3.5","clipboard": "^2.0.11","core-js": "^3.8.3","element-ui": "^2.15.13","highlight.js": "^11.7.0","markdown-it": "^13.0.1","markdown-it-abbr": "^1.0.4","markdown-it-container": "^3.0.0","markdown-it-deflist": "^2.1.0","markdown-it-emoji": "^2.0.2","markdown-it-footnote": "^3.0.3","markdown-it-ins": "^3.0.1","markdown-it-katex-external": "^1.0.0","markdown-it-mark": "^3.0.1","markdown-it-sub": "^1.0.0","markdown-it-sup": "^1.0.0","markdown-it-task-lists": "^2.1.1","sass": "^1.61.0","sass-loader": "^13.2.2","tocbot": "^4.21.0","vue": "^2.6.14","vue-router": "^3.5.1"},"devDependencies": {"@vue/cli-plugin-babel": "~5.0.0","@vue/cli-plugin-router": "~5.0.0","@vue/cli-service": "~5.0.0","babel-plugin-prismjs": "^2.1.0","prismjs": "^1.29.0","vue-template-compiler": "^2.6.14"},"browserslist": ["> 1%","last 2 versions","not dead"]
}

main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import '@/assets/css/base.css'
import 'prismjs'import Toast from '@/components/Toast.js'import '@/assets/iconfont/iconfont.css'import 'animate.css'Vue.config.productionTip = falseVue.use(ElementUI);Vue.use(Toast)import lazyLoadImage from './utils/lazyLoadImage'
const defaultImage=require('@/assets/loading.gif')//默认占位图片
Vue.use(lazyLoadImage,defaultImage)import prevImg from './plugins/prevImg';new Vue({router,render: h => h(App)
}).$mount('#app')

router.js

import Vue from 'vue'
import VueRouter from 'vue-router'Vue.use(VueRouter)const routes = [{name: 'default',path:'/',redirect: '/comment'},{name: 'comment',path:'/comment',component: () => import('@/views/Comment.vue')},]const router = new VueRouter({mode: 'history',routes
})export default router

request.js

import axios from 'axios'
import router from '@/router'const instance = axios.create({baseURL: 'http://localhost:8084',timeout: 60000,withCredentials: true /* 需要设置这个选项,axios发送请求时,才会携带cookie, 否则不会携带 */
})// Add a request interceptor
instance.interceptors.request.use(function (config) {// Do something before request is sentreturn config;}, function (error) {// Do something with request errorreturn Promise.reject(error);});// Add a response interceptor
instance.interceptors.response.use(function (response) {// Any status code that lie within the range of 2xx cause this function to trigger// Do something with response dataconsole.log('收到响应',response);if(response.data.code == 401) {router.push('/login')}return response.data.data;}, function (error) {// Any status codes that falls outside the range of 2xx cause this function to trigger// Do something with response errorreturn Promise.reject(error);});export default instance

commentApi.js

import request from '@/utils/request'// 分页查询顶级评论
export function getCommentListByPage(params) {return request({method: 'GET',url: `/comment/getCommentListByPage`,params})
}// 分页查询顶级评论下的回复
export function getReplyListByPage(params) {return request({method: 'GET',url: `/comment/getReplyListByPage`,params})
}// 添加评论
export function addComment(data) {return request({method: 'POST',url: `/comment/addComment`,data})
}

EmojiText.vue组件

详细可参考:vue.js表情文本输入框组件

emoji.json

{"[酸了]" : "/emoji/suanle.png","[捂脸]" : "/emoji/wulian.png","[支持]" : "/emoji/zhichi.png","[生气]" : "/emoji/shengqi.png","[捂眼]" : "/emoji/wuyan.png","[难过]" : "/emoji/nanguo.png","[无语]" : "/emoji/wuyu.png","[偷笑]" : "/emoji/touxiao.png","[tv_微笑]" : "/emoji/tvwx.png","[嗑瓜子]" : "/emoji/kgz.png","[原神_喝茶]" : "/emoji/hecha.png","[笑]" : "/emoji/xiao.png","[撇嘴]" : "/emoji/piezui.png","[点赞]" : "/emoji/dianzan.png","[干杯]" : "/emoji/ganbei.png","[tv_斜眼笑]" : "/emoji/tvxyx.png","[大笑]" : "/emoji/daxiao.png","[拥抱]" : "/emoji/yongbao.png","[歪嘴]" : "/emoji/waizui.png","[星星眼]" : "/emoji/xxy.png","[脱单doge]" : "/emoji/doge.png","[再见]" : "/emoji/zaijian.png","[热]" : "/emoji/re.png","[翻白眼]" : "/emoji/fanby.png","[尴尬]" : "/emoji/ganga.png","[笑哭]" : "/emoji/xiaoku.png","[doge]" : "/emoji/doge.png","[抱拳]" : "/emoji/baoquan.png","[冷]" : "/emoji/leng.png","[喜欢]" : "/emoji/xihuan.png","[委屈]" : "/emoji/weiqu.png","[疑惑]" : "/emoji/yihuo.png","[原神_嗯]" : "/emoji/en.png","[呲牙]" : "/emoji/ciya.png","[调皮]" : "/emoji/tiaopi.png","[疼]" : "/emoji/teng.png","[生病]" : "/emoji/shengbing.png","[嘟嘟]" : "/emoji/dudu.png","[灵魂出窍]" : "/emoji/lhcq.png","[嘘声]" : "/emoji/xusheng.png","[哈欠]" : "/emoji/hqian.png","[大哭]" : "/emoji/daku.png","[原神_生气]" : "/emoji/kqsq.png","[微笑]" : "/emoji/simle.png","[给心心]" : "/emoji/geixx.png","[喜极而泣]" : "/emoji/xjeq.png","[嫌弃]" : "/emoji/xianqi.png","[原神_欸嘿]" : "/emoji/aihei.png","[原神_哇]" : "/emoji/wa.png","[加油]" : "/emoji/jiayou.png","[抠鼻]" : "/emoji/koubi.png","[滑稽]" : "/emoji/guaji.png","[傲娇]" : "/emoji/aojiao.png","[吓]" : "/emoji/xia.png","[惊喜]" : "/emoji/jingxi.png","[保佑]" : "/emoji/baoyou.png","[爱心]" : "/emoji/aixin.png","[惊讶]" : "/emoji/jingya.png","[原神_哼]" : "/emoji/heng.png","[抓狂]" : "/emoji/zhuakuang.png","[打call]" : "/emoji/dacall.png","[阴险]" : "/emoji/yinxian.png","[胜利]" : "/emoji/shengli.png","[吐]" : "/emoji/tu.png","[鼓掌]" : "/emoji/guzhang.png","[脸红]" : "/emoji/lianhong.png","[墨镜]" : "/emoji/mojing.png","[OK]" : "/emoji/ok.png","[辣眼睛]" : "/emoji/lyj.png","[奋斗]" : "/emoji/fendou.png","[妙啊]" : "/emoji/miaoa.png","[呆]" : "/emoji/dai.png","[囧]" : "/emoji/jiong.png","[吃瓜]" : "/emoji/chigua.png","[思考]" : "/emoji/sikao.png","[哦呼]" : "/emoji/ohu.png"
}

EmojiText.vue

  • 使用textarea指定位置插入特定格式表情文本内容,插入完成后,须定位到插入完成时所在的位置
  • 使用正则表达式替换特定格式表情文本为img标签,将换行符转为<br/>
  • 点击其它的地方隐藏表情选择面板
<style lang="scss" scoped>
textarea {outline: none;border: none;background: #f1f2f3;resize: none;border-radius: 8px;padding: 10px 10px;font-size: 16px;color: #333333;border: 1px solid transparent;
}
img {-webkit-user-drag: none;
}.avatar {width: 40px;height: 40px;object-fit: cover;
}.height80 {height: 80px !important;
}.height80 textarea {border: 1px solid #49b1f5;
}@keyframes scaleUp {0% {opacity: 0;transform: scale(0)}100% {opacity: 1;transform: scale(1)}
}.scaleUp {animation: scaleUp 0.3s;transform-origin: 0 0;
}.comment-area {display: flex;align-items: flex-start;margin-bottom: 38px;color: #90949e;.comment-avatar {width: 48px;height: 48px;display: flex;align-items: center;justify-content: center;margin-right: 8px;i {font-size: 40px;border: 1px solid #c4c4c4;border-radius: 50%;}}.comment-right {flex: 1;display: flex;height: 60px;transition: height 0.5s;position: relative;.edit-area {flex: 1;}.comment-btn {background-color: #49b1f5;cursor: pointer;width: 64px;border-radius: 8px;margin-left: 8px;display: flex;align-items: center;justify-content: center;color: #fff;}.comment-tips {position: absolute;bottom: -28px;height: 24px;width: calc(100% - 72px);margin-right: 72px;display: flex;align-items: center;&>span:first-child {width: 20px;height: 20px;cursor: pointer;display: flex;align-items: center;justify-content: center;&.active {color: #49b1f5;}}.emoji-wrapper {z-index: 9;user-select: none;position: absolute;bottom: 0;top: 28px;left: 0;display: flex;flex-wrap: wrap;width: 294px;height: 146px;overflow-y: auto;background-color: #fff;padding: 5px;border-radius: 6px;border-radius: 6px;box-shadow: 0 3px 6px 0 rgb(0 0 0 / 12%);border: 1px solid rgba(0, 0, 0, .06);&::before {content: '';position: absolute;}span.emoji {width: 30px;height: 30px;display: block;margin: 2px;cursor: pointer;padding: 3px;border-radius: 6px;img {width: 100%;height: 100%;}transition: all 0.28s;&:hover {background-color: #dddddd;}}}.triangle {content: '';position: absolute;width: 8px;height: 8px;top: 25px;left: 8px;background-color: white;border: 1px solid #f0f0f0;transform: rotate(45deg);border-right-color: transparent;border-bottom-color: transparent;}}}}
</style><template><div class="comment-area"><!-- 左侧的头像 --><div class="comment-avatar"><img v-if="avatarUrl" :src="avatarUrl" alt=""><i v-else class="iconfont icon-touxiang"></i></div><!-- 文本框 和 评论按钮 --><div :class="['comment-right', { height80: height80 }]"><!-- 文本框 --><textarea id="textarea" ref="textarea" v-model="textareaContent" @focus="height80 = true" @blur="doBlur":placeholder="placeholder" class="edit-area"></textarea><!-- 评论按钮 --><div class="comment-btn" @click="postComment">评论</div><!-- 表情面板 --><div class="comment-tips"><!-- 触发表情icon --><span @click="activeEmojiPanel($event, true)":class="['iconfont icon-biaoqing', { active: emojiPanelActive }]"></span><!-- 待选择的表情列表 --><div v-show="emojiPanelActive"><div class="emoji-wrapper scaleUp" @click="activeEmojiPanel"><span @click="addEmoji(emoji)" class="emoji" v-for="emoji, idx in emojiList" :key="idx"><img :src="emoji.link" alt=""></span></div></div><!-- 三角形 --><div v-show="emojiPanelActive" class="triangle"></div></div></div></div>
</template><script>/* 表情配置数据 转为 数组 */
import emojiConfig from './emoji.json'
let emojiList = []
for (let key in emojiConfig) {emojiList.push({title: key,link: emojiConfig[key]})
}export default {name: 'EmojiText',props: {imgPrefix: { /* 图片路径前缀 */type:String,default:''},placeholder: { /* 默认占位符 */type:String,default: '快快来发表你的观点吧~~'},avatarUrl: { /* 头像 */type:String},emojiSize:{type:Number,default: null},afterComment: {  /* 发表评论之后,需要执行的函数 */type: Function}},data() {return {/* 文本框中有文字 或 无文字但是处于焦点状态时 为true */height80: false,/* 表情配置数据 */emojiList,/* 是否打开表情面板 */emojiPanelActive: false,/* 文本框的内容 */textareaContent: '',}},mounted() {let _this = thisdocument.addEventListener('click', function (e) { /* 点击其它地方, 关闭表情面板 */_this.emojiPanelActive = false})},methods: {/* 添加表情 */addEmoji(emoji) {let textarea = this.$refs['textarea'];console.log(textarea.selectionStart, textarea.selectionEnd, 'start,end');// 最开始的位置要记录下,后面要根据它来设置插入文本后,设置光标的位置let selectionStart1 = textarea.selectionStartlet txtArr = this.textareaContent.split('')txtArr.splice(textarea.selectionStart, textarea.selectionEnd - textarea.selectionStart, emoji.title)this.textareaContent = txtArr.join('')/* 一定要放在$nextTick去执行, 上面修改完值后, 还要等vue把修改的数据渲染出来之后, 再去定位光标 */this.$nextTick(() => {// 替换文本后, 需要把光标,再次定位到替换后的那个位置,否则,它会回到最前面textarea.focus()textarea.setSelectionRange(selectionStart1 + emoji.title.length, selectionStart1 + emoji.title.length)})},/* 激活表情面板, 第二个参数: 是否切换 */activeEmojiPanel(e, isToggle) {if (isToggle) {this.emojiPanelActive = !this.emojiPanelActive} else {this.emojiPanelActive = true}e.stopPropagation() /* 阻止事件冒泡 */},/* 文本域失去焦点时 */doBlur() {if (this.textareaContent.length > 0) {this.height80 = true} else {this.height80 = false}},/* 发表评论 */postComment() {if(!this.textareaContent) {return}let _this = this/* 处理换行, 虽然解决了, 但是不知道为什么在文本域里面按enter和手动输入\\n有啥区别?哦懂了, \\n在正则里面就是表示的换行这一个字符, 手动输入的\\n其实是2个字符, 按enter输入的其实是一个字符(虽然它看上去是2个字符),我们程序员习惯了\\n表示换行这个字符(但这只是在开发工具里面支持的写法),如果把下面改成 /\\\\n/ 去替换那就可以匹配到手动输入的\\n这2个字符*/// console.log(this.textareaContent,'textareaContent');let result = this.textareaContent.replace(/\\n/g, function (str) {console.log('检测到str:' + str);return "<br/>"})// console.log(result,'result');/* 处理表情 *//* 这个replace函数, 第一个参数是正则表达式, 他回去匹配文本;第二个参数是将匹配的文本传入进行处理的函数,函数的返回值将会替换匹配的文本 */result = result.replace(/\\[.*?]/g, function (str) {if(_this.emojiSize) {return `<img class="emoji-pic" src="${_this.imgPrefix}${emojiConfig[str]}"  style="width:${_this.emojiSize}px;height:${_this.emojiSize}"/>`;} else {return `<img class="emoji-pic" src="${_this.imgPrefix}${emojiConfig[str]}" />`;}})this.$emit('comment',result)this.textareaContent = ''this.doBlur()this.afterComment && this.afterComment()}},
}
</script>

Comment.vue组件

Comment.vue

<style lang="scss">
/* 封面图下移效果 */
@keyframes slidedown {0% {opacity: 0.3;transform: translateY(-60px);}100% {opacity: 1;transform: translateY(0px);}
}.slidedown {animation: slidedown 1s;
}/* 内容上移效果 */
@keyframes slideup {0% {opacity: 0.3;transform: translateY(60px);}100% {opacity: 1;transform: translateY(0px);}
}.slideup {animation: slideup 1s;
}.banner {height: 400px;background-image: url(@/assets/bg5.jpg);background-size: cover;background-position: center;position: relative;color: #eee;.banner-content {position: absolute;bottom: 25%;width: 100%;text-align: center;text-shadow: 0.05rem 0.05rem 0.1rem rgb(0 0 0 / 30%);height: 108px;font-size: 30px;letter-spacing: 0.3em;}
}textarea {outline: none;border: none;background: #f1f2f3;resize: none;border-radius: 8px;padding: 10px 10px;font-size: 16px;color: #333333;
}.height80 {height: 80px !important;
}.comment-wrapper {// border: 1px solid red;max-width: 1000px;margin: 40px auto;background: #fff;padding: 40px 30px;border-radius: 10px;color: #90949e;.comment-header {font-size: 20px;font-weight: bold;color: #333333;padding: 0 20px;margin-bottom: 20px;display: flex;align-items: center;i {color: #90949e;margin-right: 5px;font-size: 20px;}}}
</style><template><div><navbar /><div class="banner slidedown"><div style="position: absolute;top: 0;left: 0;width: 100%;height: 100%;backdrop-filter: blur(5px);"></div><div class="banner-content"><div>评论</div></div></div><div class="comment-wrapper  shadow slideup"><div class="comment-header"><i class="iconfont icon-pinglun1"></i>评论<el-button @click="switchUser(1)">用户id1-zzhua195</el-button><el-button @click="switchUser(2)">用户id2-ls</el-button><el-button @click="switchUser(3)">用户id3-zj</el-button></div><!-- 主评论表情输入框 --><emoji-text @comment="comment" :emojiSize="20"></emoji-text><!-- 此处为渲染 评论列表, (所有的一级评论渲染列表) --><!-- 还有一个比较麻烦的一点:每个一级评论的最下面都有一个评论输入框,当点击这个一级评论的回复或者这个一级评论的任一子评论的回复时,应当把其它一级评论下的输入框给隐藏掉。因此, 必须要能拿到所有的Reply, 并且需要知道哪个不关闭(其它的都要关掉), 所以用ref和标记index解决所以, 只能在父组件中收集所有的Reply, 然后子组件告诉父组件如何操作。在风宇博客中, 他是直接通过$ref拿到所有的子组件后, 通过子组件的$el属性, 通过修改$el属性的display来隐藏元素的 --><!-- 把当前主评论的id给到子组件的parentId属性 --><Reply ref="commentReplyRef"  @closeOtherCommentBoxExcept="closeOtherCommentBoxExcept" :index="idx" v-for="(reply, idx) in replyList" :key="idx" :reply="reply"/></div></div>
</template><script>
import Talk from '@/components/Talk/Talk'
import Navbar from './Navbar.vue';
import EmojiText from '@/components/EmojiText/EmojiText'
import Reply from '@/components/Reply/Reply'import {getCommentListByPage,addComment} from '@/api/commentApi';export default {name: 'Comment',data() {return {replyList:[]}},mounted() {/* 加载评论数据 */getCommentListByPage({pageNum:1,pageSize:100}).then(res=>{this.replyList = res.list})},methods: {/* 添加评论 */comment(content) {addComment({userId:localStorage.getItem("userId"),commentContent:content,}).then(res=>{this.replyList.splice(0,0,res)this.$toast('success','评论成功')})},/* 模拟不同用户 */switchUser(userId) {localStorage.setItem("userId",userId)this.$toast('success', `切换userId ${userId} 成功`)},/* 关闭其它一级评论的评论框 */closeOtherCommentBoxExcept(index) {/* 根据索引, 关闭其它的输入框, 除了指定的输入框外 */this.$refs['commentReplyRef'].forEach((commentReplyRef,idx)=>{if(index != idx) {commentReplyRef.hideCommentBox()}})}},watch: {},components: {Talk,Navbar,EmojiText,Reply}
}
</script>

Reply.vue组件

Reply.vue

<style lang="scss" scoped>
.reply-info {font-size: 0.815em;color: #9499a0;display: flex;align-items: center;margin-top: 6px;span {margin-right: 10px;}.dianzan,.huifu {cursor: pointer;}.dianzan i {font-size: 13px;}
}::v-deep a.reply-to-user {color: #008ac5;margin: 6px;
}::v-deep .emoji-pic {width: 20px;height: 20px;vertical-align: text-bottom;
}i.renzheng {color: #ea387e;font-size: 1.2em;margin-left: 2px;margin-right: 4px;
}::v-deep ul.el-pager .number {font-weight: normal;min-width:24px;
}.reply {// border: 1px solid red;display: flex;.reply-avatar {width: 48px;height: 48px;margin-right: 8px;a {width: 100%;height: 100%;display: flex;justify-content: center;align-items: center;i {font-size: 40px;border: 1px solid #90949e;border-radius: 50%;}img {width: 100%;height: 100%;border-radius: 50%;}}}.reply-right-container {flex: 1;color: #333;border-bottom: 1px solid #eff2f3;margin-bottom: 15px;.reply-main {margin-bottom: 10px;.reply-nickname {font-size: 0.9em;padding-top: 6px;margin-bottom: 6px;}.reply-content {margin-bottom: 6px;color: #333333;}}.reply-sub {padding: 5px 0px 5px 40px;// border: 1px solid red;color: #333333;.reply-sub-item {position: relative;margin-bottom: 10px;.reply-sub-item-avatar {position: absolute;left: -38px;top: -2px;width: 30px;height: 30px;border-radius: 50%;overflow: hidden;i {font-size: 24px;color: #90949e;}img {width: 100%;height: 100%;object-fit: cover;}}.reply-sub-user-info {font-size: 0.9em;display: inline-flex;align-items: center;margin-right: 6px;}.reply-sub-content {margin-bottom: 6px;color: #333;display: inline;word-break: break-all;}}}.reply-total-count {font-size: 13px;color: #9499a0;cursor: pointer;display: inline-flex;vertical-align: top;margin-bottom: 5px;&:hover {color: #49b1f5;}}}}
</style><template><div class="reply"><!-- 评论-头像部分 --><div class="reply-avatar"><a href="#"><i v-if="!reply.avatarUrl" class="iconfont icon-touxiang"></i><img v-else :src="reply.avatarUrl" alt=""></a></div><!-- 评论-右侧部分 --><div class="reply-right-container"><!-- 主评论 --><div class="reply-main"><div class="reply-nickname"><a href="#">{{ reply.nickname }}<i v-show="reply.isV == 1" class="renzheng iconfont icon-renzhengguanli"></i></a></div><div class="reply-content"><span v-html="reply.commentContent"></span></div><div class="reply-info"><span>{{ reply.createTime }}</span><span class="dianzan"><i class="iconfont icon-iconfontzhizuobiaozhun023148"></i>{{reply.likeNum}}</span><span class="huifu" @click="showCommentBox(reply)">回复 </span></div></div><!-- 次级评论 --><div class="reply-sub" v-if="computedReplyChildren && computedReplyChildren.length > 0"><!-- 次级评论项 --><div class="reply-sub-item" v-for="(subReply, idx) in computedReplyChildren" :key="idx"><!-- 次级评论项用户头像, 绝对定位 --><div class="reply-sub-item-avatar"><a href="#"><i v-if="!subReply.avatarUrl" class="iconfont icon-touxiang"></i><img :src="subReply.avatarUrl" alt=""></a></div><!-- 次级评论项用户昵称, 行内样式 inline-flex --><a href="#" class="reply-sub-user-info">{{subReply.nickname}} <i v-show="subReply.isV == 1" class="renzheng iconfont icon-renzhengguanli"></i></a><!-- 次级评论项回复内容, 行内样式 inline--><div class="reply-sub-content" v-html="subReply.commentContent"></div><!-- 次级评论项回复信息 --><div class="reply-info"><span>{{ subReply.createTime }}</span><span class="dianzan"><i class="iconfont icon-iconfontzhizuobiaozhun023148"></i>{{subReply.likeNum}}</span><span class="huifu" @click="showCommentBox(subReply)">回复 </span></div></div></div><!-- 共多少条回复, 大于3条的时候(不包括3), 才有必要显示。默认只显示前三条(但实际上已经请求第一页的数据,每页数据默认5条。如果超过一页,才显示分页。)--><div v-if="replyTotalCount > 3 && !showMoreReply" @click="showMore" class="reply-total-count">共 {{replyTotalCount}} 条回复, 点击查看</div><!-- 分页显示 --><div class="paging" v-if="showMoreReply && this.replyTotalCount > this.pageSize"><el-pagination layout="total,pager" @current-change="handleCurrentChange" :total="replyTotalCount" :page-size="5" hide-on-single-page></el-pagination></div><!-- 评论框 --><EmojiText v-show="commentBoxShow" ref="commentBoxRef" @comment="doComment" :after-comment="doAfterComment" :placeholder="placeholder"/></div></div>
</template><script>
import EmojiText from '@/components/EmojiText/EmojiText'
import {addComment,getReplyListByPage} from '@/api/commentApi'
export default {name: 'Reply',props:{reply: { // 评论数据实体, 由父组件传过来type:Object},index:{ // 当前子组件的索引, 通过属性传过来, 主要用于在父组件中能从v-for循环到的组件中标识到唯一到当前组件type:Number},parentId:{ // 其实就是父评论的type:Number }},data() {return {placeholder: '',commentBoxShow:false, /* 是否显示评论框 */parentCommentId: '', /* 回复的父评论id(一级评论的id,它会用于查询所有的子评论) */replyCommentId:'',   /* 回复的评论id (对哪条评论进行回复)*/replyNickname:'',    /* 用于记录要回复的昵称 @某某某 */replyTotalCount: 0,  /* 一级评论下共多少条回复 */showMoreReply: false, /* 是否显示更多的回复, 用来记录用户有没有点过查看更多回复 */pageNum: 1, /* 当前页 */pageSize: 5,/* 每页条数 */totalPage: 0, /* 总页数 */}},mounted() {/* 根据父组件传过来的数据, 初始化 总条数 和 总页数 */this.replyTotalCount = this.reply.replyTotalCountthis.totalPage = Math.ceil(this.replyTotalCount / this.pageSize)},computed:{/* 当前计算的要显示的子评论, 当没有点击查看更多回复时, 回复数量超过3个(不包含3个),仅显示前3个回复 */computedReplyChildren() {if(!this.showMoreReply && this.replyTotalCount > 3) {return this.reply.children.filter((subReply,idx)=>idx <= 2)}return this.reply.children || []} },methods: {/* 请求指定页的数据 */handleCurrentChange(currentPage) {this.pageNum = currentPage/* 请求完数据后, 直接将接口返回的list, 替换掉children, 让vue处理列表渲染 */getReplyListByPage({pageNum:this.pageNum, pageSize:this.pageSize, commentId:this.reply.id}).then(res=>{this.reply.children = res.listthis.replyTotalCount = res.totalCountthis.totalPage = Math.ceil(res.totalCount / res.pageSize)})},/* 点击查看更多 */showMore() {this.showMoreReply = true},/* 隐藏评论框, 供父组件调用(父组件可通过$refs拿到当前子组件后,调用此方法即可) */hideCommentBox() {this.commentBoxShow = false},/* 显示评论框 */showCommentBox(reply) {console.log(reply);this.commentBoxShow = true/* 如果是一级评论, 那么直接取它的id作为父评论id; 如果不是一级评论, 那么取它的父级评论的id作为父id */if(!reply.parentId) {this.parentCommentId = reply.id} else {this.parentCommentId = reply.parentId }this.replyCommentId = reply.id        /* 回复的评论id (对哪条评论进行回复) */this.replyUserId = reply.userId       /* 对谁进行回复(用户id) */this.replyNickname = reply.nickname   /* 对谁进行回复(用户昵称) */if(reply.parentId) {this.placeholder = `回复 @${reply.nickname}`} else {this.placeholder = ``}/* 让父组件去关闭其它一级评论下的输入框,因为只能展示一个评论框 */this.$emit('closeOtherCommentBoxExcept', this.index)},/* 发表评论 */doComment(commentContent) {let content = ''// 如果不是对一级评论进行回复, 那就要加上@ 回复谁if(this.parentCommentId !== this.replyCommentId) {content = `<a href="#" class="reply-to-user">@${this.replyNickname} :</a>`commentContent = content + commentContent}addComment({userId:localStorage.getItem("userId"),replyUserId:this.replyUserId,commentContent,parentId: this.parentCommentId,replyCommentId: this.replyCommentId}).then(res=>{console.log(res,'succ');if(!this.reply.children) {this.reply.children = []}this.reply.children.push(res) // 虽然不可以直接改父组件通过prop传过来的数据, 但是我不直接改prop, // 而是改传过来的prop里面的属性,意思是:不能直接改this.reply,但是可以改this.reply里面的childrenthis.$toast('success','回复成功')})},/* 在评论之后, 关闭评论框 */doAfterComment() {this.commentBoxShow = false}},components: {EmojiText}
}
</script>

App.vue组件

<template><div id="app"><router-view/></div>
</template><style lang="scss">@import url(//at.alicdn.com/t/c/font_4004562_b46lfqtm52u.css);body {margin: 0;overflow-y: scroll;overflow-x: hidden;/* 背景渐变 */background: linear-gradient(90deg, rgba(247, 149, 51, .1), rgba(243, 112, 85, .1) 15%, rgba(239, 78, 123, .1) 30%, rgba(161, 102, 171, .1) 44%, rgba(80, 115, 184, .1) 58%, rgba(16, 152, 173, .1) 72%, rgba(7, 179, 155, .1) 86%, rgba(109, 186, 130, .1));}.shadow {box-shadow: 0 4px 8px 6px rgba(7, 17, 27, .06);}/* 整个滚动条 */
::-webkit-scrollbar {width: 10px;height: 10px;
}/* 滚动条上的滚动滑块,参考: 滚动条样式修改->https://blog.csdn.net/coder_jxd/article/details/124213962 */
::-webkit-scrollbar-thumb {background-color: #49b1f5;/* 关键代码 */background-image: -webkit-linear-gradient(45deg,rgba(255, 255, 255, 0.4) 25%,transparent 25%,transparent 50%,rgba(255, 255, 255, 0.4) 50%,rgba(255, 255, 255, 0.4) 75%,transparent 75%,transparent);border-radius: 32px;
}/* 滚动条样式,参考: */
/* 滚动条轨道 */
::-webkit-scrollbar-track {background-color: #dbeffd;border-radius: 32px;
}* {box-sizing: border-box;
}a {text-decoration: none;color: inherit;
}
</style>