数据结构设计:
评论可以被回复, 回复也可以被回复, 以此连接下去就形成了树的结构
哪个用户评论、
评论的对象类型(视频、文章、用户动态...)、
具体哪个评论对象、
评论的内容、
评论的父亲节点(对于顶级评论没有父亲节点)、
根节点(如果想取出某一条评论的所有孩子节点, 也就是取出评论下方的所有回复就可以select * from comments where root_id=comment.id)、
回复了谁(如果不加这个字段需要先关联查询父节点、再从父节点关联查询用户)
本项目基于gin+gorm脚手架 https://github.com/Gourouting/singo
package models
import "time"
type Comment struct {
ID uint `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `sql:"index"`
User User `gorm:"foreignkey:UserID;association_foreignkey:ID"`
UserID uint `gorm:"not null"`
ReplyTo User `gorm:"foreignkey:ReplyToID;association_foreignkey:ID"`
ReplyToID uint `sql:"default:null"`
// 这里没有指定评论对象类型, 直接选择了只对视频评论
Video Video `gorm:"foreignkey:VideoID;association_foreignkey:ID"`
VideoID uint `gorm:"not null"`
Parent *Comment `gorm:"foreignkey:ParentID;association_foreignkey:ID;"`
ParentID uint `sql:"default:null"`
Root *Comment `gorm:"foreignkey:RootID;association_foreignkey:ID;"`
RootID uint `sql:"default:null"`
Content string `gorm:"not null"`
Replys []Comment `sql:"default:null"`
}
因为gorm默认不会添加外键约束, 需要手动添加
package models
//执行数据迁移
func migration() {
// 自动迁移模式
DB.AutoMigrate(&Video{}, &User{}, &Comment{})
DB.Model(&Comment{}).AddForeignKey("user_id", "users(id)",
"CASCADE", "CASCADE")
DB.Model(&Comment{}).AddForeignKey("reply_to_id", "users(id)",
"CASCADE", "CASCADE")
DB.Model(&Comment{}).AddForeignKey("video_id", "videos(id)",
"CASCADE", "CASCADE")
DB.Model(&Comment{}).AddForeignKey("parent_id", "comments(id)",
"CASCADE", "CASCADE")
DB.Model(&Comment{}).AddForeignKey("root_id", "comments(id)",
"CASCADE", "CASCADE")
}
添加了几条记录
![](https://static.studygolang.com/191024/120a98ffbe5960a990f964bd275cd568.png)
获取评论列表
func CommentList(c *gin.Context) {
var service services.CommentListService
if err := c.ShouldBind(&service); err != nil {
c.JSON(400, err.Error())
} else {
videoID, _ := strconv.Atoi(c.Param("video_id"))
res := service.List(uint(videoID))
c.JSON(200, res)
}
}
service
package services
import (
"mygin/e"
"mygin/models"
"mygin/serializers"
)
type CommentListService struct {
}
func (service *CommentListService) List(videoID uint) *serializers.Response {
// 取出顶级评论
var comments []models.Comment
if err := models.DB.Where("video_id=? and parent_id is null", videoID).
Preload("User").Find(&comments).Error; err != nil {
return &serializers.Response{
Status: e.SELECT_ERROR,
Message: e.GetMsg(e.SELECT_ERROR),
Error: err.Error(),
}
}
// 取出评论下方的回复、回复者、回复了谁
for i := 0; i < len(comments); i++ {
if err := models.DB.Where("root_id=?", comments[i].ID).
Preload("User").Preload("ReplyTo").
Find(&comments[i].Replys).Error; err != nil {
return &serializers.Response{
Status:e.SELECT_ERROR,
Message:e.GetMsg(e.SELECT_ERROR),
Error:err.Error(),
}
}
}
return &serializers.Response{
Status: e.SUCCESS,
Message: e.GetMsg(e.SUCCESS),
Data: serializers.BuildComments(comments),
}
}
serializer序列化器
package serializers
import (
"mygin/config"
"mygin/models"
)
type CommentSerializer struct {
ID uint `json:"id"`
User UserSerializer `json:"user"`
VideoID uint `json:"video_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
Replys []ReplySerializer `json:"replys"`
}
type ReplySerializer struct {
ID uint `json:"id"`
User UserSerializer `json:"user"`
ReplyTo UserSerializer `json:"reply_to"`
ParentID uint `json:"parent_id"`
RootID uint `json:"root_id"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
}
// 序列化回复
func BuildReply(item models.Comment) ReplySerializer {
return ReplySerializer{
ID: item.ID,
User: BuildUser(item.User),
ReplyTo: BuildUser(item.ReplyTo),
ParentID: item.ParentID,
RootID: item.RootID,
Content: item.Content,
CreatedAt: item.CreatedAt.Format(config.CurrentTime),
}
}
// 序列化评论
func BuildComment(item models.Comment) CommentSerializer {
var replys []ReplySerializer
if len(item.Replys) != 0 {
for _, reply := range item.Replys {
replys = append(replys, BuildReply(reply))
}
}
return CommentSerializer{
ID: item.ID,
User: BuildUser(item.User),
VideoID: item.VideoID,
Content: item.Content,
CreatedAt: item.CreatedAt.Format(config.CurrentTime),
Replys: replys,
}
}
func BuildComments(items []models.Comment) []CommentSerializer {
var comments []CommentSerializer
for _, item := range items {
comments = append(comments, BuildComment(item))
}
return comments
}
执行结果
![](https://static.studygolang.com/191024/9370caa913decfe773e6152510a3f6c4.png)
接下来是前端的编写
初始化定义
comments: [
{
id: 0,
user: {
id: 0,
username: '',
head_img: ''
},
video_id: 0,
content: '',
created_at: '',
replys: [ //回复,或子评论
{
id: 0,
user: {
id: 0,
username: '',
head_img: ''
},
reply_to: {
id: 0,
username: '',
head_img: ''
},
parent_id: 0,
root_id: 0,
content: '',
created_at: ''
}
]
}
]
评论表单
<el-form :model="ruleForm" :rules="rules" ref="ruleForm">
<el-form-item prop="content">
<el-input v-model="ruleForm.content" type="textarea"
placeholder="写下你的评论"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitComment"
style="float: right">提交评论</el-button>
</el-form-item>
</el-form>
<script>
export default {
name: 'comment',
props: [
'video_id'
],
data() {
return {
ruleForm: {
content: ''
},
rules: {
content: [
{ required: true, message: '请输入评论内容', trigger: 'submit' },
{ min: 3, message: '至少输入3个字符', trigger: 'submit' },
]
},
}
},
created() {
axios.get("http://localhost:8888/api/v1/comment/"+this.video_id).
then(rep => {
console.log(rep);
this.comments = rep.data.data;
})
},
methods: {
submitComment() {
this.$refs.ruleForm.validate((valid) => {
if (valid) {
axios.post("/comment", {
video_id: this.video_id,
content: this.ruleForm.content,
}, {
headers: {
token: this.$store.state.token // 附带用户token
}
}).then(rep => {
console.log(rep);
this.comments = this.comments||[];
this.comments.unshift(rep.data.data); // 添加到要展示的评论数组中
this.ruleForm.content = '' // 清除评论框内容
}).catch(err => {
console.log(err.response);
})
} else {
console.log('error submit!!');
return false;
}
});
},
}
}
</script>
对于提交评论来说很简单, 把当前用户、评论的对象、评论内容提交过去就行了, 但对于提交回复来说还要把回复了谁、父亲节点、根节点也提交过去
先来看一下评论列表的展示和提交回复的逻辑(具体的css这里没给出)
<div class="comment" v-for="item in comments">
<hr>
<!-- 展示当前评论用户 -->
<img :src="item.user.head_img">
<div>{{item.user.username}}</div>
<span class="date">{{item.created_at}}</span>
<p>{{item.content}}</p>
<!-- 展示回复input框 -->
<el-tag @click="showCommentInput(item)">回复</el-tag>
<!-- 展示评论下方的回复 -->
<div v-for="reply in item.replys">
<img :src="reply.user.head_img">
<span>{{reply.user.username}}</span><span>: </span>
<span>@{{reply.reply_to.username}}</span>
<div class="date">{{reply.created_at}}</div>
<p>{{reply.content}}</p>
<el-tag @click="showCommentInput(item, reply)">回复</el-tag>
</div>
<!-- 展示评论框 -->
<div v-if="current_root_id===item.id">
<el-form :model="ruleForm2" :rules="rules2" ref="ruleForm2">
<el-form-item prop="content">
<el-input v-model="ruleForm2.content"
type="textarea"
placeholder="写下你的评论">
</el-input>
</el-form-item>
<el-form-item>
<el-button style="float: right;"
@click="submitReply">确定</el-button>
</el-form-item>
</el-form>
</div>
</div>
<script>
export default {
data() {
return {
// 当点击展示回复框的时候记录下回复了谁、父亲节点、根节点, 方便提交
current_root_id: 0,
current_parent_id: 0,
current_reply_to_user_id: 0,
ruleForm2: {
content: ''
},
rules2: {
content: [
{ required: true, message: '请输入评论内容', trigger: 'submit' },
{ min: 3, message: '至少输入3个字符', trigger: 'submit' },
]
},
}
},
methods: {
// 展示回复框
showCommentInput(item, reply) {
// 如果回复了回复
if (reply) {
this.ruleForm2.content = "@" + reply.user.username + " ";
this.current_reply_to_user_id = reply.user.id;
this.current_parent_id = reply.id;
} else {
// 如果回复了评论
this.ruleForm2.content = '';
this.current_parent_id = item.id;
this.current_reply_to_user_id = item.user.id;
}
this.current_root_id = item.id;
},
// 提交回复
submitReply() {
this.$refs.ruleForm2[0].validate((valid) => {
if (valid) {
axios.post("/comment", {
reply_to_id: this.current_reply_to_user_id,
video_id: this.video_id,
parent_id: this.current_parent_id,
root_id: this.current_root_id,
content: this.ruleForm2.content,
}, {
headers: {
token: this.$store.state.token
}
}).then(rep => {
// 获取根节点
const comment = this.comments.find(item => item.id===this.current_root_id);
comment.replys = comment.replys||[];
// 插入到根节点的下方
comment.replys.push(rep.data.data);
this.ruleForm2.content = ''
}).catch(err => {
console.log(err);
})
} else {
console.log('error submit!!');
return false;
}
});
},
}
}
</script>
后台创建评论接口
func CreateComment(c *gin.Context) {
var service services.CreateCommentService
if err := c.ShouldBind(&service); err != nil {
c.JSON(400, err.Error())
} else {
// 从前端传过来的headers的token解析出当前用户
user := models.CurrentUser(c)
res := service.Create(user)
c.JSON(200, res)
}
}
service
package services
import (
"mygin/e"
"mygin/models"
"mygin/serializers"
)
// 表单验证
type CreateCommentService struct {
ReplyToID uint `json:"reply_to_id" form:"reply_to_id"`
VideoID uint `json:"video_id" form:"video_id" binding:"required"`
ParentID uint `json:"parent_id" form:"parent_id"`
RootID uint `json:"root_id" form:"root_id"`
Content string `json:"content" form:"content" binding:"required,min=3"`
}
func (service *CreateCommentService) Create(user *models.User) *serializers.Response {
comment := models.Comment{
UserID: user.ID,
ReplyToID: service.ReplyToID,
VideoID: service.VideoID,
ParentID: service.ParentID,
RootID: service.RootID,
Content: service.Content,
}
if err := models.DB.Create(&comment).Error; err != nil {
return &serializers.Response{
Status: e.CREATE_ERROR,
Message: e.GetMsg(e.CREATE_ERROR),
Error: err.Error(),
}
}
// 返回当前评论的用户和 replyTo用户(如果是回复的话)
comment.User = *user
if comment.ReplyToID != 0 {
var replyToUser models.User
if err := models.DB.Find(&replyToUser, service.ReplyToID).Error; err != nil {
return &serializers.Response{
Status:e.SELECT_ERROR,
Message:e.GetMsg(e.SELECT_ERROR),
Error:err.Error(),
}
}
comment.ReplyTo = replyToUser
return &serializers.Response{
Status: e.SUCCESS,
Message: e.GetMsg(e.SUCCESS),
Data: serializers.BuildReply(comment),
}
}
return &serializers.Response{
Status: e.SUCCESS,
Message: e.GetMsg(e.SUCCESS),
Data: serializers.BuildComment(comment),
}
}
效果:
![](https://static.studygolang.com/191024/4a2f7340b087e1efe893f6bb994d820e.gif)
有疑问加站长微信联系(非本文作者)
![](https://static.golangjob.cn/static/img/footer.png?imageView2/2/w/280)