diff --git a/backend/handler/comment.go b/backend/handler/comment.go index 29d7cbe..6ab70f6 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -106,7 +106,7 @@ func (h *Handlers) CreateComment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"comment": comment}) } -// DeleteComment 软删评论(占位) +// DeleteComment 软删评论(占位;自删与管理删均须填理由,管理删另须类型) func (h *Handlers) DeleteComment(c *gin.Context) { claims := middleware.CurrentUser(c) cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) @@ -114,18 +114,58 @@ func (h *Handlers) DeleteComment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) return } - if err := h.Comment.Delete(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil { + var body struct { + DeleteType string `json:"delete_type"` + DeleteReason string `json:"delete_reason"` + } + _ = c.ShouldBindJSON(&body) + if err := h.Comment.Delete(h.loadActor(claims.ID), uint(cid), claims.ID, service.DeleteCommentOpts{ + DeleteType: body.DeleteType, + DeleteReason: body.DeleteReason, + }); err != nil { switch { case errors.Is(err, service.ErrCommentNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrCommentForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentDeleteMeta), + errors.Is(err, service.ErrCommentInvalidType): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + msg := err.Error() + if msg == "删除理由不能超过 200 字" { + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + } + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// RestoreComment 恢复软删评论(自删作者或版主) +func (h *Handlers) RestoreComment(c *gin.Context) { + claims := middleware.CurrentUser(c) + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) + return + } + if err := h.Comment.Restore(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil { + switch { + case errors.Is(err, service.ErrCommentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": "无权限恢复此评论"}) + case errors.Is(err, service.ErrCommentNotDeleted): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } return } - c.JSON(http.StatusOK, gin.H{"message": "已删除"}) + c.JSON(http.StatusOK, gin.H{"message": "已恢复"}) } // PurgeComment 硬删评论及子树(仅版主;不占位) diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index 673eeda..bf09866 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -157,7 +157,26 @@ func (h *Handlers) AdminPurgePost(c *gin.Context) { } func (h *Handlers) AdminSoftDeleteComment(c *gin.Context) { - h.execContentAction(c, h.Moderation.SoftDeleteComment) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) + return + } + var body struct { + DeleteType string `json:"delete_type"` + DeleteReason string `json:"delete_reason"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请提交删除类型与理由"}) + return + } + actor := middleware.CurrentActor(c) + if err := h.Moderation.SoftDeleteComment(actor, uint(id), body.DeleteType, body.DeleteReason); err != nil { + h.writeModerationError(c, err) + return + } + h.broadcastModerationChanged() + c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) } func (h *Handlers) AdminRestoreComment(c *gin.Context) { diff --git a/backend/model/models.go b/backend/model/models.go index b48e2dc..a4730e9 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -310,9 +310,13 @@ type Comment struct { Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1 Content string `gorm:"type:text;not null" json:"content"` Status string `gorm:"size:16;default:published;index" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + // 软删元数据:列表 Unscoped 作 tombstone;DeletedBy≠UserID 为管理删除 + DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"` + DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"` + DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` } diff --git a/backend/router/router.go b/backend/router/router.go index 6776c92..1a7c896 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -151,6 +151,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.POST("/posts/:id/like", h.ToggleLike) api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment) api.DELETE("/posts/:id/comments/:cid", h.DeleteComment) + api.PUT("/posts/:id/comments/:cid/restore", h.RestoreComment) api.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment) // 通知 api.GET("/notifications", h.Notifications) diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go index b13d40e..036239f 100644 --- a/backend/service/admin_content.go +++ b/backend/service/admin_content.go @@ -366,8 +366,8 @@ func (s *ModerationService) PurgePost(actor *Actor, id uint) error { }) } -// SoftDeleteComment 软删单条评论(不级联子树,后台点对点清理) -func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error { +// SoftDeleteComment 软删单条评论(不级联子树);须选类型,「其他」才须填理由 +func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType, deleteReason string) error { cm, _, err := s.loadCommentUnscoped(actor, id) if err != nil { return err @@ -375,6 +375,29 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error { if cm.DeletedAt.Valid { return errors.New("评论已在回收站") } + deleteType = strings.TrimSpace(deleteType) + deleteReason = strings.TrimSpace(deleteReason) + if !model.ValidPostDeleteType(deleteType) { + return errors.New("无效的删除类型") + } + if deleteType == model.PostDeleteTypeOther { + if deleteReason == "" { + return errors.New("请填写删除理由") + } + if len([]rune(deleteReason)) > 200 { + return errors.New("删除理由不能超过 200 字") + } + } else { + deleteReason = "" + } + updates := map[string]interface{}{ + "delete_type": deleteType, + "delete_reason": deleteReason, + "deleted_by": actor.ID, + } + if err := s.db.Model(cm).Updates(updates).Error; err != nil { + return err + } if err := s.db.Delete(cm).Error; err != nil { return err } @@ -385,7 +408,7 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error { return nil } -// RestoreComment 恢复单条评论 +// RestoreComment 恢复单条评论,并清空删除元数据 func (s *ModerationService) RestoreComment(actor *Actor, id uint) error { cm, _, err := s.loadCommentUnscoped(actor, id) if err != nil { @@ -394,7 +417,13 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error { if !cm.DeletedAt.Valid { return errors.New("评论未被删除") } - if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + updates := map[string]interface{}{ + "deleted_at": nil, + "delete_type": "", + "delete_reason": "", + "deleted_by": 0, + } + if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Updates(updates).Error; err != nil { return err } if cm.Status == model.ContentStatusPublished { diff --git a/backend/service/comment.go b/backend/service/comment.go index a5a15bd..b670865 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -11,8 +11,11 @@ import ( // 评论操作错误 var ( - ErrCommentNotFound = errors.New("评论不存在") - ErrCommentForbidden = errors.New("无权限删除此评论") + ErrCommentNotFound = errors.New("评论不存在") + ErrCommentForbidden = errors.New("无权限删除此评论") + ErrCommentDeleteMeta = errors.New("请填写删除理由") + ErrCommentInvalidType = errors.New("无效的删除类型") + ErrCommentNotDeleted = errors.New("评论未被删除") ) // CommentService 评论服务 @@ -27,8 +30,18 @@ func NewCommentService(db *gorm.DB) *CommentService { // CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略 type CommentNode struct { model.Comment - Deleted bool `json:"deleted,omitempty"` // 软删占位 - Replies []CommentNode `json:"replies,omitempty"` + Deleted bool `json:"deleted,omitempty"` // 软删占位 + StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者 + DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开) + Replies []CommentNode `json:"replies,omitempty"` +} + +// CommentActorBrief 评论相关公开用户摘要(删评管理员链接等) +type CommentActorBrief struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` } // maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套 @@ -92,19 +105,25 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A children[*r.ParentID] = append(children[*r.ParentID], r) } } + allRows := make([]model.Comment, 0, len(roots)+len(replies)) + allRows = append(allRows, roots...) + allRows = append(allRows, replies...) + deleters := s.loadCommentActorsByIDs(collectStaffDeleterIDs(allRows)) + placed := make(map[uint]bool, len(roots)+len(replies)) + revealAudit := actor != nil && actor.CanModerateBoard(boardID) var build func(pid uint) []CommentNode build = func(pid uint) []CommentNode { out := []CommentNode{} for _, c := range children[pid] { - out = append(out, toCommentNode(c, build(c.ID))) + out = append(out, toCommentNode(c, build(c.ID), revealAudit, 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)) + result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, deleters) placed[roots[i].ID] = true } for _, r := range replies { @@ -113,7 +132,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))) + result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, deleters)) break } } @@ -121,14 +140,70 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A return result, floors, totalComments, nil } -// toCommentNode 软删行清空正文与用户展示信息,标 deleted -func toCommentNode(c model.Comment, replies []CommentNode) CommentNode { +func collectStaffDeleterIDs(rows []model.Comment) []uint { + seen := map[uint]bool{} + var ids []uint + for _, c := range rows { + if !c.DeletedAt.Valid || c.DeletedBy == 0 || c.DeletedBy == c.UserID { + continue + } + if !seen[c.DeletedBy] { + seen[c.DeletedBy] = true + ids = append(ids, c.DeletedBy) + } + } + return ids +} + +func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActorBrief { + out := map[uint]CommentActorBrief{} + if len(ids) == 0 { + return out + } + var users []model.User + if err := s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", ids).Find(&users).Error; err != nil { + return out + } + for _, u := range users { + out[u.ID] = CommentActorBrief{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Avatar: u.Avatar, + } + } + return out +} + +// toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。 +func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode { if !c.DeletedAt.Valid { return CommentNode{Comment: c, Replies: replies} } - c.Content = "" - c.User = model.User{} - return CommentNode{Comment: c, Deleted: true, Replies: replies} + staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID + if !revealAudit { + c.Content = "" + } + if !staff { + c.DeleteType = "" + if !revealAudit { + c.DeleteReason = "" + } + } + var deleter *CommentActorBrief + if staff { + if u, ok := deleters[c.DeletedBy]; ok { + cp := u + deleter = &cp + } + } + return CommentNode{ + Comment: c, + Deleted: true, + StaffDeleted: staff, + DeletedByUser: deleter, + Replies: replies, + } } // applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审); @@ -265,20 +340,60 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) { return total, err } -// Delete 软删单条评论(作者本人或板块审核权),不级联子树;占位保留楼层。 -func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error { +// DeleteCommentOpts 软删选项:自删须填理由;staff 须选类型,「其他」才须填理由 +type DeleteCommentOpts struct { + DeleteType string + DeleteReason string +} + +// Delete 软删单条评论(评论者本人或板块审核权),不级联子树;占位保留楼层。 +func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts DeleteCommentOpts) error { var comment model.Comment if err := s.db.First(&comment, commentID).Error; err != nil { return ErrCommentNotFound } - if comment.UserID != userID { + + deleteType := "" + deleteReason := strings.TrimSpace(opts.DeleteReason) + if comment.UserID == userID { + if deleteReason == "" { + return ErrCommentDeleteMeta + } + if len([]rune(deleteReason)) > 200 { + return errors.New("删除理由不能超过 200 字") + } + } else { var post model.Post if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { return ErrCommentNotFound } - if !actor.CanModerateBoard(post.BoardID) { + if actor == nil || !actor.CanModerateBoard(post.BoardID) { return ErrCommentForbidden } + deleteType = strings.TrimSpace(opts.DeleteType) + if !model.ValidPostDeleteType(deleteType) { + return ErrCommentInvalidType + } + if deleteType == model.PostDeleteTypeOther { + if deleteReason == "" { + return ErrCommentDeleteMeta + } + if len([]rune(deleteReason)) > 200 { + return errors.New("删除理由不能超过 200 字") + } + } else { + // 非「其他」仅展示类型标签,不落库自由文本 + deleteReason = "" + } + } + + updates := map[string]interface{}{ + "delete_type": deleteType, + "delete_reason": deleteReason, + "deleted_by": userID, + } + if err := s.db.Model(&comment).Updates(updates).Error; err != nil { + return err } if err := s.db.Delete(&comment).Error; err != nil { return err @@ -290,6 +405,49 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error { return nil } +// Restore 恢复软删评论:作者可恢复自己的自删;版主可恢复本板任意软删(含管理删除)。 +func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error { + var comment model.Comment + if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { + return ErrCommentNotFound + } + if !comment.DeletedAt.Valid { + return ErrCommentNotDeleted + } + + staffDeleted := comment.DeletedBy != 0 && comment.DeletedBy != comment.UserID + allowed := false + if comment.UserID == userID && !staffDeleted { + allowed = true + } else { + var post model.Post + if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { + return ErrCommentNotFound + } + if actor != nil && actor.CanModerateBoard(post.BoardID) { + allowed = true + } + } + if !allowed { + return ErrCommentForbidden + } + + updates := map[string]interface{}{ + "deleted_at": nil, + "delete_type": "", + "delete_reason": "", + "deleted_by": 0, + } + if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil { + return err + } + if comment.Status == model.ContentStatusPublished { + s.db.Model(&model.Post{}).Where("id = ?", comment.PostID). + UpdateColumn("comment_count", gorm.Expr("comment_count + 1")) + } + return nil +} + // collectDescendantIDs 收集以 root 为根的子树 ID(含自身) func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint { var all []model.Comment diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index cbf07ae..972133a 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -35,6 +35,7 @@ import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import ConfirmDialog from "@/components/ConfirmDialog"; import StaffDeletePostModal from "@/components/StaffDeletePostModal"; +import DeleteCommentModal from "@/components/DeleteCommentModal"; const PAGE_SIZE = 15; @@ -142,6 +143,8 @@ export default function ContentAdmin({ const [confirmBusy, setConfirmBusy] = useState(false); const [staffDeletePost, setStaffDeletePost] = useState(null); const [staffDeleteBusy, setStaffDeleteBusy] = useState(false); + const [staffDeleteComment, setStaffDeleteComment] = useState(null); + const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false); const refreshCounts = useCallback(() => { apiAdminContentCounts().then(setCounts).catch(() => {}); @@ -605,18 +608,7 @@ export default function ContentAdmin({ type="button" className="btn btn-line btn-sm" disabled={busy} - onClick={() => - askConfirm( - "移入回收站", - "确定软删这条评论吗?可在「已删除」中恢复。", - () => - apiAdminSoftDeleteComment(c.id).then(() => { - toast("已移入回收站", "ok"); - reload(commentsPage); - }), - true - ) - } + onClick={() => setStaffDeleteComment(c)} > 删除 @@ -702,6 +694,25 @@ export default function ContentAdmin({ .finally(() => setStaffDeleteBusy(false)); }} /> + + !staffDeleteCommentBusy && setStaffDeleteComment(null)} + onConfirm={(opts) => { + if (!staffDeleteComment) return; + setStaffDeleteCommentBusy(true); + apiAdminSoftDeleteComment(staffDeleteComment.id, opts) + .then(() => { + toast("已移入回收站", "ok"); + setStaffDeleteComment(null); + reload(commentsPage); + }) + .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) + .finally(() => setStaffDeleteCommentBusy(false)); + }} + /> ); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 32f08a5..14d574d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -2154,6 +2154,161 @@ html.dark .prose-content pre[data-theme] span { font-size: 13.5px; } +/* 软删占位:头像/昵称马赛克,可读结构但不可辨认身份 */ +.cmt-mosaic-avatar { + position: relative; + display: inline-flex; + border-radius: 9999px; + overflow: hidden; + pointer-events: none; + 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::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + 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; +} +.cmt-mosaic-name { + display: inline-block; + max-width: 9rem; + overflow: hidden; + white-space: nowrap; + filter: blur(5px); + letter-spacing: 0.04em; + user-select: none; + pointer-events: none; + transform: scaleY(1.05); +} +.cmt-tombstone { + border-radius: 12px; + padding: 10px 12px; + background: var(--panel-2); + border: 1px dashed color-mix(in srgb, var(--ink-3) 45%, var(--line)); +} +.cmt-tombstone-title { + font-size: 13px; + font-weight: 600; + color: var(--ink-2); +} +.cmt-tombstone-reason { + margin-top: 6px; + font-size: 12.5px; + line-height: 1.5; + color: var(--ink-3); +} +.cmt-tombstone-tag { + display: inline-flex; + align-items: center; + margin-top: 8px; + padding: 2px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + background: color-mix(in srgb, var(--danger) 12%, var(--panel)); + color: var(--danger); +} +.cmt-tombstone-tag-self { + background: color-mix(in srgb, var(--ink-3) 16%, var(--panel)); + color: var(--ink-3); +} +.cmt-tombstone-audit { + margin-top: 8px; + padding: 8px 10px; + border-radius: 10px; + border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line)); + background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel)); +} +.cmt-tombstone-audit-label { + display: inline-block; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--accent); +} +.cmt-tombstone-orig { + margin-top: 8px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--panel); +} +.cmt-tombstone-orig .prose-comment { + font-size: 13px; + opacity: 0.92; +} +.cmt-tombstone-mod { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: var(--ink-2); +} +.cmt-tombstone-mod a { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent); + font-weight: 600; +} +.cmt-tombstone-mod a:hover { + text-decoration: underline; +} + +/* 评论管理操作:紧凑文字按钮组,取代孤立图标 */ +.cmt-mod-actions { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 10px; + background: var(--panel-2); + border: 1px solid var(--line); +} +.cmt-mod-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + line-height: 1; + color: var(--ink-2); + transition: background 0.15s, color 0.15s; +} +.cmt-mod-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 10%, var(--panel)); + color: var(--danger); +} +.cmt-mod-btn:disabled { + opacity: 0.5; +} +.cmt-mod-btn-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 16%, var(--panel)); + color: var(--danger); +} + @media (max-width: 640px) { .cmt-replies-wrap { padding: 4px 8px; diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 34ea53b..6eb4b0c 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -11,15 +11,18 @@ import { ChevronDown, BadgeCheck, XCircle, + RotateCcw, } from "lucide-react"; import { apiCreateComment, apiDeleteComment, + apiRestoreComment, apiPurgeComment, apiAcceptBounty, apiAcceptQuestion, apiAdminApproveComment, apiAdminRejectComment, + postDeleteTypeLabel, type CommentNode, type User, } from "@/lib/api"; @@ -30,6 +33,7 @@ import Avatar from "./Avatar"; import Pagination from "./Pagination"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import ConfirmDialog from "@/components/ConfirmDialog"; +import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal"; interface CommentSectionProps { postId: string; @@ -51,14 +55,48 @@ interface CommentSectionProps { allowComments?: boolean; } -// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原 -const CONFIRM_RESET_MS = 3000; - -// 递归统计子树回复数(删除级联提示用) +// 递归统计子树回复数(彻底删除级联提示用) function countReplies(node: CommentNode): number { return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0; } +function findComment(nodes: CommentNode[], id: number): CommentNode | null { + for (const n of nodes) { + if (n.id === id) return n; + if (n.replies?.length) { + const hit = findComment(n.replies, id); + if (hit) return hit; + } + } + return null; +} + +/** 马赛克头像:保留轮廓,不可辨认 */ +function MosaicAvatar({ + name, + src, + size, +}: { + name: string; + src?: string; + size: number; +}) { + return ( + + + + ); +} + +/** 马赛克昵称 */ +function MosaicName({ text, className = "" }: { text: string; className?: string }) { + return ( + + {text || "用户"} + + ); +} + export default function CommentSection({ postId, boardId, @@ -79,14 +117,16 @@ export default function CommentSection({ const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null); const [replyContent, setReplyContent] = useState(""); const [submitting, setSubmitting] = useState(false); - const [deletingId, setDeletingId] = useState(null); - const [confirmingId, setConfirmingId] = useState(null); + const [deleting, setDeleting] = useState(false); + const [deleteTargetId, setDeleteTargetId] = useState(null); const [acceptingId, setAcceptingId] = useState(null); const [approvingId, setApprovingId] = useState(null); const [rejectTargetId, setRejectTargetId] = useState(null); const [rejecting, setRejecting] = useState(false); const [purgeTargetId, setPurgeTargetId] = useState(null); const [purging, setPurging] = useState(false); + const [restoreTargetId, setRestoreTargetId] = useState(null); + const [restoring, setRestoring] = useState(false); // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) const [expandedFloors, setExpandedFloors] = useState>(new Set()); const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false); @@ -164,19 +204,20 @@ export default function CommentSection({ } }; - const handleDelete = async (commentId: number) => { - if (confirmingId !== commentId) { - setConfirmingId(commentId); - setTimeout(() => { - setConfirmingId((cur) => (cur === commentId ? null : cur)); - }, CONFIRM_RESET_MS); - return; - } - setConfirmingId(null); - setDeletingId(commentId); + const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null; + const deleteMode: "self" | "staff" = + deleteTarget && user && user.id === deleteTarget.user_id ? "self" : "staff"; + const purgeTarget = purgeTargetId != null ? findComment(comments, purgeTargetId) : null; + const purgeReplyCount = purgeTarget ? countReplies(purgeTarget) : 0; + + const handleDeleteConfirm = async (opts: DeleteCommentPayload) => { + if (deleteTargetId == null) return; + setDeleting(true); try { - const res = await apiDeleteComment(postId, commentId); + const res = await apiDeleteComment(postId, deleteTargetId, opts); if (res.message) { + toast(deleteMode === "self" ? "已删除评论" : "已删除该评论", "ok"); + setDeleteTargetId(null); router.refresh(); } else { toast(res.error || "删除失败"); @@ -184,7 +225,7 @@ export default function CommentSection({ } catch { toast("网络错误,删除失败"); } finally { - setDeletingId(null); + setDeleting(false); } }; @@ -256,6 +297,25 @@ export default function CommentSection({ } }; + const handleRestoreConfirm = async () => { + if (restoreTargetId == null) return; + setRestoring(true); + try { + const res = await apiRestoreComment(postId, restoreTargetId); + if (res.message) { + toast("评论已恢复", "ok"); + setRestoreTargetId(null); + router.refresh(); + } else { + toast(res.error || "恢复失败"); + } + } catch (e) { + toast(e instanceof Error ? e.message : "恢复失败"); + } finally { + setRestoring(false); + } + }; + // 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能) const canSoftDelete = (comment: CommentNode) => @@ -266,6 +326,13 @@ export default function CommentSection({ const canPurge = (comment: CommentNode) => !!user && canModerateBoard(user, boardId); + /** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */ + 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; + }; + const canApprove = (comment: CommentNode) => !comment.deleted && !!user && @@ -348,42 +415,135 @@ export default function CommentSection({ const isExpanded = isFloor ? expandedFloors.has(c.id) : true; const parentAuthor = parent?.user?.nickname || parent?.user?.username; - // 软删占位:保留楼层/回复位,不展示正文;子回复仍可展开 + // 软删占位:自删不打码;管理删打码 + 管理员主页链接;版主可见原文 if (c.deleted) { - const tombstoneBody = ( -
+ ) : ( + - 此评论已删除 + + + ); + + const nameRow = showMosaic ? ( + + ) : ( + + + {displayName} + + + ); + + const tombstoneBody = ( +
+

+ {c.staff_deleted ? "管理员已删除此评论" : "评论者已删除此评论"} +

+ {staffPublicMeta ? ( +

+ {typeLabel ? ( + <> + {typeLabel} + {c.delete_reason ? " · " : null} + + ) : null} + {c.delete_reason || null} +

+ ) : null} + {c.staff_deleted && deleter ? ( +
+ 处理人 + + + {deleterName} + +
+ ) : null} + {selfReasonForStaff ? ( +
+ 自删理由 · 仅管理可见 +

{c.delete_reason}

+
+ ) : null} + {isMod && c.content ? ( +
+ 原文 · 仅管理可见 +
+ +
+
+ ) : null} + {c.staff_deleted ? ( + 管理删除 + ) : ( + 评论者删除 + )}
); const purgeBtn = canPurge(c) && ( ); + const restoreBtn = canRestore(c) && ( + + ); + const tombstoneActions = + restoreBtn || purgeBtn ? ( +
+ {restoreBtn} + {purgeBtn} +
+ ) : null; if (!isFloor) { return ( -
+
- + {identity}
+
{nameRow}
{tombstoneBody} -
+
{new Date(c.created_at).toLocaleString("zh-CN")} - {purgeBtn} + {tombstoneActions}
@@ -400,16 +560,14 @@ export default function CommentSection({
- + {identity}
{tombstoneBody} -
+
{new Date(c.created_at).toLocaleString("zh-CN")} - {purgeBtn} + {tombstoneActions}
{node.replies && node.replies.length > 0 && (
@@ -532,34 +690,32 @@ export default function CommentSection({ )}
- {canSoftDelete(c) && ( - )} - - )} - {canPurge(c) && ( - + {canPurge(c) && ( + + )} +
)}
{replyTarget?.id === c.id && inlineReplyForm} @@ -659,34 +815,32 @@ export default function CommentSection({ )}
- {canSoftDelete(c) && ( - )} - - )} - {canPurge(c) && ( - + {canPurge(c) && ( + + )} +
)}
{replyTarget?.id === c.id && inlineReplyForm} @@ -829,11 +983,37 @@ export default function CommentSection({ }} /> + { + if (!deleting) setDeleteTargetId(null); + }} + onConfirm={(opts) => void handleDeleteConfirm(opts)} + /> + + { + if (!restoring) setRestoreTargetId(null); + }} + /> + 0 + ? `将永久清除此评论及其 ${purgeReplyCount} 条回复,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。` + : "将永久清除此评论,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。" + } + confirmLabel="永久清除" danger busy={purging} onConfirm={handlePurgeConfirm} diff --git a/frontend/components/DeleteCommentModal.tsx b/frontend/components/DeleteCommentModal.tsx new file mode 100644 index 0000000..7633335 --- /dev/null +++ b/frontend/components/DeleteCommentModal.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Modal from "@/components/Modal"; +import { POST_DELETE_TYPES } from "@/lib/api"; + +export type DeleteCommentPayload = { + delete_type: string; + delete_reason: string; +}; + +/** + * 评论软删弹窗: + * - 评论者自删:必填理由(仅管理可见) + * - 管理删:必选类型;仅「其他」须填理由 + */ +export default function DeleteCommentModal({ + open, + mode, + busy, + onClose, + onConfirm, +}: { + open: boolean; + /** self=评论者自删;staff=版主/后台删除 */ + mode: "self" | "staff"; + busy?: boolean; + onClose: () => void; + onConfirm: (opts: DeleteCommentPayload) => void; +}) { + const [deleteType, setDeleteType] = useState("spam"); + const [reason, setReason] = useState(""); + const [err, setErr] = useState(""); + + useEffect(() => { + if (open) { + setDeleteType("spam"); + setReason(""); + setErr(""); + } + }, [open]); + + if (!open) return null; + + const isStaff = mode === "staff"; + const reasonRequired = !isStaff || deleteType === "other"; + + const submit = () => { + const r = reason.trim(); + if (reasonRequired) { + if (!r) { + setErr("请填写删除理由"); + return; + } + if ([...r].length > 200) { + setErr("删除理由不能超过 200 字"); + return; + } + } + onConfirm({ + delete_type: isStaff ? deleteType : "", + delete_reason: reasonRequired ? r : "", + }); + }; + + return ( + + + + + } + > + {isStaff ? ( + <> +

+ 删除类型 +

+
+ {POST_DELETE_TYPES.map((t) => { + const active = deleteType === t.value; + return ( + + ); + })} +
+

+ 选「其他」时需补充理由;其余类型直接按标签展示给读者。 +

+ + ) : null} + + {reasonRequired ? ( + <> + +