From bcb4f6a60322ef5ac489f01758111dc4c9a2ab06 Mon Sep 17 00:00:00 2001
From: freefire
Date: Wed, 16 Sep 2026 07:01:04 +0800
Subject: [PATCH] =?UTF-8?q?feat(moderation):=20=E5=B8=96=E8=AF=84=E5=88=A0?=
=?UTF-8?q?=E9=99=A4=E5=8D=A0=E4=BD=8D=E6=84=9F=E7=9F=A5=E4=B8=8E=E7=A1=AC?=
=?UTF-8?q?=E5=88=A0=E6=B8=85=E5=9C=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。
Co-authored-by: Cursor
---
backend/handler/comment.go | 31 +-
backend/handler/moderation.go | 33 ++-
backend/handler/post.go | 24 +-
backend/model/models.go | 42 +++
backend/router/router.go | 1 +
backend/service/admin_content.go | 32 ++-
backend/service/comment.go | 123 +++++---
backend/service/post.go | 109 ++++++-
frontend/app/admin/content/ContentAdmin.tsx | 35 ++-
.../app/notifications/NotificationsClient.tsx | 10 +-
frontend/app/post/[id]/page.tsx | 71 ++++-
frontend/components/CommentSection.tsx | 270 ++++++++++++++++--
frontend/components/NotificationBell.tsx | 6 +-
frontend/components/PostActions.tsx | 30 +-
frontend/components/StaffDeletePostModal.tsx | 124 ++++++++
15 files changed, 825 insertions(+), 116 deletions(-)
create mode 100644 frontend/components/StaffDeletePostModal.tsx
diff --git a/backend/handler/comment.go b/backend/handler/comment.go
index cf2c72a..29d7cbe 100644
--- a/backend/handler/comment.go
+++ b/backend/handler/comment.go
@@ -66,6 +66,13 @@ func (h *Handlers) CreateComment(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
+ if allow, err := h.Setting.AllowComments(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
+ return
+ } else if !allow {
+ c.JSON(http.StatusForbidden, gin.H{"error": "站点已关闭评论"})
+ return
+ }
var req CreateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -99,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)
@@ -120,3 +127,25 @@ func (h *Handlers) DeleteComment(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
+
+// PurgeComment 硬删评论及子树(仅版主;不占位)
+func (h *Handlers) PurgeComment(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.Purge(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": err.Error()})
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ }
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已彻底删除"})
+}
diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go
index 3deb4cc..673eeda 100644
--- a/backend/handler/moderation.go
+++ b/backend/handler/moderation.go
@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
+ "github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -118,7 +119,33 @@ func (h *Handlers) AdminListComments(c *gin.Context) {
}
func (h *Handlers) AdminSoftDeletePost(c *gin.Context) {
- h.execContentAction(c, h.Moderation.SoftDeletePost)
+ 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)
+ authorID, err := h.Moderation.SoftDeletePost(actor, uint(id), body.DeleteType, body.DeleteReason)
+ if err != nil {
+ h.writeModerationError(c, err)
+ return
+ }
+ label := model.PostDeleteTypeLabel(body.DeleteType)
+ preview := label
+ if body.DeleteReason != "" {
+ preview = label + ":" + body.DeleteReason
+ }
+ h.Notification.Create(authorID, actor.ID, model.NotificationTypeDeleted, uint(id), 0, preview)
+ h.broadcastModerationChanged()
+ c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
}
func (h *Handlers) AdminRestorePost(c *gin.Context) {
@@ -168,7 +195,9 @@ func (h *Handlers) writeModerationError(c *gin.Context, err error) {
default:
msg := err.Error()
if msg == "帖子已在回收站" || msg == "帖子未被删除" ||
- msg == "评论已在回收站" || msg == "评论未被删除" {
+ msg == "评论已在回收站" || msg == "评论未被删除" ||
+ msg == "无效的删除类型" || msg == "请填写删除理由" ||
+ msg == "删除理由不能超过 200 字" {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
diff --git a/backend/handler/post.go b/backend/handler/post.go
index 71c3373..0818122 100644
--- a/backend/handler/post.go
+++ b/backend/handler/post.go
@@ -361,7 +361,7 @@ func (h *Handlers) DownloadPostAttachment(c *gin.Context) {
_ = filepath.Base(path)
}
-// DeletePost 删除帖子
+// DeletePost 删除帖子(作者自删或 staff 软删;staff 须提交类型与理由)
func (h *Handlers) DeletePost(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -369,10 +369,30 @@ func (h *Handlers) DeletePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
- if err := h.Post.Delete(h.loadActor(claims.ID), uint(id), claims.ID); err != nil {
+ var body struct {
+ DeleteType string `json:"delete_type"`
+ DeleteReason string `json:"delete_reason"`
+ }
+ _ = c.ShouldBindJSON(&body)
+
+ staffDeleted, authorID, err := h.Post.Delete(
+ h.loadActor(claims.ID),
+ uint(id),
+ claims.ID,
+ service.DeletePostOpts{DeleteType: body.DeleteType, DeleteReason: body.DeleteReason},
+ )
+ if err != nil {
respondPostModError(c, err)
return
}
+ if staffDeleted {
+ label := model.PostDeleteTypeLabel(body.DeleteType)
+ preview := label
+ if body.DeleteReason != "" {
+ preview = label + ":" + body.DeleteReason
+ }
+ h.Notification.Create(authorID, claims.ID, model.NotificationTypeDeleted, uint(id), 0, preview)
+ }
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
diff --git a/backend/model/models.go b/backend/model/models.go
index 1e15712..b48e2dc 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -177,6 +177,10 @@ type Post struct {
ViewCount int `gorm:"default:0" json:"view_count"`
CommentCount int `gorm:"default:0" json:"comment_count"`
Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库)
+ // 软删元数据:列表仍排除;详情 Unscoped 可读作 tombstone 页
+ 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"` // 执行删除者;与 UserID 不同即为管理删除
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@@ -185,6 +189,43 @@ type Post struct {
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
+// 帖子管理删除类型(staff 软删必填)
+const (
+ PostDeleteTypeSpam = "spam"
+ PostDeleteTypeViolation = "violation"
+ PostDeleteTypeOffTopic = "off_topic"
+ PostDeleteTypeDuplicate = "duplicate"
+ PostDeleteTypeOther = "other"
+)
+
+// ValidPostDeleteType 管理删帖类型白名单
+func ValidPostDeleteType(t string) bool {
+ switch t {
+ case PostDeleteTypeSpam, PostDeleteTypeViolation, PostDeleteTypeOffTopic,
+ PostDeleteTypeDuplicate, PostDeleteTypeOther:
+ return true
+ }
+ return false
+}
+
+// PostDeleteTypeLabel 类型中文标签(通知/专用页)
+func PostDeleteTypeLabel(t string) string {
+ switch t {
+ case PostDeleteTypeSpam:
+ return "垃圾广告"
+ case PostDeleteTypeViolation:
+ return "违规内容"
+ case PostDeleteTypeOffTopic:
+ return "灌水跑题"
+ case PostDeleteTypeDuplicate:
+ return "重复内容"
+ case PostDeleteTypeOther:
+ return "其他"
+ default:
+ return ""
+ }
+}
+
// PointLedger 积分流水(余额以 User.Points 为准,本表可审计)
type PointLedger struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -302,6 +343,7 @@ const (
NotificationTypeRejected = "rejected" // 内容审核未通过
NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID)
NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者)
+ NotificationTypeDeleted = "deleted" // 帖子被管理员删除
)
// Notification 站内通知
diff --git a/backend/router/router.go b/backend/router/router.go
index f52305b..6776c92 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.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment)
// 通知
api.GET("/notifications", h.Notifications)
api.GET("/notifications/unread-count", h.UnreadCount)
diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go
index e4cb1d9..b13d40e 100644
--- a/backend/service/admin_content.go
+++ b/backend/service/admin_content.go
@@ -303,16 +303,38 @@ func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.C
return &cm, post.BoardID, nil
}
-// SoftDeletePost 软删帖子(未删除时)
-func (s *ModerationService) SoftDeletePost(actor *Actor, id uint) error {
+// SoftDeletePost 软删帖子(未删除时);须填写删除类型与理由,并通知作者
+func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) {
post, err := s.loadPostUnscoped(actor, id)
if err != nil {
- return err
+ return 0, err
}
if post.DeletedAt.Valid {
- return errors.New("帖子已在回收站")
+ return 0, errors.New("帖子已在回收站")
}
- return s.db.Delete(post).Error
+ deleteType = strings.TrimSpace(deleteType)
+ deleteReason = strings.TrimSpace(deleteReason)
+ if !model.ValidPostDeleteType(deleteType) {
+ return 0, errors.New("无效的删除类型")
+ }
+ if deleteReason == "" {
+ return 0, errors.New("请填写删除理由")
+ }
+ if len([]rune(deleteReason)) > 200 {
+ return 0, errors.New("删除理由不能超过 200 字")
+ }
+ updates := map[string]interface{}{
+ "delete_type": deleteType,
+ "delete_reason": deleteReason,
+ "deleted_by": actor.ID,
+ }
+ if err := s.db.Model(post).Updates(updates).Error; err != nil {
+ return 0, err
+ }
+ if err := s.db.Delete(post).Error; err != nil {
+ return 0, err
+ }
+ return post.UserID, nil
}
// RestorePost 从回收站恢复帖子
diff --git a/backend/service/comment.go b/backend/service/comment.go
index d6d879a..a5a15bd 100644
--- a/backend/service/comment.go
+++ b/backend/service/comment.go
@@ -27,6 +27,7 @@ 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"`
}
@@ -35,8 +36,8 @@ const maxReplyDepth = 10
// ListFloorPaged 楼层分页获取帖子评论树:
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
-// viewer 可为作者本人或该板可审者时并入 pending;游客仅 published。
-// 返回:树根列表、楼层数、全部评论数(含回复)。
+// 已软删评论以 tombstone 占位(计入楼层);已硬删不存在。
+// totalComments 仅计未删条数(头部徽章);floors 含 tombstone 主评。
func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *Actor, page, size int) ([]CommentNode, int64, int64, error) {
if page < 1 {
page = 1
@@ -44,18 +45,23 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
if size < 1 || size > 100 {
size = 20
}
- base := applyCommentListVisibility(s.db.Model(&model.Comment{}).Where("post_id = ?", postID), boardID, viewerID, actor)
+ // Unscoped:把软删行一并纳入,供占位展示
+ base := applyCommentListVisibility(
+ s.db.Unscoped().Model(&model.Comment{}).Where("post_id = ?", postID),
+ boardID, viewerID, actor,
+ )
var floors, totalComments int64
if err := base.Session(&gorm.Session{}).
Where("parent_id IS NULL").Count(&floors).Error; err != nil {
return nil, 0, 0, err
}
- if err := base.Session(&gorm.Session{}).Count(&totalComments).Error; err != nil {
+ // 徽章:仅未软删
+ if err := base.Session(&gorm.Session{}).
+ Where("deleted_at IS NULL").Count(&totalComments).Error; err != nil {
return nil, 0, 0, err
}
- // 1. 本页楼层(主评论)
var roots []model.Comment
if err := base.Session(&gorm.Session{}).
Where("parent_id IS NULL").
@@ -70,19 +76,17 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
return []CommentNode{}, floors, totalComments, nil
}
- // 2. 一次拉齐本页全部楼层的所有回复(root_id 冗余,免递归查询)
rootIDs := make([]uint, len(roots))
for i, r := range roots {
rootIDs[i] = r.ID
}
- replyQ := applyCommentListVisibility(s.db.Where("root_id IN (?)", rootIDs), boardID, viewerID, actor)
+ replyQ := applyCommentListVisibility(s.db.Unscoped().Where("root_id IN (?)", rootIDs), boardID, viewerID, actor)
var replies []model.Comment
if err := replyQ.Order("created_at ASC").Preload("User").Find(&replies).Error; err != nil {
return nil, 0, 0, err
}
- // 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部
- children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序)
+ children := make(map[uint][]model.Comment, len(replies))
for _, r := range replies {
if r.ParentID != nil {
children[*r.ParentID] = append(children[*r.ParentID], r)
@@ -93,24 +97,23 @@ 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, CommentNode{Comment: c, Replies: build(c.ID)})
+ out = append(out, toCommentNode(c, build(c.ID)))
placed[c.ID] = true
}
return out
}
result := make([]CommentNode, len(roots))
for i := range roots {
- result[i] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)}
+ result[i] = toCommentNode(roots[i], build(roots[i].ID))
placed[roots[i].ID] = true
}
- // 防御性兜底:父评论不在树中的回复 → 挂到所属楼层根部,保证不丢内容
for _, r := range replies {
if placed[r.ID] || r.RootID == nil {
continue
}
for i := range result {
if result[i].ID == *r.RootID {
- result[i].Replies = append(result[i].Replies, CommentNode{Comment: r, Replies: build(r.ID)})
+ result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID)))
break
}
}
@@ -118,17 +121,38 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
return result, floors, totalComments, nil
}
-// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审)
+// toCommentNode 软删行清空正文与用户展示信息,标 deleted
+func toCommentNode(c model.Comment, replies []CommentNode) 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}
+}
+
+// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
+// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
+ pub := model.ContentStatusPublished
+ pend := model.ContentStatusPending
if viewerID == 0 || actor == nil {
- return db.Where("status = ?", model.ContentStatusPublished)
+ return db.Where(
+ "(deleted_at IS NULL AND status = ?) OR (deleted_at IS NOT NULL AND status = ?)",
+ pub, pub,
+ )
}
if actor.CanModerateBoard(boardID) {
- return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
+ return db.Where(
+ "(deleted_at IS NULL AND status IN ?) OR (deleted_at IS NOT NULL AND status IN ?)",
+ []string{pub, pend}, []string{pub, pend},
+ )
}
return db.Where(
- "status = ? OR (status = ? AND user_id = ?)",
- model.ContentStatusPublished, model.ContentStatusPending, viewerID,
+ `(deleted_at IS NULL AND (status = ? OR (status = ? AND user_id = ?)))
+ OR (deleted_at IS NOT NULL AND (status = ? OR (status = ? AND user_id = ?)))`,
+ pub, pend, viewerID,
+ pub, pend, viewerID,
)
}
@@ -241,14 +265,12 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
return total, err
}
-// Delete 删除评论(作者本人,或对帖子所属板块有审核权的管理成员),
-// 级联软删整棵子树
+// Delete 软删单条评论(作者本人或板块审核权),不级联子树;占位保留楼层。
func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
var comment model.Comment
if err := s.db.First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
- // 权限校验:作者本人或板块审核权
if comment.UserID != userID {
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
@@ -258,12 +280,23 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
return ErrCommentForbidden
}
}
- // 收集子树:取同楼层全部评论,多轮标记出以目标为祖先的集合(含自身,深度有限必然收敛)
+ if err := s.db.Delete(&comment).Error; err != nil {
+ return err
+ }
+ if comment.Status == model.ContentStatusPublished {
+ s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
+ UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
+ }
+ return nil
+}
+
+// collectDescendantIDs 收集以 root 为根的子树 ID(含自身)
+func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint {
var all []model.Comment
if comment.RootID != nil {
- s.db.Where("root_id = ?", *comment.RootID).Find(&all)
+ s.db.Unscoped().Where("root_id = ?", *comment.RootID).Find(&all)
} else {
- s.db.Where("root_id = ?", comment.ID).Find(&all)
+ s.db.Unscoped().Where("root_id = ? OR id = ?", comment.ID, comment.ID).Find(&all)
}
descendants := map[uint]bool{comment.ID: true}
for i := 0; i <= maxReplyDepth; i++ {
@@ -285,27 +318,37 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
for id := range descendants {
ids = append(ids, id)
}
- // 只有已发布评论曾计入 comment_count,递减时排除待审/被拒评论。
- // all 含同楼层全部子评论(删除主评论时目标自身不在 all 内,需单独计入)
- publishedCnt := 0
- if comment.Status == model.ContentStatusPublished {
- publishedCnt++
+ return ids
+}
+
+// Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。
+func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
+ var comment model.Comment
+ if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
+ return ErrCommentNotFound
}
- for _, c := range all {
- if c.ID == comment.ID {
- continue
- }
- if descendants[c.ID] && c.Status == model.ContentStatusPublished {
- publishedCnt++
- }
+ var post model.Post
+ if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
+ return ErrCommentNotFound
}
- // 软删子树并按已发布条数递减帖子评论数
- if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil {
+ if actor == nil || !actor.CanModerateBoard(post.BoardID) {
+ return ErrCommentForbidden
+ }
+ _ = userID
+
+ ids := s.collectDescendantIDs(comment)
+ // 仅对「尚未软删」的已发布评论减计数(软删时已减过)
+ var livePublished int64
+ s.db.Model(&model.Comment{}).
+ Where("id IN ? AND status = ? AND deleted_at IS NULL", ids, model.ContentStatusPublished).
+ Count(&livePublished)
+
+ if err := s.db.Unscoped().Delete(&model.Comment{}, ids).Error; err != nil {
return err
}
- if publishedCnt > 0 {
+ if livePublished > 0 {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
- UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", publishedCnt))
+ UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished))
}
return nil
}
diff --git a/backend/service/post.go b/backend/service/post.go
index 75a619c..25c2e7a 100644
--- a/backend/service/post.go
+++ b/backend/service/post.go
@@ -12,8 +12,10 @@ import (
// 帖子操作错误
var (
- ErrPostNotFound = errors.New("帖子不存在")
- ErrPostForbidden = errors.New("无权限操作此帖子")
+ ErrPostNotFound = errors.New("帖子不存在")
+ ErrPostForbidden = errors.New("无权限操作此帖子")
+ ErrPostDeleteMeta = errors.New("管理删除须填写类型与理由")
+ ErrPostInvalidType = errors.New("无效的删除类型")
)
// PostService 帖子服务
@@ -384,6 +386,13 @@ type PostDetail struct {
Poll *PollState `json:"poll,omitempty"`
Bounty *BountyState `json:"bounty,omitempty"`
Lottery *LotteryState `json:"lottery,omitempty"`
+ // 软删 tombstone:列表不可见,详情专用页可读
+ Tombstone bool `json:"tombstone,omitempty"`
+ DeleteType string `json:"delete_type,omitempty"`
+ DeleteReason string `json:"delete_reason,omitempty"`
+ DeletedBy uint `json:"deleted_by,omitempty"`
+ StaffDeleted bool `json:"staff_deleted,omitempty"`
+ DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// CreatePostInput 发帖入参
@@ -412,11 +421,23 @@ type UpdatePostInput struct {
AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
}
-// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)
+// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)。
+// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) {
var post model.Post
- if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
- return nil, ErrPostNotFound
+ err := s.db.Preload("Board").Preload("User").First(&post, id).Error
+ if err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrPostNotFound
+ }
+ // 默认 scope 找不到:尝试软删行 → tombstone
+ if err := s.db.Unscoped().Preload("Board").Preload("User").First(&post, id).Error; err != nil {
+ return nil, ErrPostNotFound
+ }
+ if !post.DeletedAt.Valid {
+ return nil, ErrPostNotFound
+ }
+ return buildPostTombstone(&post), nil
}
if !visibleToPost(&post, viewerID, loadActor) {
return nil, ErrPostNotFound
@@ -448,6 +469,34 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
return detail, nil
}
+func buildPostTombstone(post *model.Post) *PostDetail {
+ var deletedAt *time.Time
+ if post.DeletedAt.Valid {
+ t := post.DeletedAt.Time
+ deletedAt = &t
+ }
+ staffDeleted := post.DeletedBy != 0 && post.DeletedBy != post.UserID
+ return &PostDetail{
+ ID: post.ID,
+ BoardID: post.BoardID,
+ UserID: post.UserID,
+ Title: post.Title,
+ PostType: model.NormalizePostType(post.PostType),
+ Status: post.Status,
+ CreatedAt: post.CreatedAt,
+ UpdatedAt: post.UpdatedAt,
+ Board: post.Board,
+ User: post.User,
+ Attachments: []PostAttachmentDTO{},
+ Tombstone: true,
+ DeleteType: post.DeleteType,
+ DeleteReason: post.DeleteReason,
+ DeletedBy: post.DeletedBy,
+ StaffDeleted: staffDeleted,
+ DeletedAt: deletedAt,
+ }
+}
+
func buildPostDetail(post *model.Post) *PostDetail {
return &PostDetail{
ID: post.ID, BoardID: post.BoardID, UserID: post.UserID,
@@ -954,17 +1003,53 @@ func (s *PostService) checkNewUserCooldown(userID uint) error {
return nil
}
-// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)
-func (s *PostService) Delete(actor *Actor, postID, userID uint) error {
+// DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值
+type DeletePostOpts struct {
+ DeleteType string
+ DeleteReason string
+}
+
+// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)。
+// staff 删除他人帖必须提供合法类型与非空理由;作者自删不写类型/理由。
+// 返回 staffDeleted 与作者 ID,供 handler 发通知。
+func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostOpts) (staffDeleted bool, authorID uint, err error) {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
- return ErrPostNotFound
+ return false, 0, ErrPostNotFound
}
- if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
- return ErrPostForbidden
+ authorID = post.UserID
+
+ deleteType := ""
+ deleteReason := ""
+ if post.UserID == userID {
+ staffDeleted = false
+ } else if actor != nil && actor.CanModerateBoard(post.BoardID) {
+ deleteType = strings.TrimSpace(opts.DeleteType)
+ deleteReason = strings.TrimSpace(opts.DeleteReason)
+ if !model.ValidPostDeleteType(deleteType) {
+ return false, authorID, ErrPostInvalidType
+ }
+ if deleteReason == "" {
+ return false, authorID, ErrPostDeleteMeta
+ }
+ if len([]rune(deleteReason)) > 200 {
+ return false, authorID, errors.New("删除理由不能超过 200 字")
+ }
+ staffDeleted = true
+ } else {
+ return false, authorID, ErrPostForbidden
+ }
+
+ updates := map[string]interface{}{
+ "delete_type": deleteType,
+ "delete_reason": deleteReason,
+ "deleted_by": userID,
+ }
+ if err := s.db.Model(&post).Updates(updates).Error; err != nil {
+ return false, authorID, err
}
if err := s.db.Delete(&post).Error; err != nil {
- return err
+ return false, authorID, err
}
- return nil
+ return staffDeleted, authorID, nil
}
diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx
index a9c30a9..cbf07ae 100644
--- a/frontend/app/admin/content/ContentAdmin.tsx
+++ b/frontend/app/admin/content/ContentAdmin.tsx
@@ -34,6 +34,7 @@ import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import ConfirmDialog from "@/components/ConfirmDialog";
+import StaffDeletePostModal from "@/components/StaffDeletePostModal";
const PAGE_SIZE = 15;
@@ -139,6 +140,8 @@ export default function ContentAdmin({
run: () => Promise;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
+ const [staffDeletePost, setStaffDeletePost] = useState(null);
+ const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {});
@@ -515,18 +518,7 @@ export default function ContentAdmin({
type="button"
className="btn btn-line btn-sm"
disabled={busy}
- onClick={() =>
- askConfirm(
- "移入回收站",
- `确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`,
- () =>
- apiAdminSoftDeletePost(p.id).then(() => {
- toast("已移入回收站", "ok");
- reload(postsPage);
- }),
- true
- )
- }
+ onClick={() => setStaffDeletePost(p)}
>
删除
@@ -691,6 +683,25 @@ export default function ContentAdmin({
});
}}
/>
+
+ !staffDeleteBusy && setStaffDeletePost(null)}
+ onConfirm={(opts) => {
+ if (!staffDeletePost) return;
+ setStaffDeleteBusy(true);
+ apiAdminSoftDeletePost(staffDeletePost.id, opts)
+ .then(() => {
+ toast("已移入回收站", "ok");
+ setStaffDeletePost(null);
+ reload(postsPage);
+ })
+ .catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
+ .finally(() => setStaffDeleteBusy(false));
+ }}
+ />
);
}
diff --git a/frontend/app/notifications/NotificationsClient.tsx b/frontend/app/notifications/NotificationsClient.tsx
index db8a8a5..bf57417 100644
--- a/frontend/app/notifications/NotificationsClient.tsx
+++ b/frontend/app/notifications/NotificationsClient.tsx
@@ -76,7 +76,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
const approval = n.type === "approved" || n.type === "rejected";
const iconNode = n.type === "approved"
?
- : n.type === "rejected"
+ : n.type === "rejected" || n.type === "deleted"
?
: n.type === "like"
?
@@ -87,14 +87,14 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
: ;
const iconColor = n.type === "approved"
? "var(--ok)"
- : n.type === "rejected"
+ : n.type === "rejected" || n.type === "deleted"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
- : n.type === "rejected"
+ : n.type === "rejected" || n.type === "deleted"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
@@ -143,7 +143,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
? "在消息中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
- : "点赞了你的帖子"}
+ : n.type === "deleted"
+ ? "删除了你的帖子"
+ : "点赞了你的帖子"}
>
)}
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index 5f3bc0b..1d0accf 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -18,12 +18,13 @@ import {
fetchComments,
fetchPosts,
fetchUserProfile,
+ postDeleteTypeLabel,
type UserProfile,
type PostListItem,
type Board,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
-import { getMeCached } from "@/lib/serverData";
+import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
@@ -50,9 +51,16 @@ export async function generateMetadata({ params }: PageProps): Promise
try {
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, authCookieHeader(await cookies()));
+ if (post.tombstone) {
+ return {
+ title: post.title || "帖子已删除",
+ description: "该帖子已被删除",
+ robots: { index: false, follow: false },
+ };
+ }
const desc = post.content_locked
? post.access_hint || "部分内容需满足条件后可见"
- : post.content.slice(0, 160);
+ : (post.content || "").slice(0, 160);
return {
title: post.title,
description: desc,
@@ -383,10 +391,63 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
notFound();
}
- // 评论 / 当前用户 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
- const [commentsData, me, profile, relatedData] = await Promise.all([
+ // 软删专用页:列表已不可见,原链接展示删除说明
+ if (post.tombstone) {
+ const typeLabel = postDeleteTypeLabel(post.delete_type);
+ return (
+
+
+ 帖子已删除
+
+
+ {post.title || "(无标题)"}
+
+ {post.staff_deleted ? (
+
+
+ 此帖已被管理员删除
+
+ {typeLabel && (
+
+ 类型:
+ {typeLabel}
+
+ )}
+ {post.delete_reason && (
+
+ 理由:
+ {post.delete_reason}
+
+ )}
+
+ ) : (
+
作者已删除此帖
+ )}
+
+
+ 返回首页
+
+ {post.board?.id ? (
+
+ 浏览板块
+
+ ) : null}
+
+
+ );
+ }
+
+ // 评论 / 当前用户 / 站点设置 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
+ const [commentsData, me, siteSettings, profile, relatedData] = await Promise.all([
fetchComments(id, commentPage, cookie),
getMeCached(cookie || undefined),
+ getPublicSettingsCached(),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
]);
@@ -554,6 +615,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
pinned={post.pinned}
recommended={post.recommended}
status={post.status}
+ title={post.title}
/>
@@ -659,6 +721,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
page={commentsData.page}
size={commentsData.size}
user={me.user}
+ allowComments={siteSettings.allow_comments}
acceptMode={acceptMode}
acceptedCommentId={
post.question?.accepted_comment_id ||
diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx
index 0ee5861..34ea53b 100644
--- a/frontend/components/CommentSection.tsx
+++ b/frontend/components/CommentSection.tsx
@@ -1,12 +1,21 @@
"use client";
-import { useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck, XCircle } from "lucide-react";
+import {
+ Trash2,
+ MessagesSquare,
+ MessageSquareOff,
+ ChevronRight,
+ ChevronDown,
+ BadgeCheck,
+ XCircle,
+} from "lucide-react";
import {
apiCreateComment,
apiDeleteComment,
+ apiPurgeComment,
apiAcceptBounty,
apiAcceptQuestion,
apiAdminApproveComment,
@@ -16,6 +25,7 @@ import {
} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
+import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import Avatar from "./Avatar";
import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
@@ -37,6 +47,8 @@ interface CommentSectionProps {
/** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
acceptMode?: "bounty" | "question" | null;
acceptedCommentId?: number;
+ /** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */
+ allowComments?: boolean;
}
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
@@ -59,6 +71,7 @@ export default function CommentSection({
user,
acceptMode = null,
acceptedCommentId = 0,
+ allowComments: allowCommentsProp = true,
}: CommentSectionProps) {
const router = useRouter();
const [content, setContent] = useState("");
@@ -72,13 +85,33 @@ export default function CommentSection({
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);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState>(new Set());
+ const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
const textareaRef = useRef(null);
+ useEffect(() => {
+ setCommentsOpen(allowCommentsProp !== false);
+ }, [allowCommentsProp]);
+
+ // 管理后台开关变更后即时隐藏发评入口(后端仍硬拒绝)
+ useEffect(() => {
+ return realtime.on(RT_SETTINGS_CHANGED, (data) => {
+ if (typeof data?.allow_comments === "boolean") {
+ setCommentsOpen(data.allow_comments);
+ if (!data.allow_comments) {
+ setReplyTarget(null);
+ setReplyContent("");
+ }
+ }
+ });
+ }, []);
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (!content.trim()) return;
+ if (!commentsOpen || !content.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, content);
@@ -105,7 +138,7 @@ export default function CommentSection({
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
- if (!replyTarget || !replyContent.trim()) return;
+ if (!commentsOpen || !replyTarget || !replyContent.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
@@ -204,19 +237,47 @@ export default function CommentSection({
}
};
+ const handlePurgeConfirm = async () => {
+ if (purgeTargetId == null) return;
+ setPurging(true);
+ try {
+ const res = await apiPurgeComment(postId, purgeTargetId);
+ if (res.message) {
+ toast("已彻底删除", "ok");
+ setPurgeTargetId(null);
+ router.refresh();
+ } else {
+ toast(res.error || "彻底删除失败");
+ }
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "彻底删除失败");
+ } finally {
+ setPurging(false);
+ }
+ };
+
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
- const canDelete = (comment: CommentNode) =>
- !!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
+ const canSoftDelete = (comment: CommentNode) =>
+ !comment.deleted &&
+ !!user &&
+ (user.id === comment.user?.id || canModerateBoard(user, boardId));
+
+ const canPurge = (comment: CommentNode) =>
+ !!user && canModerateBoard(user, boardId);
const canApprove = (comment: CommentNode) =>
- !!user && comment.status === "pending" && canModerateBoard(user, boardId);
+ !comment.deleted &&
+ !!user &&
+ comment.status === "pending" &&
+ canModerateBoard(user, boardId);
const canAccept = (comment: CommentNode) =>
+ !comment.deleted &&
!!acceptMode &&
!!user &&
comment.status === "published" &&
- comment.user.id !== authorId;
+ comment.user?.id !== authorId;
const openReply = (c: CommentNode) => {
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
@@ -235,8 +296,8 @@ export default function CommentSection({
const totalPages = Math.max(1, Math.ceil(total / size));
- // 内联回复表单(出现在目标评论正下方)
- const inlineReplyForm = replyTarget && (
+ // 内联回复表单(出现在目标评论正下方;全站关评时不渲染)
+ const inlineReplyForm = commentsOpen && replyTarget && (
- 来说两句,抢占沙发
+
+ {commentsOpen ? "来说两句,抢占沙发" : "评论开放后即可参与讨论"}
+
)}
@@ -621,6 +828,19 @@ export default function CommentSection({
if (!rejecting) setRejectTargetId(null);
}}
/>
+
+ {
+ if (!purging) setPurgeTargetId(null);
+ }}
+ />
);
}
diff --git a/frontend/components/NotificationBell.tsx b/frontend/components/NotificationBell.tsx
index b6da141..e656120 100644
--- a/frontend/components/NotificationBell.tsx
+++ b/frontend/components/NotificationBell.tsx
@@ -133,15 +133,17 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "rejected") return "未通过审核";
if (n.type === "mention") return "在消息中提到了你";
if (n.type === "pending_review") return "有新内容待审核";
+ if (n.type === "deleted") return "删除了你的帖子";
return "点赞了你的帖子";
};
// 审核结果类通知:无互动对象语义,使用专用图标与语义色
- const isApproval = (n: NotificationItem) => n.type === "approved" || n.type === "rejected";
+ const isApproval = (n: NotificationItem) =>
+ n.type === "approved" || n.type === "rejected" || n.type === "deleted";
const iconStyle = (n: NotificationItem): { color: string; bg: string; node: React.ReactNode } => {
if (n.type === "approved")
return { color: "var(--ok)", bg: "var(--ok-soft)", node: };
- if (n.type === "rejected")
+ if (n.type === "rejected" || n.type === "deleted")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: };
if (n.type === "like")
return { color: "var(--clay)", bg: "var(--clay-soft)", node: };
diff --git a/frontend/components/PostActions.tsx b/frontend/components/PostActions.tsx
index f19c7c2..62b7857 100644
--- a/frontend/components/PostActions.tsx
+++ b/frontend/components/PostActions.tsx
@@ -8,6 +8,7 @@ import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprove
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
+import StaffDeletePostModal from "@/components/StaffDeletePostModal";
interface PostActionsProps {
postId: string;
@@ -17,6 +18,8 @@ interface PostActionsProps {
recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string;
+ /** 供删除确认展示 */
+ title?: string;
}
export default function PostActions({
@@ -26,6 +29,7 @@ export default function PostActions({
pinned,
recommended,
status,
+ title,
}: PostActionsProps) {
const router = useRouter();
const [user, setUser] = useState(null);
@@ -35,6 +39,7 @@ export default function PostActions({
const [isRecommended, setIsRecommended] = useState(recommended);
const [acting, setActing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
+ const [staffDeleteOpen, setStaffDeleteOpen] = useState(false);
const menuRef = useRef(null);
useEffect(() => {
@@ -43,7 +48,6 @@ export default function PostActions({
.catch(() => setUser(null));
}, []);
- // 点击外部 / Esc 关闭菜单(与通知铃铛同一交互模式)
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
@@ -60,17 +64,20 @@ export default function PostActions({
};
}, [open]);
- // 作者本人或有该板块审核权的管理团队可管理;置顶/加精仅管理员及以上
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
const isAdmin = !!user && isAdminOrAbove(user.role);
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
+ // staff 删他人帖:走类型+理由;作者自删:简单确认
+ const staffDeletingOther =
+ !!user && user.id !== authorId && canModerateBoard(user, boardId);
- const handleDelete = async () => {
+ const runDelete = async (opts?: { delete_type?: string; delete_reason?: string }) => {
setDeleting(true);
try {
- const res = await apiDeletePost(postId);
+ const res = await apiDeletePost(postId, opts);
if (res.message) {
setConfirmDelete(false);
+ setStaffDeleteOpen(false);
router.push("/");
toast("帖子已删除", "ok");
} else {
@@ -217,7 +224,8 @@ export default function PostActions({
role="menuitem"
onClick={() => {
setOpen(false);
- setConfirmDelete(true);
+ if (staffDeletingOther) setStaffDeleteOpen(true);
+ else setConfirmDelete(true);
}}
disabled={deleting}
className={`${itemCls} items-center`}
@@ -232,13 +240,21 @@ export default function PostActions({
void runDelete()}
onCancel={() => setConfirmDelete(false)}
/>
+
+ setStaffDeleteOpen(false)}
+ onConfirm={(opts) => void runDelete(opts)}
+ />
);
}
diff --git a/frontend/components/StaffDeletePostModal.tsx b/frontend/components/StaffDeletePostModal.tsx
new file mode 100644
index 0000000..5a65334
--- /dev/null
+++ b/frontend/components/StaffDeletePostModal.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Modal from "@/components/Modal";
+import { POST_DELETE_TYPES } from "@/lib/api";
+
+/**
+ * Staff 软删帖:必填删除类型 + 理由。
+ * 作者自删仍用 ConfirmDialog,不走此组件。
+ */
+export default function StaffDeletePostModal({
+ open,
+ title,
+ busy,
+ onClose,
+ onConfirm,
+}: {
+ open: boolean;
+ /** 帖子标题,展示在说明里 */
+ title?: string;
+ busy?: boolean;
+ onClose: () => void;
+ onConfirm: (opts: { delete_type: string; delete_reason: string }) => 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 submit = () => {
+ const r = reason.trim();
+ if (!r) {
+ setErr("请填写删除理由");
+ return;
+ }
+ if ([...r].length > 200) {
+ setErr("删除理由不能超过 200 字");
+ return;
+ }
+ onConfirm({ delete_type: deleteType, delete_reason: r });
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+ 删除类型
+
+
+ {POST_DELETE_TYPES.map((t) => {
+ const active = deleteType === t.value;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}