From c9d27b2b0cf3fb69ce419ead57db08631c92dbef Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 17 Sep 2026 04:44:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AE=A1=E7=90=86=E5=88=A0=E8=AF=84?= =?UTF-8?q?=E5=AF=B9=E5=85=AC=E4=BC=97=E5=89=A5=E7=A6=BB=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=EF=BC=8C=E8=AF=84=E8=AE=BA=E8=80=85=E6=9C=AC?= =?UTF-8?q?=E4=BA=BA=E4=BB=8D=E5=8F=AF=E8=A7=81=E5=8E=9F=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/model/models.go | 2 +- backend/service/comment.go | 53 +++++--- backend/service/comment_node_test.go | 125 +++++++++++++++++++ backend/service/post_interact.go | 104 ++++++++-------- frontend/app/globals.css | 41 +++++-- frontend/app/post/[id]/page.tsx | 10 +- frontend/components/AcceptedAnswerCard.tsx | 98 ++++++++++----- frontend/components/CommentSection.tsx | 135 ++++++++++++--------- frontend/components/DeleteCommentModal.tsx | 2 +- frontend/lib/api.ts | 9 +- 10 files changed, 405 insertions(+), 174 deletions(-) create mode 100644 backend/service/comment_node_test.go diff --git a/backend/model/models.go b/backend/model/models.go index b2fb726..eb7e1dd 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -306,7 +306,7 @@ type PostAttachmentUnlock struct { type Comment struct { ID uint `gorm:"primaryKey" json:"id"` PostID uint `gorm:"index;not null" json:"post_id"` - UserID uint `gorm:"index;not null" json:"user_id"` + UserID uint `gorm:"index;not null" json:"user_id,omitempty"` ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层) RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填 Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1 diff --git a/backend/service/comment.go b/backend/service/comment.go index 51bcc9d..bb4b80f 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -32,11 +32,13 @@ func NewCommentService(db *gorm.DB) *CommentService { // CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略 type CommentNode struct { model.Comment - Deleted bool `json:"deleted,omitempty"` // 软删占位 - StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者 - Edited bool `json:"edited,omitempty"` // 相对创建已编辑 + Deleted bool `json:"deleted,omitempty"` // 软删占位 + StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者 + Edited bool `json:"edited,omitempty"` // 相对创建已编辑 DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开) Replies []CommentNode `json:"replies,omitempty"` + // 覆盖嵌入的 User:指针 + omitempty,管理删对公众可整段省略(避免空对象仍进 JSON/DOM) + User *model.User `json:"user,omitempty"` } // CommentActorBrief 评论相关公开用户摘要(删评管理员链接等) @@ -119,14 +121,14 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A build = func(pid uint) []CommentNode { out := []CommentNode{} for _, c := range children[pid] { - out = append(out, toCommentNode(c, build(c.ID), revealAudit, deleters)) + out = append(out, toCommentNode(c, build(c.ID), revealAudit, viewerID, deleters)) placed[c.ID] = true } return out } result := make([]CommentNode, len(roots)) for i := range roots { - result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, deleters) + result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, viewerID, deleters) placed[roots[i].ID] = true } for _, r := range replies { @@ -135,7 +137,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A } for i := range result { if result[i].ID == *r.RootID { - result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, deleters)) + result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, viewerID, deleters)) break } } @@ -178,14 +180,25 @@ func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActo return out } -// toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。 -func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode { +// commentAuthorPtr 有预加载作者时返回拷贝,供 JSON omitempty 整段省略。 +func commentAuthorPtr(c model.Comment) *model.User { + if c.User.ID == 0 { + return nil + } + u := c.User + return &u +} + +// toCommentNode 软删占位:公众清空正文;版主与评论者本人保留原文。 +// 自删理由仅版主可见。管理删:公众与其他登录用户不返回评论者身份。 +func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, viewerID uint, deleters map[uint]CommentActorBrief) CommentNode { edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute if !c.DeletedAt.Valid { - return CommentNode{Comment: c, Edited: edited, Replies: replies} + return CommentNode{Comment: c, Edited: edited, Replies: replies, User: commentAuthorPtr(c)} } staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID - if !revealAudit { + isCommenter := viewerID != 0 && viewerID == c.UserID + if !revealAudit && !isCommenter { c.Content = "" } if !staff { @@ -201,6 +214,11 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del deleter = &cp } } + // 版主审计、评论者本人仍可见身份;其余观众剥离 + if staff && !revealAudit && !isCommenter { + c.UserID = 0 + c.User = model.User{} + } return CommentNode{ Comment: c, Deleted: true, @@ -208,6 +226,7 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del Edited: edited, DeletedByUser: deleter, Replies: replies, + User: commentAuthorPtr(c), } } @@ -356,7 +375,7 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI query := s.db.Table("comments"). Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at"). Joins("JOIN posts ON posts.id = comments.post_id"). - Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished) + Where("comments.user_id = ? AND comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished) var total int64 if err := query.Count(&total).Error; err != nil { @@ -378,7 +397,7 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) { var total int64 err := s.db.Table("comments"). Joins("JOIN posts ON posts.id = comments.post_id"). - Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished). + Where("comments.user_id = ? AND comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished). Count(&total).Error return total, err } @@ -603,16 +622,16 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil { return nil, err } - node := toCommentNode(comment, nil, false, nil) + node := toCommentNode(comment, nil, false, userID, nil) return &node, nil } // CommentEditHistoryItem 管理可见的评论修订记录 type CommentEditHistoryItem struct { - ID uint `json:"id"` - Editor CommentActorBrief `json:"editor"` - OldContent string `json:"old_content"` - CreatedAt time.Time `json:"created_at"` + ID uint `json:"id"` + Editor CommentActorBrief `json:"editor"` + OldContent string `json:"old_content"` + CreatedAt time.Time `json:"created_at"` } // ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页) diff --git a/backend/service/comment_node_test.go b/backend/service/comment_node_test.go new file mode 100644 index 0000000..2b39d80 --- /dev/null +++ b/backend/service/comment_node_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +func staffDeletedComment() model.Comment { + uid := uint(7) + c := model.Comment{ + ID: 11, + PostID: 22, + UserID: uid, + Content: "不该出现在公开 JSON", + Status: model.ContentStatusPublished, + DeletedBy: 9, + User: model.User{ + ID: uid, + Username: "alice", + Nickname: "爱丽丝", + Avatar: "/uploads/avatars/alice.jpg", + }, + } + c.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true} + c.CreatedAt = time.Now().Add(-time.Hour) + c.UpdatedAt = c.CreatedAt + return c +} + +func marshalNode(t *testing.T, n CommentNode) map[string]any { + t.Helper() + b, err := json.Marshal(n) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return raw +} + +func TestToCommentNodeHidesStaffDeletedAuthorFromPublic(t *testing.T) { + c := staffDeletedComment() + raw := marshalNode(t, toCommentNode(c, nil, false, 0, nil)) + if _, ok := raw["user"]; ok { + t.Fatalf("公众 JSON 不应含 user: %v", raw["user"]) + } + if _, ok := raw["user_id"]; ok { + t.Fatalf("公众 JSON 不应含 user_id: %v", raw["user_id"]) + } + if raw["staff_deleted"] != true { + t.Fatalf("staff_deleted=%v", raw["staff_deleted"]) + } + if s, _ := raw["content"].(string); s != "" { + t.Fatalf("公众不应看到正文: %q", s) + } + blob, _ := json.Marshal(raw) + for _, leak := range []string{"alice", "爱丽丝", "/uploads/avatars/alice.jpg"} { + if strings.Contains(string(blob), leak) { + t.Fatalf("公众 JSON 泄漏身份 %q: %s", leak, blob) + } + } +} + +func TestToCommentNodeKeepsAuthorForCommenter(t *testing.T) { + c := staffDeletedComment() + raw := marshalNode(t, toCommentNode(c, nil, false, 7, nil)) + if raw["user_id"] != float64(7) { + t.Fatalf("评论者应看到 user_id: %v", raw["user_id"]) + } + user, ok := raw["user"].(map[string]any) + if !ok { + t.Fatalf("评论者应看到 user") + } + if user["username"] != "alice" { + t.Fatalf("username=%v", user["username"]) + } + if raw["content"] != "不该出现在公开 JSON" { + t.Fatalf("评论者应看到原文, got %v", raw["content"]) + } +} + +func TestToCommentNodeKeepsAuthorForModerator(t *testing.T) { + c := staffDeletedComment() + raw := marshalNode(t, toCommentNode(c, nil, true, 99, nil)) + if raw["user_id"] != float64(7) { + t.Fatalf("版主应看到 user_id") + } + if _, ok := raw["user"]; !ok { + t.Fatal("版主应看到 user") + } + if raw["content"] != "不该出现在公开 JSON" { + t.Fatalf("版主应看到原文, got %v", raw["content"]) + } +} + +func TestToCommentNodeSelfDeleteKeepsAuthor(t *testing.T) { + c := staffDeletedComment() + c.DeletedBy = c.UserID + raw := marshalNode(t, toCommentNode(c, nil, false, 0, nil)) + if raw["staff_deleted"] != nil && raw["staff_deleted"] != false { + t.Fatalf("自删不应标 staff_deleted: %v", raw["staff_deleted"]) + } + if raw["user_id"] != float64(7) { + t.Fatalf("自删应保留作者: %v", raw["user_id"]) + } + if s, _ := raw["content"].(string); s != "" { + t.Fatalf("公众不应看到自删正文: %q", s) + } +} + +func TestToCommentNodeSelfDeleteKeepsContentForCommenter(t *testing.T) { + c := staffDeletedComment() + c.DeletedBy = c.UserID + raw := marshalNode(t, toCommentNode(c, nil, false, 7, nil)) + if raw["content"] != "不该出现在公开 JSON" { + t.Fatalf("自删评论者应看到原文, got %v", raw["content"]) + } +} diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go index dc10c41..fc28609 100644 --- a/backend/service/post_interact.go +++ b/backend/service/post_interact.go @@ -38,7 +38,7 @@ type BountyMeta struct { Escrowed bool `json:"escrowed"` Refunded bool `json:"refunded"` Expired bool `json:"expired"` - EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写 + EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写 ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清 } @@ -58,16 +58,16 @@ type LotteryMeta struct { } var ( - ErrPollClosed = errors.New("投票已结束") - ErrAlreadyVoted = errors.New("你已投过票") - ErrInvalidPollOpt = errors.New("无效的投票选项") - ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置") - ErrBountySettled = errors.New("悬赏已结算") - ErrBountyNotEscrow = errors.New("悬赏未托管") - ErrLotteryDrawn = errors.New("已开奖") - ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖") + ErrPollClosed = errors.New("投票已结束") + ErrAlreadyVoted = errors.New("你已投过票") + ErrInvalidPollOpt = errors.New("无效的投票选项") + ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置") + ErrBountySettled = errors.New("悬赏已结算") + ErrBountyNotEscrow = errors.New("悬赏未托管") + ErrLotteryDrawn = errors.New("已开奖") + ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖") ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置") - ErrQuestionNotType = errors.New("本文不是问答帖") + ErrQuestionNotType = errors.New("本文不是问答帖") ) func parseQuestionMeta(raw string) (*QuestionMeta, error) { @@ -476,17 +476,12 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) ( // AcceptedAnswer 问答/悬赏已采纳答案(详情页正文下展示;与评论分页无关) type AcceptedAnswer struct { - ID uint `json:"id"` - Floor int `json:"floor"` - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` - Deleted bool `json:"deleted,omitempty"` - User struct { - ID uint `json:"id"` - Username string `json:"username"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - } `json:"user"` + ID uint `json:"id"` + Floor int `json:"floor"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Deleted bool `json:"deleted,omitempty"` + User *CommentActorBrief `json:"user,omitempty"` // 管理删对公众省略 } type QuestionState struct { @@ -554,17 +549,17 @@ type LotteryEntrant struct { } type LotteryState struct { - Slots int `json:"slots"` - Prizes []LotteryPrize `json:"prizes"` - Drawn bool `json:"drawn"` - Closed bool `json:"closed"` - EndsAt string `json:"ends_at,omitempty"` - EntryCount int `json:"entry_count"` - Eligible bool `json:"eligible"` - CooldownHours int `json:"cooldown_hours"` - CanDraw bool `json:"can_draw"` - CanEditPrizes bool `json:"can_edit_prizes"` - Winners []LotteryEntrant `json:"winners"` + Slots int `json:"slots"` + Prizes []LotteryPrize `json:"prizes"` + Drawn bool `json:"drawn"` + Closed bool `json:"closed"` + EndsAt string `json:"ends_at,omitempty"` + EntryCount int `json:"entry_count"` + Eligible bool `json:"eligible"` + CooldownHours int `json:"cooldown_hours"` + CanDraw bool `json:"can_draw"` + CanEditPrizes bool `json:"can_edit_prizes"` + Winners []LotteryEntrant `json:"winners"` } func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) { @@ -580,23 +575,23 @@ func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, vi switch pt { case model.PostTypeQuestion: - detail.Question = s.buildQuestionState(post, canManage) + detail.Question = s.buildQuestionState(post, viewerID, canManage, isMod) case model.PostTypePoll: detail.Poll = s.buildPollState(post, viewerID, canManage) case model.PostTypeBounty: - detail.Bounty = s.buildBountyState(post, canManage) + detail.Bounty = s.buildBountyState(post, viewerID, canManage, isMod) case model.PostTypeLottery: detail.Lottery = s.buildLotteryState(post, viewerID, canManage) } } -func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *QuestionState { +func (s *PostService) buildQuestionState(post *model.Post, viewerID uint, canManage, isMod bool) *QuestionState { m, err := parseQuestionMeta(post.TypeMeta) if err != nil || m == nil { m = &QuestionMeta{} } floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID) - answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor) + answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod) solvedAt := m.SolvedAt if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() { solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间 @@ -610,7 +605,7 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques CanAccept: canManage && !m.Solved, CanSolve: canManage && !m.Solved, // 已采纳答案后不可重新打开;仅「手动标已解决」可撤回 - CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0, + CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0, } } @@ -713,7 +708,7 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b } } -func (s *PostService) buildBountyState(post *model.Post, canManage bool) *BountyState { +func (s *PostService) buildBountyState(post *model.Post, viewerID uint, canManage, isMod bool) *BountyState { m, err := parseBountyMeta(post.TypeMeta) if err != nil { return nil @@ -724,7 +719,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty Points: m.Points, AcceptedCommentID: m.AcceptedCommentID, AcceptedFloor: floor, - AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor), + AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod), Escrowed: m.Escrowed, Refunded: m.Refunded, Expired: m.Expired, @@ -736,7 +731,8 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty } // loadAcceptedAnswer 加载已采纳评论快照(公开可见正文);软删则清空正文并标记 deleted。 -func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *AcceptedAnswer { +// 管理删:公众不返回评论者身份(与评论树 toCommentNode 一致)。 +func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, viewerID uint, isMod bool) *AcceptedAnswer { if postID == 0 || commentID == 0 { return nil } @@ -756,13 +752,23 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc Floor: floor, CreatedAt: c.CreatedAt, } - ans.User.ID = c.User.ID - ans.User.Username = c.User.Username - ans.User.Nickname = c.User.Nickname - ans.User.Avatar = c.User.Avatar + staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID + hideAuthor := staff && !isMod && viewerID != c.UserID + if !hideAuthor && c.User.ID != 0 { + ans.User = &CommentActorBrief{ + ID: c.User.ID, + Username: c.User.Username, + Nickname: c.User.Nickname, + Avatar: c.User.Avatar, + } + } if c.DeletedAt.Valid { ans.Deleted = true - ans.Content = "" + if isMod || viewerID == c.UserID { + ans.Content = c.Content + } else { + ans.Content = "" + } return ans } ans.Content = c.Content @@ -1513,12 +1519,10 @@ func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*mo func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) { detail := buildPostDetail(post) - locked, hint := s.evalContentAccess(post, viewerID, nil) - detail.ContentLocked = locked + sanitized, fullyLocked, hint := s.sanitizePostContent(post, viewerID, nil) + detail.Content = sanitized + detail.ContentLocked = fullyLocked detail.AccessHint = hint - if locked { - detail.Content = "" - } atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID) detail.Attachments = atts s.fillInteractState(detail, post, viewerID, nil) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7271907..8470558 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -3913,7 +3913,7 @@ html.dark .prose-content pre[data-theme] span { font-size: 13.5px; } -/* 软删占位:头像/昵称马赛克,可读结构但不可辨认身份 */ +/* 软删占位:匿名马赛克(不含真实头像/昵称) */ .cmt-mosaic-avatar { position: relative; display: inline-flex; @@ -3923,9 +3923,11 @@ html.dark .prose-content pre[data-theme] span { user-select: none; flex-shrink: 0; } -.cmt-mosaic-avatar > * { - filter: blur(7px) saturate(0.35) contrast(0.9); - transform: scale(1.2); +.cmt-mosaic-avatar-fill { + display: block; + width: 100%; + height: 100%; + background: color-mix(in srgb, var(--ink-3) 50%, var(--panel)); } .cmt-mosaic-avatar::after { content: ""; @@ -3947,15 +3949,34 @@ html.dark .prose-content pre[data-theme] span { pointer-events: none; } .cmt-mosaic-name { + position: relative; display: inline-block; - max-width: 9rem; + width: 4.5rem; + height: 14px; + border-radius: 4px; overflow: hidden; - white-space: nowrap; - filter: blur(5px); - letter-spacing: 0.04em; - user-select: none; + background: color-mix(in srgb, var(--ink-3) 40%, var(--panel)); + pointer-events: none; + user-select: none; + vertical-align: middle; +} +.cmt-mosaic-name::after { + content: ""; + position: absolute; + inset: 0; + background-image: + repeating-linear-gradient( + 0deg, + color-mix(in srgb, var(--ink) 18%, transparent) 0 1px, + transparent 1px 3px + ), + repeating-linear-gradient( + 90deg, + color-mix(in srgb, var(--ink) 18%, transparent) 0 1px, + transparent 1px 3px + ); + mix-blend-mode: soft-light; pointer-events: none; - transform: scaleY(1.05); } .cmt-tombstone { border-radius: 12px; diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 7a81b0e..d0745d5 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -546,10 +546,12 @@ export default async function PostDetailPage({ params, searchParams }: PageProps "@type": "Comment", text: acceptedAnswer.content, dateCreated: acceptedAnswer.created_at, - author: { - "@type": "Person", - name: acceptedAnswer.user.nickname || acceptedAnswer.user.username, - }, + author: acceptedAnswer.user + ? { + "@type": "Person", + name: acceptedAnswer.user.nickname || acceptedAnswer.user.username, + } + : undefined, url: `#comment-${acceptedAnswer.id}`, }, }), diff --git a/frontend/components/AcceptedAnswerCard.tsx b/frontend/components/AcceptedAnswerCard.tsx index 6daa847..392eb96 100644 --- a/frontend/components/AcceptedAnswerCard.tsx +++ b/frontend/components/AcceptedAnswerCard.tsx @@ -16,7 +16,8 @@ export default async function AcceptedAnswerCard({ /** 解决/采纳时刻;缺省用答案创建时间 */ solvedAt?: string; }) { - const name = answer.user.nickname || answer.user.username || "用户"; + const author = answer.user; + const name = author ? author.nickname || author.username || "用户" : ""; const isQuestion = variant === "question"; const title = isQuestion ? "问答" : "悬赏"; const statusLabel = isQuestion ? "已解决" : "已采纳"; @@ -48,42 +49,73 @@ export default async function AcceptedAnswerCard({ {answerLabel} - - - -
- - {name} - -

- - {answer.floor > 0 && ( - <> - · - - #{answer.floor} - - - )} -

-
+ {author ? ( + <> + + + +
+ + {name} + +

+ + {answer.floor > 0 && ( + <> + · + + #{answer.floor} + + + )} +

+
+ + ) : ( +
+

+ + {answer.floor > 0 && ( + <> + · + + #{answer.floor} + + + )} +

+
+ )}
{answer.deleted ? ( -

- 该答案已被删除 -

+ <> +

+ 该答案已被删除 +

+ {answer.content ? ( +
+ +
+ ) : null} + ) : ( )} diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 00fe776..1c9d8d5 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -75,30 +75,18 @@ function findComment(nodes: CommentNode[], id: number): CommentNode | null { return null; } -/** 马赛克头像:保留轮廓,不可辨认 */ -function MosaicAvatar({ - name, - src, - size, -}: { - name: string; - src?: string; - size: number; -}) { +/** 匿名马赛克头像:不含真实 src / 姓名,避免进 DOM */ +function MosaicAvatar({ size }: { size: number }) { return ( - + ); } -/** 马赛克昵称 */ -function MosaicName({ text, className = "" }: { text: string; className?: string }) { - return ( - - {text || "用户"} - - ); +/** 匿名马赛克昵称条:固定宽度,不含真实文本 */ +function MosaicName() { + return ; } export default function CommentSection({ @@ -205,7 +193,9 @@ export default function CommentSection({ const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null; const deleteMode: "self" | "staff" = - deleteTarget && user && user.id === deleteTarget.user_id ? "self" : "staff"; + deleteTarget && user && user.id === (deleteTarget.user?.id ?? deleteTarget.user_id) + ? "self" + : "staff"; const purgeTarget = purgeTargetId != null ? findComment(comments, purgeTargetId) : null; const purgeReplyCount = purgeTarget ? countReplies(purgeTarget) : 0; @@ -333,7 +323,7 @@ export default function CommentSection({ const canRestore = (comment: CommentNode) => { if (!comment.deleted || !user) return false; if (canModerateBoard(user, boardId)) return true; - return user.id === comment.user_id && !comment.staff_deleted; + return user.id === (comment.user?.id ?? comment.user_id) && !comment.staff_deleted; }; const canApprove = (comment: CommentNode) => @@ -351,7 +341,7 @@ export default function CommentSection({ const openReply = (c: CommentNode) => { setEditingId(null); - setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username }); + setReplyTarget({ id: c.id, nickname: c.user?.nickname || c.user?.username || "用户" }); setReplyContent(""); }; @@ -490,16 +480,19 @@ export default function CommentSection({ const replyCount = countReplies(node); const isFloor = depth === 0; const isExpanded = isFloor ? expandedFloors.has(c.id) : true; - const parentAuthor = parent?.user?.nickname || parent?.user?.username; + const parentAuthor = parent?.user + ? parent.user.nickname || parent.user.username + : parent?.deleted + ? "已删除" + : undefined; - // 软删占位:自删不打码;管理删打码 + 管理员主页链接;版主可见原文 + // 软删占位:自删不打码;管理删对公众匿名马赛克(身份以 API 是否返回 user 为准) if (c.deleted) { - const displayName = c.user?.nickname || c.user?.username || "用户"; + const author = c.user; + const displayName = author?.nickname || author?.username || "用户"; const typeLabel = postDeleteTypeLabel(c.delete_type); const isMod = !!user && canModerateBoard(user, boardId); - const isCommenter = !!user && user.id === c.user_id; - // 管理删:仅普通访客打码;管理员与评论者本人看真实身份 - const showMosaic = !!c.staff_deleted && !isMod && !isCommenter; + const showMosaic = !!c.staff_deleted && !author; const selfReasonForStaff = !c.staff_deleted && !!c.delete_reason && isMod; const staffPublicMeta = c.staff_deleted && (!!typeLabel || !!c.delete_reason); const deleter = c.deleted_by_user; @@ -508,21 +501,23 @@ export default function CommentSection({ : ""; const identity = showMosaic ? ( - - ) : ( + + ) : author ? ( - + + ) : ( + ); const nameRow = showMosaic ? ( - - ) : ( - + + ) : author ? ( + + ) : ( + + {displayName} + ); const tombstoneBody = ( @@ -565,9 +567,11 @@ export default function CommentSection({

{c.delete_reason}

) : null} - {isMod && c.content ? ( + {c.content ? (
- 原文 · 仅管理可见 + + {isMod && user?.id !== author?.id ? "原文 · 仅管理可见" : "原文 · 仅你可见"} +
@@ -692,22 +696,33 @@ export default function CommentSection({ ); } + const author = c.user; + const authorName = author?.nickname || author?.username || "用户"; + if (!isFloor) { // 子回复:紧凑行式布局(抖音风格) const isReplyToReply = depth >= 2; return (
- - - + {author ? ( + + + + ) : ( + + )}
- - - {c.user.nickname || c.user.username} - - + {author ? ( + + + {authorName} + + + ) : ( + {authorName} + )} {c.status === "pending" && 待审} {acceptedCommentId === c.id && ( 已采纳 @@ -734,7 +749,7 @@ export default function CommentSection({ className={`cmt-reply-btn transition-colors ${ replyTarget?.id === c.id ? "!text-[var(--accent)]" : "" }`} - aria-label={`回复 ${c.user.nickname || c.user.username}`} + aria-label={`回复 ${authorName}`} data-tip="回复" > 回复 @@ -745,7 +760,7 @@ export default function CommentSection({ type="button" className="cmt-reply-btn !text-[var(--accent)]" disabled={acceptingId === c.id} - onClick={() => void handleAccept(c.id, c.user.id)} + onClick={() => author && void handleAccept(c.id, author.id)} data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"} > {acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"} @@ -848,16 +863,26 @@ export default function CommentSection({ style={{ borderColor: "var(--line)" }} >
- - - + {author ? ( + + + + ) : ( + + )}
- - - {c.user.nickname || c.user.username} + {author ? ( + + + {authorName} + + + ) : ( + + {authorName} - + )} {c.status === "pending" && 待审} {acceptedCommentId === c.id && ( 已采纳 @@ -882,7 +907,7 @@ export default function CommentSection({ className={`cmt-reply-btn transition-colors ${ replyTarget?.id === c.id ? "!text-[var(--accent)]" : "" }`} - aria-label={`回复 ${c.user.nickname || c.user.username}`} + aria-label={`回复 ${authorName}`} data-tip="回复" > 回复 @@ -893,7 +918,7 @@ export default function CommentSection({ type="button" className="cmt-reply-btn !text-[var(--accent)]" disabled={acceptingId === c.id} - onClick={() => void handleAccept(c.id, c.user.id)} + onClick={() => author && void handleAccept(c.id, author.id)} data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"} > {acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"} diff --git a/frontend/components/DeleteCommentModal.tsx b/frontend/components/DeleteCommentModal.tsx index 7633335..5828255 100644 --- a/frontend/components/DeleteCommentModal.tsx +++ b/frontend/components/DeleteCommentModal.tsx @@ -69,7 +69,7 @@ export default function DeleteCommentModal({ title={isStaff ? "删除评论" : "删除我的评论"} description={ isStaff - ? "评论区保留马赛克占位;读者可见删除类型,「其他」时还会展示补充理由。子回复不受影响。" + ? "评论区保留匿名马赛克占位,读者看不到评论者头像与名称;可见删除类型,「其他」时还会展示补充理由。子回复不受影响。" : "评论区保留马赛克占位,读者仅见「评论者已删除此评论」。你填写的理由仅管理员可见。" } onClose={onClose} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index dc55e66..c30ca6f 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -241,7 +241,8 @@ export interface AcceptedAnswer { content: string; created_at: string; deleted?: boolean; - user: { + /** 管理删对公众省略 */ + user?: { id: number; username: string; nickname: string; @@ -337,7 +338,8 @@ export interface LotteryState { export interface Comment { id: number; post_id: number; - user_id: number; + /** 管理删对公众省略 */ + user_id?: number; parent_id?: number | null; // 父评论 ID,null = 主评论(楼层) root_id?: number | null; // 所属楼层 ID,子评论必填 depth?: number; // 层级:主评论 0,子评论 = 父 + 1 @@ -347,7 +349,8 @@ export interface Comment { updated_at?: string; /** 相对创建已编辑(服务端判定) */ edited?: boolean; - user: User; + /** 管理删对公众省略;版主与评论者本人仍返回 */ + user?: User; /** 软删占位 */ deleted?: boolean; /** 管理删除(DeletedBy≠评论者) */