首先进行分析:
一级评论就是父评论,二级评论三级评论四级评论是子评论,通过@name的方式进行回复父级评论
响应的格式是:(下面是一个有二级评论的响应格式)
{
"code": 200,
"rows": [
{
"createBy": null,
"createTime": null,
"updateBy": null,
"updateTime": null,
"remark": null,
"id": 172,
"content": "<p>aaa</p>",
"username": "admin",
"time": "2025-01-06 23:16:14",
"parentId": null,
"foreignId": 0,
"children": [
{
"createBy": null,
"createTime": null,
"updateBy": null,
"updateTime": null,
"remark": null,
"id": 173,
"content": "@admin zzz",
"username": "admin",
"time": "2025-01-07 17:34:47",
"parentId": 172,
"foreignId": 0,
"children": null,
}
]
},
一、后端实现
1.创建springboot工程
2.创建留言实体类
package com.ruoyi.comment.domain;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import javax.validation.constraints.Past;
/**
* 求职论坛对象 comment
*
* @author ruoyi
* @date 2024-12-11
*/
public class Comment extends BaseEntity {
private static final long serialVersionUID = 1L;
/**
* id
*/
private Long id;
/**
* 内容
*/
@Excel(name = "内容")
private String content;
/**
* 用户id
*/
@Excel(name = "用户id")
private String username;
/**
* 时间
*/
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "时间", width = 30, dateFormat = "yyyy-MM-dd")
private String time;
/**
* 父id
*/
@Excel(name = "父id")
private Long parentId;
/**
* 关联id
*/
@Excel(name = "关联id")
private Long foreignId;
private List<Comment> children;
public List<Comment> getChildren() {
return children;
}
public void setChildren(List<Comment> children) {
this.children = children;
}
public void setId(Long id) {
this.id = id;
}
public Long getId() {
return id;
}
public void setContent(String content) {
this.content = content;
}
public String getContent() {
return content;
}
public void setTime(String time) {
this.time = time;
}
public String getTime() {
return time;
}
public void setParentId(Long parentId) {
this.parentId = parentId;
}
public Long getParentId() {
return parentId;
}
public void setForeignId(Long foreignId) {
this.foreignId = foreignId;
}
public Long getForeignId() {
return foreignId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String toString() {
return "Comment{" +
"id=" + id +
", content='" + content + '\'' +
", username='" + username + '\'' +
", time='" + time + '\'' +
", parentId=" + parentId +
", foreignId=" + foreignId +
", children=" + children +
'}';
}
}
3.操作留言表的mapper接口
package com.ruoyi.comment.mapper;
import java.util.List;
import com.ruoyi.comment.domain.Comment;
import org.apache.ibatis.annotations.Param;
import org.springframework.jmx.export.annotation.ManagedOperationParameter;
/**
* 求职论坛Mapper接口
*
* @author ruoyi
* @date 2024-12-11
*/
public interface CommentMapper
{
/**
* 查询求职论坛
*
* @param id 求职论坛主键
* @return 求职论坛
*/
public Comment selectCommentById(Long id);
/**
* 查询求职论坛列表
*
* @param comment 求职论坛
* @return 求职论坛集合
*/
public List<Comment> selectCommentList(Comment comment);
public List<Comment> selectCommentListByid(Long id);
public List<Comment> selectCommentByParentId(@Param("parentId")Long parentId);
/**
* 新增求职论坛
*
* @param comment 求职论坛
* @return 结果
*/
public int insertComment(Comment comment);
/**
* 删除求职论坛
*
* @param id 求职论坛主键
* @return 结果
*/
public int deleteCommentById(Long id);
}
4.服务类
package com.ruoyi.comment.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.comment.mapper.CommentMapper;
import com.ruoyi.comment.domain.Comment;
import com.ruoyi.comment.service.ICommentService;
/**
* 求职论坛Service业务层处理
*
* @author ruoyi
* @date 2024-12-11
*/
@Service
public class CommentServiceImpl implements ICommentService
{
@Autowired
private CommentMapper commentMapper;
/**
* 查询求职论坛
*
* @param id 求职论坛主键
* @return 求职论坛
*/
@Override
public Comment selectCommentById(Long id)
{
return commentMapper.selectCommentById(id);
}
/**
* 查询求职论坛列表
*
* @param comment 求职论坛
* @return 求职论坛
*/
@Override
public List<Comment> selectCommentList(Comment comment)
{
return commentMapper.selectCommentList(comment);
}
@Override
public List<Comment> selectCommentByParentId(Long parentId) {
return commentMapper.selectCommentByParentId(parentId);
}
/**
* 新增求职论坛
*
* @param comment 求职论坛
* @return 结果
*/
@Override
public int insertComment(Comment comment)
{
return commentMapper.insertComment(comment);
}
@Override
public List<Comment> selectCommentListByid(Long id) {
return commentMapper.selectCommentListByid(id);
}
/**
* 删除求职论坛信息
*
* @param id 求职论坛主键
* @return 结果
*/
@Override
public int deleteCommentById(Long id)
{
return commentMapper.deleteCommentById(id);
}
}
5.编写Controller层
package com.ruoyi.comment.controller;
import java.lang.reflect.Array;
import java.util.*;
import javax.servlet.http.HttpServletResponse;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.service.ISysUserService;
import org.apache.poi.ss.usermodel.DateUtil;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.comment.domain.Comment;
import com.ruoyi.comment.service.ICommentService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
/**
* 求职论坛Controller
*
* @author ruoyi
* @date 2024-12-11
*/
@RestController
@RequestMapping("/comment/comment")
public class CommentController extends BaseController
{
@Autowired
private ICommentService commentService;
/**
* 查询求职论坛列表
*/
@PreAuthorize("@ss.hasPermi('comment:comment:list')")
@GetMapping("/list")
public TableDataInfo list(Comment comment) {
List<Comment> list = commentService.selectCommentList(comment);
List<Comment> rootComments = new ArrayList<>();
Map<Long, List<Comment>> childCommentsMap = new HashMap<>();
for (Comment comment1 : list) {
Long parentId = comment1.getParentId();
if (parentId == null) {
rootComments.add(comment1);
} else {
childCommentsMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(comment1);
}
}
for (Comment rootComment : rootComments) {
Long rootCommentId = rootComment.getId();
List<Comment> childComments = childCommentsMap.get(rootCommentId);
if (childComments != null) {
rootComment.setChildren(childComments);
}
}
return pagination(rootComments);
}
/**
* 新增求职论坛
*/
@PreAuthorize("@ss.hasPermi('comment:comment:add')")
@Log(title = "求职论坛", businessType = BusinessType.INSERT)
@PostMapping("/add")
public AjaxResult add(@RequestBody Comment comment)
{
comment.setUsername(SecurityUtils.getUsername());
comment.setTime(DateUtils.getTime());
return toAjax(commentService.insertComment(comment));
}
/**
* 删除求职论坛
*/
@PreAuthorize("@ss.hasPermi('comment:comment:remove')")
@Log(title = "求职论坛", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(commentService.deleteCommentByIds(ids));
}
}
6.编写sql语句
<?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.ruoyi.comment.mapper.CommentMapper">
<resultMap type="com.ruoyi.comment.domain.Comment" id="CommentResult">
<result property="id" column="id" />
<result property="content" column="content" />
<result property="username" column="username" />
<result property="time" column="time" />
<result property="parentId" column="parent_id" />
<result property="foreignId" column="foreign_id" />
<collection property="children" ofType="com.ruoyi.comment.domain.Comment">
<id property="id" column="parent_id"/>
<result property="content" column="parent_content"/>
<result property="username" column="parent_username"/>
<result property="time" column="parent_time"/>
<result property="parentId" column="parent_parent_id"/>
<result property="foreignId" column="parent_foreign_id"/>
</collection>
</resultMap>
<select id="selectCommentList" resultMap="CommentResult">
SELECT c.*, p.*
FROM comment c
LEFT JOIN comment p ON c.parent_id = p.id
<where>
<if test="content != null and content != ''">
AND c.content = #{content}
</if>
<if test="username != null">
AND c.username = #{username}
</if>
<if test="parentId != null">
AND c.parent_id = #{parentId}
</if>
<if test="foreignId != null">
AND c.foreign_id = #{foreignId}
</if>
</where>
ORDER BY
CASE WHEN c.parent_id IS NULL THEN c.time END DESC, -- 按时间降序排序父评论
CASE WHEN c.parent_id IS NOT NULL THEN c.id END ASC -- 按ID升序排序子评论
</select>
<select id="selectCommentById" parameterType="Long" resultType="com.ruoyi.comment.domain.Comment">
select * from comment where id = #{id}
</select>
<insert id="insertComment" parameterType="Comment" useGeneratedKeys="true" keyProperty="id">
insert into comment
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="content != null">content,</if>
<if test="username != null">username,</if>
<if test="time != null">time,</if>
<if test="parentId != null">parent_id,</if>
<if test="foreignId != null">foreign_id,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="content != null">#{content},</if>
<if test="username != null">#{username},</if>
<if test="time != null">#{time},</if>
<if test="parentId != null">#{parentId},</if>
<if test="foreignId != null">#{foreignId},</if>
</trim>
</insert>
<update id="updateComment" parameterType="Comment">
update comment
<trim prefix="SET" suffixOverrides=",">
<if test="content != null">content = #{content},</if>
<if test="username != null">username = #{username},</if>
<if test="time != null">time = #{time},</if>
<if test="parentId != null">parent_id = #{parentId},</if>
<if test="foreignId != null">foreign_id = #{foreignId},</if>
</trim>
where id = #{id}
</update>
<delete id="deleteCommentById" parameterType="Long">
delete from comment where id = #{id}
</delete>
<delete id="deleteCommentByIds" parameterType="String">
delete from comment where id in
<foreach item="id" collection="array" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
</mapper>
二、搭建前端
1.编写api
import request from '@/utils/request'
// 查询求职论坛列表
export function listComment(query) {
return request({
url: '/comment/comment/list',
method: 'get',
params: query
})
}
// 查询求职论坛详细
export function getComment(id) {
return request({
url: '/comment/comment/' + id,
method: 'get'
})
}
// 新增求职论坛
export function addComment(data) {
return request({
url: '/comment/comment/add',
method: 'post',
data: data
})
}
// 删除求职论坛
export function delComment(id) {
return request({
url: '/comment/comment/' + id,
method: 'delete'
})
}
2. 写前端的模块
首先是写前端的留言的发布以及回复和删除评论
<template>
<div id="building" style="margin-top: 10px; margin-bottom: 80px">
<el-card>
<div class="text-container">
<div style="padding: 20px; color: #888;">
<quill-editor
v-model="entity.content"
:options="editorOptions"
placeholder="添加评论..."
@blur="onEditorBlur"
></quill-editor>
<div style="text-align: right; padding: 10px;">
<el-button type="primary" @click="save">发送</el-button>
</div>
</div>
<!-- 渲染评论列表 -->
<div class="comment-list">
<comment-item
v-for="item in commentsList"
:key="item.id"
:item="item"
:depth="0"
@reply="reply"
@delete="del"
></comment-item>
</div>
<!-- 分页控件 -->
<el-pagination
v-if="commentsList.length > 0"
@current-change="handleCurrentChange"
:current-page="queryParam.pageNum"
:page-size="queryParam.pageSize"
:total="totalComments"
layout="total, prev, pager, next, jumper"
></el-pagination>
</div>
<img src="/static/img/img.png" alt="背景图片" class="background-image">
</el-card>
</div>
</template>
<script>
import { quillEditor } from 'vue-quill-editor';
import request from "@/utils/request";
import { listComment } from "@/api/comment/comment";
import CommentItem from "@/views/comment/comment/CommentItem";
import 'quill/dist/quill.core.css'; // Quill 样式
import 'quill/dist/quill.snow.css'; // Quill 雪花主题样式
import 'quill/dist/quill.bubble.css'; // Quill 气泡主题样式
import 'vue-quill-editor/dist/ssr'; // SSR 支持
export default {
name: "Comment",
components: {
CommentItem,
quillEditor
},
data() {
return {
loading: true,
commentsList: [],
totalComments: 0, // 用于保存总评论数
entity: {
content: '',
parentId: null // 回复关联的父评论ID
},
queryParam: {
pageNum: 1,
pageSize: 10,
id: '',
},
editorOptions: {
modules: {
toolbar: [
[{ 'header': [1, 2, false] }],
['bold', 'italic', 'underline'],
['link', 'image'],
['clean'] // 清除格式
]
},
placeholder: '添加评论...',
}
};
},
created() {
this.getlist(); // 组件创建时获取评论列表
},
methods: {
onEditorBlur() {
// 可以处理编辑器失去焦点的逻辑
},
getlist() {
this.loading = true;
listComment(this.queryParam).then(res => {
this.commentsList = res.rows; // 评论数据
this.totalComments = res.total; // 假设返回数据包含总评论数
this.loading = false;
});
},
handleCurrentChange(pageNum) {
this.queryParam.pageNum = pageNum; // 更新当前页码
this.getlist(); // 获取新页码的评论列表
},
reply(replyData) {
const dataToSend = {
content: replyData.content,
parentId: replyData.parentId,
};
request.post("/comment/comment/add", dataToSend).then(() => {
this.$message({
message: "回复成功",
type: "success"
});
this.getlist(); // 刷新评论列表
});
},
del(id) {
request.delete("/comment/comment/" + id).then(() => {
this.$message({
message: "删除成功",
type: "success"
});
this.getlist(); // 刷新评论列表
});
},
save() {
const dataToSend = {
content: this.entity.content,
parentId: null // 顶级评论的parentId为null
};
request.post("/comment/comment/add", dataToSend).then(() => {
this.$message({
message: "评论成功",
type: "success"
});
this.entity.content = ''; // 重置输入
this.getlist(); // 刷新评论列表
});
}
}
};
</script>
<style scoped>
#building {
position: relative;
width: 100%;
height: 100%;
padding: 20px;
border-radius: 10px;
}
.text-container {
position: relative;
z-index: 2;
}
.comment-list {
padding: 20px; /* 内边距 */
background: transparent; /* 透明背景 */
margin-top: 20px; /* 上部间距 */
}
.background-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
opacity: 0.5; /* 背景透明度 */
pointer-events: none;
}
</style>
3.展示子评论的模块
<template>
<div :style="{ paddingLeft: `${depth * 20}px`, marginBottom: '10px' }">
<div class="comment" v-if="item.content">
<strong>{{ item.username }}:</strong>
<div class="comment-content"><span v-html="item.content"></span></div>
<span class="comment-time">{{ item.time }}</span>
<el-button size="mini" @click="toggleReply">回复</el-button>
<el-button size="mini" @click="$emit('delete', item.id)">删除</el-button>
</div>
<!-- 回复框 -->
<div v-if="isReplying" class="reply-container">
<el-input
type="textarea"
v-model="replyContent"
:placeholder="getReplyPlaceholder"
rows="2"
style="width: 100%;"
></el-input>
<el-button type="primary" @click="submitReply">提交回复</el-button>
</div>
<!-- 渲染子评论 -->
<div v-if="item.children && item.children.length > 0" class="children-container">
<comment-item
v-for="child in item.children"
:key="child.id"
:item="child"
:depth="depth + 1"
@reply="$emit('reply', child)"
@delete="$emit('delete', child.id)"
></comment-item>
</div>
</div>
</template>
<script>
export default {
name: 'CommentItem',
props: {
item: {
type: Object,
required: true
},
depth: {
type: Number,
default: 0
}
},
data() {
return {
isReplying: false,
replyContent: ''
};
},
computed: {
getReplyPlaceholder() {
return this.depth === 0
? "请输入评论..."
: `@${this.item.username} 请输入回复...`; // 动态生成占位符
}
},
methods: {
toggleReply() {
this.isReplying = !this.isReplying;
if (this.isReplying) {
this.replyContent = `@${this.item.username} `; // 添加 @username
}
},
submitReply() {
if (!this.replyContent.trim()) {
this.$message({
message: "回复内容不能为空!",
type: "warning"
});
return;
}
this.$emit('reply', {
content: this.replyContent,
parentId: this.item.id // 传递当前评论的ID作为父ID
});
this.replyContent = '';
this.isReplying = false; // 隐藏回复输入框
}
}
};
</script>
<style scoped>
.comment {
background: #f5f5f5;
border: 1px solid #eee;
border-radius: 5px;
padding: 10px;
margin-bottom: 5px;
}
.comment-content {
padding: 15px; /* 内边距 */
margin-bottom: 10px; /* 下边距 */
background: transparent; /* 透明背景 */
border: none;
/* 背景模糊效果 */
}
.reply-container {
margin-top: 10px;
}
.comment-time {
color: #888;
font-size: 12px;
margin-left: 10px;
}
.children-container {
margin-left: 20px; /* 子评论的缩进 */
}
</style>
四、最后展示