Vue+SpringBoot实现评论功能
Posted 瑶琴遇知音
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue+SpringBoot实现评论功能相关的知识,希望对你有一定的参考价值。
目录
前言
评论系统相信大家并不陌生,在社交网络相关的软件中是一种常见的功能。然而对于初学者来说,实现一个完整的评论系统并不容易。本文笔者以 Vue+SpringBoot 前后端分离的架构细说博客评论功能的实现思路。
难点
对于一个评论系统主要包含评论人,评论时间,评论内容,评论回复等内容。此外可能还存在回复的回复以及回复的回复的回复,每条评论可能存在多条回复,每条回复又可能存在多条回复,即是一个多叉树的关系。因此,难点如下:
- 确定并存储评论与回复的层级关系以及与博客本章的从属关系
- 多层级评论与回复的前端递归显示
- 多层级评论与回复的递归删除
实现思路
数据表设计
首先我们需要考虑的是数据表中如何存储评论与回复的层级关系以及与博客文章的从属关系。
- 很直观能够想到对于每一条评论,拥有一个表示所属博客文章ID的字段blogId
- 每一条评论维护一个parentId字段,表示父评论的id,由此确定评论之间的层级关系
- 此外我们还会维护一个rootParentId字段,表示当前评论所属根评论的id,该字段将在前端递归显示时有大用
于是,添加上其他相关信息后最终的数据表schema如下:
字段名称 | 中文注释 | 数据类型 | 是否为null | 备注 |
---|---|---|---|---|
id | 评论id | bigint | not null | primary key,auto increment |
content | 评论内容 | text | not null | |
user_id | 评论人id | bigint | not null | |
user_name | 评论人姓名 | varchar(80) | ||
create_time | 创建时间 | datetime | ||
is_delete | 是否已删除 | tinyint | default 0 | 0:未删除;1:已删除 |
blog_id | 所属博客id | bigint | ||
parent_id | 父评论id | bigint | ||
root_parent_id | 根评论id | bigint |
数据传输格式设计
基于数据表schema,我们需要设计前后端数据传输的格式,以方便前后端对于层级关系的解析。
- 很自然地想到将评论的基本信息封装为 bean,并将其子评论对象封装为其一个属性。
- 由于每条评论可能存在多条回复,因此属性的数据类型应当为 List
于是得到的评论 bean 为:
/**
* 评论信息
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Comment implements Serializable
private Long id; // 评论ID
private String content; // 评论内容
private Long userId; // 评论作者ID
private String userName; // 评论作者姓名
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime; // 创建时间
private Integer isDelete; // 是否删除(0:未删除;1:已删除)
private Long blogId; // 博客ID
private Long parentId; // 父评论ID(被回复的评论)
private Long rootParentId; // 根评论ID(最顶级的评论)
private List<Comment> child; // 本评论下的子评论
那么接下来的问题是如何将数据表中的层级关系转化为 Comment 类中的 father-child 的关系
我这里写了一个 util 的方法完成这个转化过程
/**
* 构建评论树
* @param list
* @return
*/
public static List<Comment> processComments(List<Comment> list)
Map<Long, Comment> map = new HashMap<>(); // (id, Comment)
List<Comment> result = new ArrayList<>();
// 将所有根评论加入 map
for(Comment comment : list)
if(comment.getParentId() == null)
result.add(comment);
map.put(comment.getId(), comment);
// 子评论加入到父评论的 child 中
for(Comment comment : list)
Long id = comment.getParentId();
if(id != null) // 当前评论为子评论
Comment p = map.get(id);
if(p.getChild() == null) // child 为空,则创建
p.setChild(new ArrayList<>());
p.getChild().add(comment);
return result;
这样父子关系就表示清楚了,前端通过接口请求到的数据就会是如下的样子
"success": true,
"code": 200,
"message": "执行成功",
"data":
"commentList": [
"id": 13,
"content": "r34r43r4r54t54t54",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-26 04:53:21",
"isDelete": null,
"blogId": 1,
"parentId": null,
"rootParentId": null,
"child": [
"id": 19,
"content": "评论回复测试2",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-27 03:10:41",
"isDelete": null,
"blogId": 1,
"parentId": 13,
"rootParentId": 13,
"child": null
]
,
"id": 12,
"content": "fdfgdfgfg",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-26 04:51:46",
"isDelete": null,
"blogId": 1,
"parentId": null,
"rootParentId": null,
"child": [
"id": 20,
"content": "评论回复测试3",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-27 03:16:09",
"isDelete": null,
"blogId": 1,
"parentId": 12,
"rootParentId": 12,
"child": null
]
,
"id": 11,
"content": "demo",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-26 04:12:43",
"isDelete": null,
"blogId": 1,
"parentId": null,
"rootParentId": null,
"child": [
"id": 21,
"content": "评论回复测试4",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-27 03:19:42",
"isDelete": null,
"blogId": 1,
"parentId": 11,
"rootParentId": 11,
"child": null
]
,
"id": 9,
"content": "评论3",
"userId": 3,
"userName": "zhangsan",
"createTime": "2022-10-05 06:20:54",
"isDelete": null,
"blogId": 1,
"parentId": null,
"rootParentId": null,
"child": [
"id": 24,
"content": "评论回复测试n3",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-27 03:23:54",
"isDelete": null,
"blogId": 1,
"parentId": 9,
"rootParentId": 9,
"child": null
]
,
"id": 7,
"content": "评论2",
"userId": 2,
"userName": "liming",
"createTime": "2022-10-05 06:19:40",
"isDelete": null,
"blogId": 1,
"parentId": null,
"rootParentId": null,
"child": [
"id": 8,
"content": "回复2-1",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-14 06:20:07",
"isDelete": null,
"blogId": 1,
"parentId": 7,
"rootParentId": 7,
"child": null
]
,
"id": 1,
"content": "评论1",
"userId": 1,
"userName": "admin",
"createTime": "2022-10-05 06:14:32",
"isDelete": null,
"blogId": 1,
"parentId": null,
"rootParentId": null,
"child": [
"id": 3,
"content": "回复1-2",
"userId": 2,
"userName": "liming",
"createTime": "2022-10-07 06:16:25",
"isDelete": null,
"blogId": 1,
"parentId": 1,
"rootParentId": 1,
"child": [
"id": 6,
"content": "回复1-2-1",
"userId": 3,
"userName": "zhangsan",
"createTime": "2022-10-13 06:18:51",
"isDelete": null,
"blogId": 1,
"parentId": 3,
"rootParentId": 1,
"child": null
]
]
],
"total": 13
对于处于叶子节点的评论,其 child 就为 null
前端递归显示
接下来的一个难题是从后端获取到的这个多叉树结构的数据如何显示出来。
- 我们首先能想到的是 Vue 里的 v-for 来循环输出所有 comment,再取其 child 进行嵌套 v-for 输出
- 但是这样就会产生一个问题,v-for 的嵌套次数这么写就是固定的,然而对于这棵多叉树我们并不知道其深度为多少。举个例子,例如我的前端结构是外层一个 v-for 输出所有的 comment,内层一个 v-for 输出这些 comment 的 child。但是这样的结构无法输出 child 的 child,如果再加一层 v-for,又无法输出 child 的 child 的 child。因为我们无法知道这棵树的深度为多少,所以并不能确定 v-for 的嵌套层树。而且这样的一种写法也实在是冗余,缺乏优雅。
- 因此,我们很自然地想到算法中的递归。
- Vue 中的递归可以利用其独特的父子组件机制实现。简单来说,Vue 允许父组件调用子组件,并可进行数据的传递,那么只要我们让组件自己调用自己并调整传递的数据,那么这不就形成了一个递归结构了吗?
我们接下来来看我的具体实现
blogDetails.vue(父组件)
<!-- 显示评论 -->
<div class="comment-list-container">
<div class="comment-list-box comment-operate-item">
<ul class="comment-list" v-for="comment in commentList">
<!-- 评论根目录 -->
<root :comment="comment" :blog="blog" :getCommentList="getCommentList"></root>
<!-- 评论子目录 -->
<li class="replay-box" style="display: block;">
<ul class="comment-list">
<!-- 子组件递归实现 -->
<child :childComments="comment.child" :parentComment="comment" :blog="blog" :rootParentId="comment.id" :getCommentList="getCommentList" v-if="comment.child != null"></child>
</ul>
</li>
</ul>
</div>
</div>
在父组件中我们调用了子组件 child 去实现评论的输出,child 来自于 childComment.vue
childComment.vue
<div class="comment-line-box" v-for="childComment in childComments">
<div class="comment-list-item">
<el-avatar icon="el-icon-user-solid" :size="35" style="width: 38px;"></el-avatar>
<div class="right-box">
<div class="new-info-box clearfix">
<div class="comment-top">
<div class="user-box">
<span class="comment-name"> childComment.userName </span>
<el-tag size="mini" type="danger" v-show="childComment.userName === blog.authorName" style="margin-left: 5px;">作者</el-tag>
<span class="text">回复</span>
<span class="nick-name"> parentComment.userName </span>
<span class="date"> childComment.createTime </span>
<div class="opt-comment">
<i class="el-icon-delete"></i>
<span style="margin-left: 3px;" @click="deleteComment(childComment)">删除</span>
<i class="el-icon-chat-round" style="margin-left: 10px;"></i>
<span style="margin-left: 3px;" @click="showReplay = !showReplay">回复</span>
</div>
</div>
</div>
<div class="comment-center">
<div class="new-comment"> childComment.content </div>
</div>博文评论与在线消息提示[开发日志-SpringBoot+Vue]
文章目录
前言
没想到就两个礼拜左右没有碰一起的项目代码,就忘得差不多了。还熟悉了一下代码,跪了。
okey~接下来的话,来看到今天实现的效果:
每次就是这样的一个评论区
并且在评论成功之后,还要发送消息给博主。
然后如果博主在线的话,那么就要实时进行一个推送,告诉博主,然后博主可以在消息的一个页面看到这个消息:
整个过程的话,和一个正常的博文社区,例如CSDN,掘金,思否,知乎是类似的。
然后我们要做的就是实现这个东西。
要点
那么实现这个功能的话,大概由一下几个点需要明确一下:
- 评论的数据格式
- 评论的过滤审核
- 消息的转发与接收
- 前端页面的功能实现
- 评论展示
- 评论回复
- 消息展示与通知显示
那么在这里的话,由于篇幅问题,加上这个消息推送服务并不一定是需要的,并且消息的实现也稍微麻烦一点儿,因此本文消息部分的话会缩略一下。重点是咱们的这个评论的一个实现。
评论结构
OKey,那么在开始之前的话,我们也是需要去确定一下这个评论的数据结构的,这个结构的话是这样的:
comments:[
name:'Huterox',
id:19870621,
commentid: 777,
headImg:'https://ae01.alicdn.com/kf/Hd60a3f7c06fd47ae85624badd32ce54dv.jpg',
comment:'Huterox is best',
time:'2022年9月16日 18:43',
inputShow:false,
reply:[
from:'Huterox03',
fromId:19891221,
replyid: 666,
fromHeadImg:'https://ae01.alicdn.com/kf/H94c78935ffa64e7e977544d19ecebf06L.jpg',
to:'Huterox',
toId:19870621,
comment:'66666666666666',
time:'2022年9月26日 20:43',
inputShow:false
,
]
]
是的这里的话,我将这个评论部分拆分为两个部分,就是图中这样的两个部分:
那么在我们的数据库里面的话也是这样设计的:
这里说明一下的就是 blogs_com_coment 这个表其实就是回复的那个表。没办法语文没学好,原来评论的评论可以叫做回复(giao)。
okey,那么这个的话就是我们大概的一个数据结构的样子。
获取评论
okey,我们按照功能来开始进行一个简要的分析和概述。
评论组件
那么在开始之前的话,我们先来看到整个评论组件的完整代码。是的这里的话还没有去做封装,主要是方便我进行一个博文的编写,同时,也方便给别人用,如果你的流程和我的类似,你直接CV然后改数据结构即可。当然还有个原因是方便我调试,开发阶段。
那么我们完整的代码是这样的:
<template>
<div>
<div v-if="!isLoginFlag" style="height: 300px;width: 100%">
<el-empty image="/static/image/go_login.gif" :description="badTips"></el-empty>
</div>
<div v-if="isLoginFlag">
<div v-clickoutside="hideReplyBtn" @click="inputFocus" class="my-reply">
<el-avatar class="header-img" :size="40" :src="myHeader"></el-avatar>
<div class="reply-info" >
<div
tabindex="0"
contenteditable="true"
id="replyInput"
spellcheck="false"
placeholder="输入评论..."
class="reply-input"
@focus="showReplyBtn"
@input="onDivInput($event)"
>
</div>
</div>
<div class="reply-btn-box" v-show="btnShow">
<el-button class="reply-btn" size="medium" @click="sendComment" type="primary">发表评论</el-button>
</div>
</div>
<div v-for="(item,i) in comments" :key="i" class="author-title reply-father">
<el-avatar
class="header-img" :size="40" :src="item.headImg"
@click="gotoSpace(item.id)"
></el-avatar>
<div class="author-info">
<span class="author-name">item.name</span>
<span class="author-time">item.time</span>
</div>
<div class="icon-btn">
<span @click="showReplyInput(i,item.name,item.id)">
<i style="font-size: 6px" class="iconfont el-icon-s-comment">回复</i>
</span>
</div>
<div class="talk-box">
<p>
<span class="reply">item.comment</span>
</p>
</div>
<div class="reply-box">
<div v-for="(reply,j) in item.reply" :key="j" class="author-title">
<el-avatar class="header-img" :size="40" :src="reply.fromHeadImg"
@click="gotoSpace(reply.fromId)"
></el-avatar>
<div class="author-info">
<span class="author-name">reply.from</span>
<span class="author-time">reply.time</span>
</div>
<div class="icon-btn">
<span @click="showReplyInput(i,reply.from,reply.id)">
<i style="font-size: 6px" class="iconfont el-icon-s-comment">回复</i>
</span>
</div>
<div class="talk-box">
<p>
<span style="font-size: 8px">@ reply.to:</span>
<span class="reply">reply.comment</span>
</p>
</div>
<div class="reply-box">
</div>
</div>
</div>
<div v-show="_inputShow(i)" class="my-reply my-comment-reply">
<el-avatar class="header-img" :size="40" :src="myHeader"></el-avatar>
<div class="reply-info" >
<div tabindex="0" contenteditable="true" spellcheck="false" placeholder="输入回复..." @input="onDivInput($event)" class="reply-input reply-comment-input"></div>
</div>
<div class=" reply-btn-box">
<el-button class="reply-btn" size="medium" @click="sendCommentReply(i,j)" type="primary">点击回复</el-button>
</div>
</div>
</div>
<div>
<el-pagination
background
layout="total, prev, pager, next"
:current-page="page"
page-size=5
@current-change="handleCurrentChange"
:total=total>
</el-pagination>
</div>
</div>
</div>
</template>
<script>
const clickoutside =
// 初始化指令
bind(el, binding, vnode)
function documentHandler(e)
// 这里判断点击的元素是否是本身,是本身,则返回
if (el.contains(e.target))
return false;
// 判断指令中是否绑定了函数
if (binding.expression)
// 如果绑定了函数 则调用那个函数,此处binding.value就是handleClose方法
binding.value(e);
// 给当前元素绑定个私有变量,方便在unbind中可以解除事件监听
el.vueClickOutside = documentHandler;
document.addEventListener('click', documentHandler);
,
update() ,
unbind(el, binding)
// 解除事件监听
document.removeEventListener('click', el.vueClickOutside);
delete el.vueClickOutside;
,
;
export default
name:'comment_article',
// 接受父组件的值
props:
blogid: String,
myuserid: String,
isLoginFlag: String,
blogTitle: String,
blogUserid: String,
loginToken: String,
required: true
,
data()
return
total: 10,
page: 1,
limit: 5,
badTips: "登录后才能查看和发表评论呦~",
btnShow: false,
index:'0',
replyComment:'',
myName:'Lana Del Rey',
myHeader:'https://ae01.alicdn.com/kf/Hd60a3f7c06fd47ae85624badd32ce54dv.jpg',
myId:19870621,
to:'',
toId:-1,
comments:[
]
,
directives: clickoutside,
created()
this.getMyInfo(this.myuserid);
this.getComments();
,
methods:
gotoSpace(userid)
this.$router.push(path: "/userinfo",query:'userid':userid);
,
handleCurrentChange(val)
//换页
this.page = val;
this.getComments();
,
getComments()
//得到当前博文的评论
this.axios(
url: "/blog/blog/comment/getCommentList",
method: 'post',
headers:
"userid": this.myuserid,
"loginType": "PcType",
"loginToken": this.loginToken,
,
data:
'userid': this.myuserid,
"blogid": this.blogid,
'page': this.page,
'limit': this.limit
).then((res) =>
res = res.data;
if(res.code === 0)
this.comments = res.page.list;
this.total = res.page.totalCount;
this.page = res.page.currPage;
else
this.isLoginFlag=false;
this.badTips = res.msg;
).catch(reason =>
this.isLoginFlag=false;
this.badTips = "当前访问异常";
)
,
sendReply(comment,i,j)
let commentid = null;
let commentUserid = null;
let commentNickname = null;
let commentUserimg = null;
if(j)
commentid = comment[j].replyid;
commentUserid = comment[j].fromId;
commentNickname = comment[j].from;
commentUserimg = comment[j].fromHeadImg;
else
commentid = comment.commentid;
commentUserid = comment.id;
commentNickname = comment.name;
commentUserimg = comment.headImg;
//发送回复
this.axios(
url: "/blog/blog/comment/upReply",
method: 博文评论与在线消息提示[开发日志-SpringBoot+Vue]
博文评论与在线消息提示[开发日志-SpringBoot+Vue]
推荐一个SpringBoot + Vue + MyBatis 音乐网站项目
推荐一个SpringBoot + Vue + MyBatis 音乐网站项目