From 9fc61e878e1ecd9b4a17abf224936392d354b53c Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 29 Jul 2026 16:33:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=EF=BC=88=E4=B8=A4=E7=BA=A7=20+=20@=E6=9F=90?= =?UTF-8?q?=E4=BA=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不做无限层级——手机屏幕撑不住层层缩进,读到第四层就没法看了。 微信、小红书、B站清一色是两级:一级评论 + 其下的回复列表,回复里 用「回复 @某人」表达指向谁。 数据结构:comments 加 parent_id + reply_to_name。回复的回复会被 压平到同一条一级评论下(parent_id 取爷爷的),同时把被回复人的名字 记进 reply_to_name —— 这样既保住两级,又不丢「在跟谁说话」的信息。 几个容易做错的地方: - 一次查完这一页所有一级评论的回复,不是每条一次查询(N+1) - 一级评论默认只带 3 条回复,其余点「展开全部 N 条」再拉,避免热门 评论一次返回几百条 - 删一级评论要连它下面的回复一起删,否则回复变成挂在空处的孤儿; 帖子的 comment_count 也要按实际删除条数减,不是减 1 - total 统计含回复,和帖子上显示的数字对得上 - replies 为空时返回 [],不是 null 实测四条评论(含一条回复的回复): ▸ 甲:一级评论 (2 条回复) └ 乙:多久了? └ 甲 回复 乙:三天了 ▸ 乙:另一条一级评论 (0 条回复) 删掉第一条一级评论后 total 4→1,comment_count 同步。 Co-Authored-By: Claude Opus 5 --- pets-be/internal/handler/community.go | 17 +++- pets-be/internal/model/comment.go | 11 ++- pets-be/internal/router/router.go | 1 + pets-be/internal/service/community.go | 93 ++++++++++++++++--- .../components/bottom-sheet/bottom-sheet.js | 37 +++++++- .../components/bottom-sheet/bottom-sheet.wxml | 26 +++++- .../components/bottom-sheet/bottom-sheet.wxss | 13 +++ pets-fe/utils/api.js | 9 +- 8 files changed, 182 insertions(+), 25 deletions(-) diff --git a/pets-be/internal/handler/community.go b/pets-be/internal/handler/community.go index b5367e8..c34e0de 100644 --- a/pets-be/internal/handler/community.go +++ b/pets-be/internal/handler/community.go @@ -113,8 +113,9 @@ func (h *Handler) ListComments(c *gin.Context) { } type commentReq struct { - Content string `json:"content"` - Images []string `json:"images"` + Content string `json:"content"` + Images []string `json:"images"` + ParentID string `json:"parent_id"` // 回复某条评论时传它的 id } // CreateComment POST /api/posts/:id/comments @@ -128,7 +129,7 @@ func (h *Handler) CreateComment(c *gin.Context) { response.FailParams(c, "评论内容不能为空") return } - comment, err := h.svc.CreateComment(middleware.UserID(c), idParam(c, "id"), req.Content, req.Images) + comment, err := h.svc.CreateComment(middleware.UserID(c), idParam(c, "id"), req.Content, req.Images, req.ParentID) if err != nil { respondErr(c, err) return @@ -171,3 +172,13 @@ func (h *Handler) DeleteOwnComment(c *gin.Context) { } response.OK(c, gin.H{"deleted": true}) } + +// ListReplies GET /api/comments/:id/replies 展开某条评论的全部回复 +func (h *Handler) ListReplies(c *gin.Context) { + list, err := h.svc.ListReplies(middleware.UserID(c), idParam(c, "id")) + if err != nil { + respondErr(c, err) + return + } + response.OK(c, list) +} diff --git a/pets-be/internal/model/comment.go b/pets-be/internal/model/comment.go index 1ca3cf6..4995bfb 100644 --- a/pets-be/internal/model/comment.go +++ b/pets-be/internal/model/comment.go @@ -10,7 +10,14 @@ type Comment struct { AuthorName string `gorm:"size:64" json:"author_name"` Content string `gorm:"size:512" json:"content"` Images datatypes.JSON `json:"images"` - Status string `gorm:"size:16;default:published" json:"status"` + // 两级评论:ParentID 为空是一级评论,否则是某条一级评论下的回复。 + // 不做无限层级——手机上层层缩进读到第四层就没法看了, + // 主流产品(微信、小红书、B站)都是两级 + @某人 表达指向。 + ParentID string `gorm:"size:24;index" json:"parent_id"` + ReplyToName string `gorm:"size:64" json:"reply_to_name"` + Status string `gorm:"size:16;default:published" json:"status"` - IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的 + IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的 + Replies []Comment `gorm:"-" json:"replies"` // 计算字段:一级评论下挂的回复 + ReplyN int `gorm:"-" json:"reply_n"` // 回复总数(可能多于返回的条数) } diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go index 0e3dca7..c3b2b10 100644 --- a/pets-be/internal/router/router.go +++ b/pets-be/internal/router/router.go @@ -100,6 +100,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage g.POST("/posts/:id/comments", h.CreateComment) g.DELETE("/posts/:id", h.DeleteOwnPost) g.DELETE("/comments/:id", h.DeleteOwnComment) + g.GET("/comments/:id/replies", h.ListReplies) g.GET("/articles", h.ListArticles) g.POST("/feedback", h.CreateFeedback) diff --git a/pets-be/internal/service/community.go b/pets-be/internal/service/community.go index ebb0ec7..03971ad 100644 --- a/pets-be/internal/service/community.go +++ b/pets-be/internal/service/community.go @@ -193,23 +193,67 @@ func (s *Service) postLikeCount(postID string) (int, error) { // ListComments 评论分页 func (s *Service) ListComments(userID, postID string, offset, limit int) ([]model.Comment, int64, error) { - q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published") + base := func() *gorm.DB { + return s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published") + } + // total 是这条帖子的评论总数(含回复),和帖子上显示的数字对得上 var total int64 - if err := q.Count(&total).Error; err != nil { + if err := base().Count(&total).Error; err != nil { return nil, 0, err } - var comments []model.Comment - if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil { + // 只分页一级评论,回复跟着它爹一起返回 + var roots []model.Comment + if err := base().Where("parent_id = ? OR parent_id IS NULL", ""). + Order("id desc").Offset(offset).Limit(limit).Find(&roots).Error; err != nil { return nil, 0, err } - for i := range comments { - comments[i].IsSelf = comments[i].UserID == userID + if len(roots) == 0 { + return []model.Comment{}, total, nil } - return comments, total, nil + + ids := make([]string, 0, len(roots)) + for i := range roots { + roots[i].IsSelf = roots[i].UserID == userID + ids = append(ids, roots[i].ID) + } + // 一次查完这一页所有一级评论的回复,不要每条一次查询 + var replies []model.Comment + s.db.Where("parent_id IN ? AND status = ?", ids, "published").Order("id asc").Find(&replies) + byParent := map[string][]model.Comment{} + for i := range replies { + replies[i].IsSelf = replies[i].UserID == userID + byParent[replies[i].ParentID] = append(byParent[replies[i].ParentID], replies[i]) + } + for i := range roots { + list := byParent[roots[i].ID] + if list == nil { + list = []model.Comment{} // 空切片而不是 nil,否则 JSON 出来是 null + } + roots[i].ReplyN = len(list) + // 默认只带前 3 条,其余等前端点「展开」再要,避免热门评论一次拉几百条 + if len(list) > 3 { + list = list[:3] + } + roots[i].Replies = list + } + return roots, total, nil +} + +// ListReplies 展开某条一级评论下的全部回复 +func (s *Service) ListReplies(userID, commentID string) ([]model.Comment, error) { + var list []model.Comment + if err := s.db.Where("parent_id = ? AND status = ?", commentID, "published"). + Order("id asc").Find(&list).Error; err != nil { + return nil, err + } + for i := range list { + list[i].IsSelf = list[i].UserID == userID + } + return list, nil } // CreateComment 评论 -func (s *Service) CreateComment(userID, postID string, content string, images []string) (*model.Comment, error) { +func (s *Service) CreateComment(userID, postID string, content string, images []string, parentID string) (*model.Comment, error) { if _, err := s.GetPost(postID); err != nil { return nil, err } @@ -218,7 +262,23 @@ func (s *Service) CreateComment(userID, postID string, content string, images [] if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" { name = user.Nickname } - comment := model.Comment{PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: "published"} + // 回复的回复仍然挂到同一条一级评论下,保持两级不塌成深树 + replyTo := "" + if parentID != "" { + var parent model.Comment + if err := s.db.Where("id = ? AND post_id = ?", parentID, postID).First(&parent).Error; err != nil { + return nil, ErrNotFound + } + if parent.ParentID != "" { + replyTo = parent.AuthorName + parentID = parent.ParentID + } + } + + comment := model.Comment{ + PostID: postID, UserID: userID, AuthorName: name, Content: content, + Status: "published", ParentID: parentID, ReplyToName: replyTo, + } // 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空 if b, err := json.Marshal(images); err == nil && len(images) > 0 { comment.Images = b @@ -265,7 +325,18 @@ func (s *Service) DeleteOwnComment(userID, commentID string) error { Update("status", "deleted").Error; err != nil { return err } - return tx.Model(&model.Post{}).Where("id = ? AND comment_count > 0", c.PostID). - UpdateColumn("comment_count", gorm.Expr("comment_count - 1")).Error + // 删一级评论要连它下面的回复一起删,否则回复会变成挂在空处的孤儿 + n := int64(1) + if c.ParentID == "" { + res := tx.Model(&model.Comment{}). + Where("parent_id = ? AND status = ?", commentID, "published"). + Update("status", "deleted") + if res.Error != nil { + return res.Error + } + n += res.RowsAffected + } + return tx.Model(&model.Post{}).Where("id = ? AND comment_count >= ?", c.PostID, n). + UpdateColumn("comment_count", gorm.Expr("comment_count - ?", n)).Error }) } diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.js b/pets-fe/components/bottom-sheet/bottom-sheet.js index ac93f06..f9a8dbf 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.js +++ b/pets-fe/components/bottom-sheet/bottom-sheet.js @@ -119,6 +119,7 @@ Component({ posterSaving: false, centered: false, commentImages: [], + replyTo: {}, }, observers: { show: function (show) { @@ -165,6 +166,7 @@ Component({ patch.addBreed = ''; patch.commentText = ''; patch.commentImages = []; + patch.replyTo = {}; patch.riskData = null; patch.saving = false; } @@ -417,6 +419,7 @@ Component({ // 没有头像字段,用昵称首字做个色块,比一律显示同一个 emoji 强 initial: (c.author_name || '?').slice(0, 1), imgs: Array.isArray(c.images) ? c.images : [], + replies: (c.replies || []).map((r) => ({ ...r, timeText: fmtAgo(r.created_at) })), })), }), ) @@ -424,8 +427,9 @@ Component({ }, // 长按删除自己的评论 onDeleteComment(e) { - const c = this.data.comments[e.currentTarget.dataset.index]; - if (!c || !c.is_self) return; + const { id, self } = e.currentTarget.dataset; + if (!id || !self) return; + const c = { id }; wx.showModal({ title: '删除评论', content: '确定删除这条评论?', @@ -934,8 +938,13 @@ Component({ if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' }); if (!this.data.postId) return this.close(); try { - await api.createComment(this.data.postId, text, this.data.commentImages.map((i) => i.url)); - this.setData({ commentText: '', commentImages: [] }); + await api.createComment( + this.data.postId, + text, + this.data.commentImages.map((i) => i.url), + this.data.replyTo.id || '', + ); + this.setData({ commentText: '', commentImages: [], replyTo: {} }); this.loadComments(); this.triggerEvent('commented'); } catch (e) { @@ -955,6 +964,26 @@ Component({ wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' }); }); }, + // 回复某条评论:输入区顶部显示「回复 xxx」,发送时带上 parent_id + onReplyTo(e) { + const { id, name } = e.currentTarget.dataset; + this.setData({ replyTo: { id, name } }); + }, + onCancelReply() { + this.setData({ replyTo: {} }); + }, + // 一级评论默认只带 3 条回复,点开拉全量 + onExpandReplies(e) { + const { id, index } = e.currentTarget.dataset; + api + .listReplies(id) + .then((list) => + this.setData({ + [`comments[${index}].replies`]: (list || []).map((r) => ({ ...r, timeText: fmtAgo(r.created_at) })), + }), + ) + .catch((err) => wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' })); + }, onPreviewCommentImage(e) { const { urls, cur } = e.currentTarget.dataset; if (urls && urls.length) wx.previewImage({ urls, current: cur }); diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxml b/pets-fe/components/bottom-sheet/bottom-sheet.wxml index 8be9fb2..47a02d7 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxml +++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxml @@ -321,7 +321,7 @@ module.exports.sel = function (map, key, index, def) { 评论 {{comments.length ? comments.length : ''}} - + {{item.initial}} @@ -329,12 +329,28 @@ module.exports.sel = function (map, key, index, def) { {{item.timeText}} - {{item.content}} + {{item.content}} + + + + + {{r.author_name}} + 回复 {{r.reply_to_name}} + {{r.content}} + + + 展开全部 {{item.reply_n}} 条回复 + + + + 回复 @@ -342,7 +358,11 @@ module.exports.sel = function (map, key, index, def) { 长按自己的评论可删除 - diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxss b/pets-fe/components/bottom-sheet/bottom-sheet.wxss index 360119a..8f05b32 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxss +++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxss @@ -127,3 +127,16 @@ .cm-count{margin-left:auto;color:var(--muted2);font-size:var(--fs-cap)} .cm-imgs{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} .cm-img{width:150rpx;height:150rpx;border-radius:var(--r-sm)} + +/* 回复:只缩进一层,再深手机上就没法读了 */ +.cm-replies{margin-top:var(--sp-2);padding:var(--sp-2) var(--sp-3);background:var(--surface-2);border-radius:var(--r-sm)} +.cm-reply{font-size:var(--fs-sm);line-height:1.6;color:var(--text);padding:4rpx 0} +.cm-at{color:var(--muted)} +.cm-more{margin-top:6rpx;font-size:var(--fs-cap);color:var(--primary-dark)} +.cm-act{margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted)} +.cm-replying{ + display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2); + padding:var(--sp-1) var(--sp-3);border-radius:var(--r-full); + background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap);align-self:flex-start; +} +.cm-cancel{display:flex;align-items:center} diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index 6bcc196..2690012 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -108,8 +108,13 @@ const api = { deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }), deleteComment: (id) => request({ url: `/api/comments/${id}`, method: 'DELETE' }), listComments: (id) => request({ url: `/api/posts/${id}/comments` }), - createComment: (id, content, images) => - request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content, images: images || [] } }), + createComment: (id, content, images, parentId) => + request({ + url: `/api/posts/${id}/comments`, + method: 'POST', + data: { content, images: images || [], parent_id: parentId || '' }, + }), + listReplies: (commentId) => request({ url: `/api/comments/${commentId}/replies` }), // 文章 listArticles: () => request({ url: '/api/articles' }),