vue+golang实现评论系统

aside section._1OhGeD · · 1632 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

数据结构设计:

评论可以被回复, 回复也可以被回复, 以此连接下去就形成了树的结构

哪个用户评论、
评论的对象类型(视频、文章、用户动态...)、
具体哪个评论对象、
评论的内容、
评论的父亲节点(对于顶级评论没有父亲节点)、
根节点(如果想取出某一条评论的所有孩子节点, 也就是取出评论下方的所有回复就可以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")
}

添加了几条记录

image.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
}

执行结果

image.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),
    }
}

效果:

深度录屏_google-chrome_20191024194933.gif

有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:aside section._1OhGeD

查看原文:vue+golang实现评论系统

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1632 次点击  
加入收藏 微博
2 回复  |  直到 2019-11-08 14:51:39
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传