先看效果图
样式是模仿哔哩哔哩的,支持二级评论。
动态效果图如下,点击图片放大
Comment 表
id:主键,自定生成
content:评论内容,限制500个字符
create_time:创建时间,自动生成
user_id:评论的用户的id
post_id:评论所在文章的id
pid:评论的父id,注意A评论下的所有子评论,我这里都设计为他的儿子,而不是儿子,孙子,曾孙。即,回复的回复,回复的回复的回复的pid都是某个一级评论。这样设计,主要是为了二级显示和避免太多递归(多级显示太麻烦了,/(ㄒoㄒ)/~~)
reply_user_id:被回复人的id,用于@对方时候显示。对一级评论进行评论不需要@他,所以reply_user_id 为空,对回复回复需要@对方,否则不知道回复谁。
再补充一下,解释一下上面的。
如图,柯南的评论属于一级评论,有楼层号显示。小兰回复柯南,灰原哀回复小兰,琴酒回复灰原哀。小兰、灰原哀、琴酒的 pid 都是柯南的 id,而柯南的 pid 为 0。
Post.java 文章实体
这里去掉了不必要的代码
Comment.java 评论实体
本项目采用的是 Spring Data JPA 作为 ORM 框架
因为我们在 Post 实体中关联查询评论列表,只查询一级评论,其中二级评论由 Comment 实体中的 CommentList 查询。
但是,前台需要显示评论数,也就是 Post 表中的字段 commentSize 需要及时更改,所以我们采用每次从数据库查询的方式来解决。
如果我们不使用 @Where(clause = "pid=0") ,获取评论数可以在 Post 实体中添加这个方法,用于设置评论数
但是,我们设置了 @Where(clause = "pid=0") ,所以获取的 size 是不准确。所以,这里解释了一下。
CommentServiceImpl.java
PostController.java
文章详情页方法
CommentController.java
detail.html
这里只贴出评论列表的代码
main.js
css 代码比较多,这里就不贴出来
样式是模仿哔哩哔哩的,支持二级评论。
动态效果图如下,点击图片放大
一、数据库设计
Comment 表
id:主键,自定生成
content:评论内容,限制500个字符
create_time:创建时间,自动生成
user_id:评论的用户的id
post_id:评论所在文章的id
pid:评论的父id,注意A评论下的所有子评论,我这里都设计为他的儿子,而不是儿子,孙子,曾孙。即,回复的回复,回复的回复的回复的pid都是某个一级评论。这样设计,主要是为了二级显示和避免太多递归(多级显示太麻烦了,/(ㄒoㄒ)/~~)
reply_user_id:被回复人的id,用于@对方时候显示。对一级评论进行评论不需要@他,所以reply_user_id 为空,对回复回复需要@对方,否则不知道回复谁。
再补充一下,解释一下上面的。
如图,柯南的评论属于一级评论,有楼层号显示。小兰回复柯南,灰原哀回复小兰,琴酒回复灰原哀。小兰、灰原哀、琴酒的 pid 都是柯南的 id,而柯南的 pid 为 0。
二、JPA 实体
Post.java 文章实体
- package com.liuyanzhao.forum.entity;
- import lombok.Data;
- import org.hibernate.annotations.Where;
- import javax.persistence.*;
- import javax.validation.constraints.NotEmpty;
- import javax.validation.constraints.Size;
- import java.io.Serializable;
- import java.util.List;
- /**
- * @author 言曌
- * @date 2018/3/19 下午9:54
- */
- @Entity
- @Data
- public class Post implements Serializable {
- private static final long serialVersionUID = -5086173193716866676L;
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY) // 自增长策略
- @Column(name = "id", nullable = false)
- private Long id;
- @NotEmpty(message = "标题不能为空")
- @Size(max = 200, message = "标题不能超过200个字符")
- @Column(nullable = false, length = 200)
- private String title;
- @Lob // 大对象,映射 MySQL 的 Long Text 类型
- @Basic(fetch = FetchType.LAZY) // 懒加载
- @NotEmpty(message = "内容不能为空")
- @Size(max = 100000, message = "内容不能超过100000个字符")
- @Column(nullable = false)
- private String content;//文章全文内容
- @Column(name = "comment_size")
- private Integer commentSize = 0; // 评论数
- //当文章删除后,与之关联的评论也会被删除
- @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
- @OrderBy("id DESC")
- @Where(clause = "pid=0")
- private List<Comment> commentList;
- public Post() {
- }
- }
这里去掉了不必要的代码
Comment.java 评论实体
- package com.liuyanzhao.forum.entity;
- import lombok.Data;
- import javax.persistence.*;
- import javax.validation.constraints.NotEmpty;
- import javax.validation.constraints.Size;
- import java.io.Serializable;
- import java.sql.Timestamp;
- import java.util.List;
- /**
- * 评论实体
- * @author 言曌
- * @date 2018/3/19 下午9:54
- */
- @Entity
- @Data
- public class Comment implements Serializable {
- private static final long serialVersionUID = -4502134548520740266L;
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY) // 自增长策略
- private Long id;
- @Column(name = "pid", nullable = false)
- private Long pid = 0L;//父评论,如果不设置,默认为0
- @NotEmpty(message = "评论内容不能为空")
- @Size(max = 500, message = "评论内容不能多于500个字符")
- @Column(nullable = false)
- private String content;
- @OneToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "user_id")
- private User user;
- @Column(nullable = false)
- @org.hibernate.annotations.CreationTimestamp // 由数据库自动创建时间
- private Timestamp createTime;
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "post_id")
- private Post post;//所属文章
- @OneToMany(fetch = FetchType.LAZY)//不设置级联,使用懒加载
- @JoinColumn(name = "pid", referencedColumnName = "id")
- @OrderBy("id ASC")
- private List<Comment> commentList;
- @JoinColumn(name = "reply_user_id")
- @OneToOne(fetch = FetchType.LAZY)//不设置级联,使用懒加载
- private User replyUser;
- public Comment() {
- }
- public Comment(User user,Post post, String content) {
- this.post = post;
- this.content = content;
- this.user = user;
- }
- public Comment(User user, Post post, Long pid, String content) {
- this.user = user;
- this.post = post;
- this.pid = pid;
- this.content = content;
- }
- @Override
- public String toString() {
- return "Comment{" +
- "id=" + id +
- ", pid=" + pid +
- ", content='" + content + '\'' +
- ", createTime=" + createTime +
- '}';
- }
- }
三、DAO 层
本项目采用的是 Spring Data JPA 作为 ORM 框架
- package com.liuyanzhao.forum.repository;
- import com.liuyanzhao.forum.entity.Comment;
- import com.liuyanzhao.forum.entity.Post;
- import org.springframework.data.jpa.repository.JpaRepository;
- /**
- * @author 言曌
- * @date 2018/4/1 上午9:32
- */
- public interface CommentRepository extends JpaRepository<Comment, Long> {
- /**
- * 统计某篇文章有多少条评论
- * @param post
- * @return
- */
- Integer countByPost(Post post);
- }
因为我们在 Post 实体中关联查询评论列表,只查询一级评论,其中二级评论由 Comment 实体中的 CommentList 查询。
- @Where(clause = "pid=0")
- private List<Comment> commentList;
但是,前台需要显示评论数,也就是 Post 表中的字段 commentSize 需要及时更改,所以我们采用每次从数据库查询的方式来解决。
如果我们不使用 @Where(clause = "pid=0") ,获取评论数可以在 Post 实体中添加这个方法,用于设置评论数
- /**
- * 修改评论数
- *
- */
- public void updateCommentSize() {
- this.commentSize = this.commentList.size();
- }
但是,我们设置了 @Where(clause = "pid=0") ,所以获取的 size 是不准确。所以,这里解释了一下。
四、Service 层
CommentServiceImpl.java
- package com.liuyanzhao.forum.service.impl;
- import com.liuyanzhao.forum.entity.Comment;
- import com.liuyanzhao.forum.entity.Post;
- import com.liuyanzhao.forum.entity.User;
- import com.liuyanzhao.forum.repository.CommentRepository;
- import com.liuyanzhao.forum.repository.PostRepository;
- import com.liuyanzhao.forum.service.CommentService;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.security.core.context.SecurityContextHolder;
- import org.springframework.stereotype.Service;
- import org.springframework.transaction.annotation.Transactional;
- /**
- * @author 言曌
- * @date 2018/4/18 下午3:33
- */
- @Service
- public class CommentServiceImpl implements CommentService {
- @Autowired
- private CommentRepository commentRepository;
- @Autowired
- private PostRepository postRepository;
- @Override
- @Transactional
- public void removeComment(Long id) {
- commentRepository.deleteById(id);
- }
- @Override
- public Integer countCommentSizeByPost(Post post) {
- return commentRepository.countByPost(post);
- }
- @Override
- public Comment getCommentById(Long id) {
- return commentRepository.findById(id).get();
- }
- @Override
- public void createComment(Long postId, String commentContent) {
- Post post = postRepository.findById(postId).get();
- User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
- Comment comment = new Comment(user, post, commentContent);
- commentRepository.save(comment);
- }
- @Override
- public void replyComment(Long postId, Long commentId, Long replyId, String commentContent) {
- Post post = postRepository.findById(postId).get();
- User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
- Comment comment = new Comment(user, post, commentId, commentContent);
- //评论回复,需要加上@用户
- if (replyId != null) {
- Comment replyComment = commentRepository.findById(replyId).get();
- comment.setReplyUser(replyComment.getUser());
- }
- //添加评论
- commentRepository.save(comment);
- }
- }
五、Controller 层
PostController.java
文章详情页方法
- /**
- * 文章详情页
- *
- * @param username
- * @param id
- * @param model
- * @return
- */
- @GetMapping("/u/{username}/posts/{id}")
- public String getPostById(@PathVariable("username") String username, @PathVariable("id") Long id, Model model) {
- //1、每次读取,简单的可以认为阅读量增加1次
- try {
- postService.readingIncrease(id);
- } catch (Exception e) {
- //文章不存在
- }
- // 2、判断操作用户是否是博客的所有者
- boolean isPostOwner = false;
- if (SecurityContextHolder.getContext().getAuthentication() != null && SecurityContextHolder.getContext().getAuthentication().isAuthenticated()
- && !SecurityContextHolder.getContext().getAuthentication().getPrincipal().toString().equals("anonymousUser")) {
- User principal = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
- if (principal != null && username.equals(principal.getUsername())) {
- isPostOwner = true;
- }
- }
- Post post = postService.getPostById(id);
- model.addAttribute("site_title", post.getTitle() + "-" + SITE_NAME);
- model.addAttribute("isPostOwner", isPostOwner);
- model.addAttribute("post", post);
- return "home/post/detail";
- }
CommentController.java
- package com.liuyanzhao.forum.controller;
- import com.liuyanzhao.forum.entity.Comment;
- import com.liuyanzhao.forum.entity.Post;
- import com.liuyanzhao.forum.entity.User;
- import com.liuyanzhao.forum.repository.PostRepository;
- import com.liuyanzhao.forum.service.CommentService;
- import com.liuyanzhao.forum.service.PostService;
- import com.liuyanzhao.forum.util.ConstraintViolationExceptionHandler;
- import com.liuyanzhao.forum.vo.Response;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.http.ResponseEntity;
- import org.springframework.security.access.prepost.PreAuthorize;
- import org.springframework.security.core.context.SecurityContextHolder;
- import org.springframework.stereotype.Controller;
- import org.springframework.web.bind.annotation.*;
- import javax.validation.ConstraintViolationException;
- import java.util.List;
- /**
- * @author 言曌
- * @date 2018/4/18 下午3:29
- */
- @Controller
- @RequestMapping("/comments")
- public class CommentController {
- @Autowired
- private PostRepository postRepository;
- @Autowired
- private CommentService commentService;
- /**
- * 发表评论
- *
- * @param postId
- * @param commentId
- * @param commentContent
- * @return
- */
- @PostMapping
- @PreAuthorize("hasAnyAuthority('ROLE_ADMIN','ROLE_USER')") // 指定角色权限才能操作方法
- public ResponseEntity<Response> createComment(Long postId, Long replyId, Long commentId, String commentContent) {
- try {
- postRepository.findById(postId).get();
- } catch (Exception e) {
- return ResponseEntity.ok().body(new Response(false, "文章不存在!"));
- }
- try {
- //1、评论
- if (commentId == null) {
- commentService.createComment(postId, commentContent);
- } else {
- //回复
- commentService.replyComment(postId, commentId, replyId, commentContent);
- }
- //2、修改文章的评论数目
- Post originalPost = postRepository.findById(postId).get();
- originalPost.setCommentSize(commentService.countCommentSizeByPost(originalPost));
- postRepository.save(originalPost);
- } catch (ConstraintViolationException e) {
- return ResponseEntity.ok().body(new Response(false, ConstraintViolationExceptionHandler.getMessage(e)));
- } catch (Exception e) {
- return ResponseEntity.ok().body(new Response(false, e.getMessage()));
- }
- return ResponseEntity.ok().body(new Response(true, "处理成功!", null));
- }
- /**
- * 删除评论
- *
- * @return
- */
- @DeleteMapping("/{id}")
- @PreAuthorize("hasAnyAuthority('ROLE_ADMIN','ROLE_USER')") // 指定角色权限才能操作方法
- public ResponseEntity<Response> delete(@PathVariable("id") Long id) {
- boolean isOwner = false;
- User user;
- try {
- user = commentService.getCommentById(id).getUser();
- } catch (Exception e) {
- return ResponseEntity.ok().body(new Response(false, "评论不存在!"));
- }
- // 判断操作用户是否是评论的所有者
- if (SecurityContextHolder.getContext().getAuthentication() != null && SecurityContextHolder.getContext().getAuthentication().isAuthenticated()
- && !SecurityContextHolder.getContext().getAuthentication().getPrincipal().toString().equals("anonymousUser")) {
- User principal = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
- if (principal != null && user.getUsername().equals(principal.getUsername())) {
- isOwner = true;
- }
- }
- if (!isOwner) {
- return ResponseEntity.ok().body(new Response(false, "没有操作权限!"));
- }
- try {
- Comment originalComment = commentService.getCommentById(id);
- //1、先删除子评论
- List<Comment> commentList = originalComment.getCommentList();
- if (commentList != null && commentList.size() != 0) {
- for (int i = 0; i < commentList.size(); i++) {
- Comment comment = commentList.get(i);
- commentService.removeComment(comment.getId());
- }
- }
- //2、删除该评论
- commentService.removeComment(id);
- //3、修改文章的评论数目
- Post originalPost = originalComment.getPost();
- originalPost.setCommentSize(commentService.countCommentSizeByPost(originalPost));
- postRepository.save(originalPost);
- } catch (ConstraintViolationException e) {
- return ResponseEntity.ok().body(new Response(false, ConstraintViolationExceptionHandler.getMessage(e)));
- } catch (Exception e) {
- return ResponseEntity.ok().body(new Response(false, e.getMessage()));
- }
- return ResponseEntity.ok().body(new Response(true, "处理成功", null));
- }
- }
六、视图层
detail.html
这里只贴出评论列表的代码
- <div class="comment-list">
- <div class="list-item reply-wrap"
- th:each="comment,userStat:${post.commentList}"
- data-th-attr="data-commentid=${comment.id}">
- <div class="user-face">
- <a th:href="@{'/u/'+${comment.user.username}}"
- target="_blank">
- <img th:src="${comment.user.avatar}"
- th:alt="${comment.user.nickname}">
- </a>
- </div>
- <div class="con">
- <div class="user">
- <a th:href="@{'/u/'+${comment.user.username}}"
- target="_blank"
- class="name"
- th:text="${comment.user.nickname}"></a>
- </a>
- <span class="comment-author-mark"
- th:if="${comment.user.id==post.user.id}">作者</span>
- <span th:text="${'Lv'+comment.user.badge.level}"
- th:class="${comment.user.badge.style}"></span>
- </div>
- <p class="text" th:text="${comment.content}"></p>
- <div class="info">
- <span class="floor"
- th:text="${(userStat.size-userStat.count+1)+'楼'}"></span>
- <span class="time"
- th:text="${comment.getRelativeCreateTime()}"></span>
- <span class="reply btn-hover"
- sec:authorize="isAuthenticated()">回复</span>
- <div class="operation"
- sec:authorize="isAuthenticated()">
- <div class="spot"></div>
- <div class="opera-list" style="display: none;">
- <ul>
- <li class="comment-delete-btn"
- th:if="${session.user != null and comment.user.id==session.user.id}">
- 删除
- </li>
- <li class="comment-report-btn"
- th:unless="${session.user != null and comment.user.id==session.user.id}">
- 举报
- </li>
- </ul>
- </div>
- </div>
- </div>
- <div class="reply-box">
- <div class="reply-item reply-wrap"
- th:each="reply:${comment.commentList}"
- data-th-attr="data-commentid=${reply.id}">
- <a th:href="@{'/u/'+${reply.user.username}}"
- target="_blank" class="reply-face">
- <img th:src="${reply.user.avatar}"
- th:alt="${reply.user.nickname}">
- </a>
- <div class="reply-con">
- <div class="user">
- <a th:href="@{'/u/'+${reply.user.username}}"
- target="_blank"
- class="name "
- th:text="${reply.user.nickname}">言曌</a>
- <a th:href="@{/help/level}"
- target="_blank">
- </a>
- <span th:if="${reply.replyUser != null}">回复
- <a th:href="@{'/u/'+${reply.replyUser.username}}"
- class="name"
- th:text="${' '+reply.replyUser.nickname}"></a>
- </span>:
- <span class="text-con"
- th:text="${reply.content}"></span>
- </div>
- <div class="info">
- <span class="time"
- th:text="${reply.getRelativeCreateTime()}"></span>
- <span class="reply btn-hover"
- data-th-attr="data-replyid=${reply.id}">回复</span>
- <div class="operation"
- sec:authorize="isAuthenticated()">
- <div class="spot"></div>
- <div class="opera-list"
- style="display: none;">
- <ul>
- <li class="comment-delete-btn"
- th:if="${session.user != null and reply.user.id==session.user.id}">
- 删除
- </li>
- <li class="comment-report-btn"
- th:unless="${session.user != null and reply.user.id==session.user.id}">
- 举报
- </li>
- </ul>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
main.js
- // 提交评论
- $(document).on("click", ".comment-submit", function () {
- var _ctx = $("meta[name='ctx']").attr("content");
- var token = $("meta[name='_csrf']").attr("content");
- var header = $("meta[name='_csrf_header']").attr("content");
- var postId = $("#comment").attr("data-postid");
- var commentId = $(this).attr("data-commentid");
- var replyId = $(this).attr("data-replyid");
- var commentContent = $(this).prev(".comment-content").val();
- if (commentContent.length < 1) {
- layer.msg("评论不可为空!", {icon: 2, anim: 6});
- return false;
- }
- if (commentContent.length > 500) {
- layer.msg("单条评论文字不能超过500个字符!", {icon: 2, anim: 6});
- return false;
- }
- $.ajax({
- url: _ctx + "/comments",
- type: 'POST',
- data: {
- postId: postId,
- commentId: commentId,
- replyId: replyId,
- commentContent: commentContent
- },
- beforeSend: function (request) {
- request.setRequestHeader(header, token); // 添加 CSRF Token
- },
- success: function (data) {
- if (data.success) {
- layer.msg("评论成功!", {icon: 1});
- $("#comment-wrapper").load(window.location.href + " #comment");
- } else {
- layer.alert(data.message, {icon: 2});
- }
- },
- error: function () {
- layer.msg("出现错误,请尝试刷新页面!", {icon: 2, anim: 6});
- }
- });
- });
- //删除评论
- $(document).on('click', '.comment-delete-btn', function () {
- var _ctx = $("meta[name='ctx']").attr("content");
- var token = $("meta[name='_csrf']").attr("content");
- var header = $("meta[name='_csrf_header']").attr("content");
- //你确定要删除吗?
- var currentNode = $(this);
- var postId = $("#comment").attr("data-postid");
- var commentId = currentNode.parents(".reply-wrap").attr("data-commentid");
- //删除
- $.ajax({
- url: _ctx + "/comments/" + commentId + "?postId=" + postId,
- type: 'DELETE',
- beforeSend: function (request) {
- request.setRequestHeader(header, token); // 添加 CSRF Token
- },
- success: function (data) {
- if (data.success) {
- layer.msg("删除成功!", {icon: 1});
- var count = $(".results").html();
- currentNode.parents(".reply-wrap:first").remove();
- $(".results").load(window.location.href + " .results");
- } else {
- layer.alert(data.message, {icon: 2});
- }
- },
- error: function () {
- layer.msg("出现错误,请尝试刷新页面!", {icon: 2, anim: 6});
- }
- });
- });
css 代码比较多,这里就不贴出来
2019年03月15日 11:33:53
发个源码好吗,谢谢哦
2019年03月14日 23:54:40
如果是高并发怎么办?
2019年05月26日 00:13:26
一个留言本有什么高并发?我去
2019年03月13日 21:48:27
这都可以
2019年03月07日 17:39:16
能给发个源码吗,多谢哦326131423@qq.com
2019年03月15日 11:35:02
:mrgreen: 你好
2018年08月11日 08:47:10
自己没事想捅咕捅咕,没想到你这 这么全~ 不过,没CSS,样式不能显示! 你那还有源码么?求分享一下 a1016844010@qq.com邮箱
2018年12月22日 20:45:18
6666啊
2019年03月07日 16:13:54
不错,很实用,多谢