feat(moderation): 帖评删除占位感知与硬删清场
管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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": "已彻底删除"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": "已删除"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 站内通知
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 从回收站恢复帖子
|
||||
|
||||
@@ -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
|
||||
var post model.Post
|
||||
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
if descendants[c.ID] && c.Status == model.ContentStatusPublished {
|
||||
publishedCnt++
|
||||
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
|
||||
return ErrCommentForbidden
|
||||
}
|
||||
}
|
||||
// 软删子树并按已发布条数递减帖子评论数
|
||||
if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil {
|
||||
_ = 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
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
var (
|
||||
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,12 +421,24 @@ 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
} | null>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
const [staffDeletePost, setStaffDeletePost] = useState<AdminContentPost | null>(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)}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</button>
|
||||
@@ -691,6 +683,25 @@ export default function ContentAdmin({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<StaffDeletePostModal
|
||||
open={!!staffDeletePost}
|
||||
title={staffDeletePost?.title}
|
||||
busy={staffDeleteBusy}
|
||||
onClose={() => !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));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
const approval = n.type === "approved" || n.type === "rejected";
|
||||
const iconNode = n.type === "approved"
|
||||
? <CheckCircle2 size={16} />
|
||||
: n.type === "rejected"
|
||||
: n.type === "rejected" || n.type === "deleted"
|
||||
? <XCircle size={16} />
|
||||
: n.type === "like"
|
||||
? <Heart size={16} />
|
||||
@@ -87,14 +87,14 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
: <MessageCircle size={16} />;
|
||||
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,6 +143,8 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
? "在消息中提到了你"
|
||||
: n.type === "pending_review"
|
||||
? "有新内容待审核"
|
||||
: n.type === "deleted"
|
||||
? "删除了你的帖子"
|
||||
: "点赞了你的帖子"}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -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<Metadata>
|
||||
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 (
|
||||
<div className="max-w-lg mx-auto py-16 px-4 text-center">
|
||||
<p
|
||||
className="inline-flex items-center rounded-full px-3 py-1 text-[12px] font-semibold mb-4"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
帖子已删除
|
||||
</p>
|
||||
<h1 className="text-[20px] sm:text-[22px] font-extrabold mb-3" style={{ color: "var(--ink)" }}>
|
||||
{post.title || "(无标题)"}
|
||||
</h1>
|
||||
{post.staff_deleted ? (
|
||||
<div
|
||||
className="panel text-left p-5 sm:p-6 mt-2"
|
||||
style={{ borderColor: "var(--danger-soft)" }}
|
||||
>
|
||||
<p className="text-[14px] font-medium mb-3" style={{ color: "var(--ink)" }}>
|
||||
此帖已被管理员删除
|
||||
</p>
|
||||
{typeLabel && (
|
||||
<p className="text-[13px] mb-2" style={{ color: "var(--ink-2)" }}>
|
||||
<span style={{ color: "var(--ink-3)" }}>类型:</span>
|
||||
{typeLabel}
|
||||
</p>
|
||||
)}
|
||||
{post.delete_reason && (
|
||||
<p className="text-[13px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||
<span style={{ color: "var(--ink-3)" }}>理由:</span>
|
||||
{post.delete_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="meta text-[14px] mt-2">作者已删除此帖</p>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<Link href="/" className="btn btn-line btn-sm">
|
||||
返回首页
|
||||
</Link>
|
||||
{post.board?.id ? (
|
||||
<Link href={`/?board=${post.board_id}`} className="btn btn-primary btn-sm">
|
||||
浏览板块
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 评论 / 当前用户 / 站点设置 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [rejectTargetId, setRejectTargetId] = useState<number | null>(null);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [purgeTargetId, setPurgeTargetId] = useState<number | null>(null);
|
||||
const [purging, setPurging] = useState(false);
|
||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCommentsOpen(allowCommentsProp !== false);
|
||||
}, [allowCommentsProp]);
|
||||
|
||||
// 管理后台开关变更后即时隐藏发评入口(后端仍硬拒绝)
|
||||
useEffect(() => {
|
||||
return realtime.on<RtSettingsData>(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 && (
|
||||
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-3">
|
||||
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
|
||||
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
|
||||
@@ -285,7 +346,104 @@ 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?.nickname || parent?.user?.username;
|
||||
|
||||
// 软删占位:保留楼层/回复位,不展示正文;子回复仍可展开
|
||||
if (c.deleted) {
|
||||
const tombstoneBody = (
|
||||
<div
|
||||
className="rounded-lg px-3 py-2.5 text-[13px]"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
color: "var(--ink-3)",
|
||||
border: "1px dashed var(--line)",
|
||||
}}
|
||||
>
|
||||
此评论已删除
|
||||
</div>
|
||||
);
|
||||
const purgeBtn = canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--danger)]"
|
||||
disabled={purging}
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
data-tip="彻底删除,不占位"
|
||||
>
|
||||
彻底删除
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!isFloor) {
|
||||
return (
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24 opacity-80">
|
||||
<div className="flex gap-2.5">
|
||||
<span className="shrink-0 w-7 h-7 rounded-full" style={{ background: "var(--line)" }} aria-hidden />
|
||||
<div className="flex-1 min-w-0">
|
||||
{tombstoneBody}
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{purgeBtn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{node.replies && node.replies.length > 0 && (
|
||||
<div className="cmt-children">
|
||||
{node.replies.map((r) => renderComment(r, depth + 1, undefined, node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
id={`comment-${c.id}`}
|
||||
className="py-5 border-t first:border-t-0 scroll-mt-24 opacity-90"
|
||||
style={{ borderColor: "var(--line)" }}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<span className="shrink-0 w-[38px] h-[38px] rounded-full mt-0.5" style={{ background: "var(--line)" }} aria-hidden />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-1.5">
|
||||
<span className="text-sm" style={{ color: "var(--ink-3)" }}>
|
||||
已删除
|
||||
</span>
|
||||
{floor && (
|
||||
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
||||
#{floor}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{tombstoneBody}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{purgeBtn}
|
||||
</div>
|
||||
{node.replies && node.replies.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFloor(c.id)}
|
||||
className="cmt-fold-btn"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{isExpanded ? "收起回复" : `展开 ${replyCount} 条回复`}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="cmt-replies-wrap">
|
||||
{node.replies.map((r) => renderComment(r, 1, undefined, node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFloor) {
|
||||
// 子回复:紧凑行式布局(抖音风格)
|
||||
@@ -323,7 +481,7 @@ export default function CommentSection({
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{user && (
|
||||
{commentsOpen && user && (
|
||||
<button
|
||||
onClick={() => openReply(c)}
|
||||
className={`cmt-reply-btn transition-colors ${
|
||||
@@ -374,7 +532,7 @@ export default function CommentSection({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
@@ -382,17 +540,27 @@ export default function CommentSection({
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
|
||||
</span>
|
||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
disabled={purging}
|
||||
className="meta p-1 hover:text-[var(--danger)] transition-colors"
|
||||
data-tip="彻底删除,不占位"
|
||||
aria-label="彻底删除"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
</div>
|
||||
@@ -440,7 +608,7 @@ export default function CommentSection({
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{user && (
|
||||
{commentsOpen && user && (
|
||||
<button
|
||||
onClick={() => openReply(c)}
|
||||
className={`cmt-reply-btn transition-colors ${
|
||||
@@ -491,7 +659,7 @@ export default function CommentSection({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
@@ -499,17 +667,27 @@ export default function CommentSection({
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
|
||||
</span>
|
||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
disabled={purging}
|
||||
className="meta p-1 hover:text-[var(--danger)] transition-colors"
|
||||
data-tip="彻底删除,不占位"
|
||||
aria-label="彻底删除"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
|
||||
@@ -555,7 +733,34 @@ export default function CommentSection({
|
||||
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
{!commentsOpen ? (
|
||||
<div
|
||||
className="mb-8 rounded-2xl border px-5 py-6 text-center sm:px-6"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ink-3) 35%, var(--line))",
|
||||
background:
|
||||
"repeating-linear-gradient(-45deg, transparent, transparent 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 7px), var(--panel-2)",
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<span
|
||||
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--ink-3) 14%, transparent)",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<MessageSquareOff size={22} />
|
||||
</span>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
评论功能暂时关闭
|
||||
</p>
|
||||
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||
站点当前暂停发表新评论与回复,历史评论仍可浏览。
|
||||
</p>
|
||||
</div>
|
||||
) : user ? (
|
||||
<form onSubmit={handleSubmit} className="mb-8">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -602,7 +807,9 @@ export default function CommentSection({
|
||||
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
|
||||
暂无评论
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{commentsOpen ? "来说两句,抢占沙发" : "评论开放后即可参与讨论"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -621,6 +828,19 @@ export default function CommentSection({
|
||||
if (!rejecting) setRejectTargetId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={purgeTargetId != null}
|
||||
title="彻底删除评论"
|
||||
message="将从数据库永久删除此评论及其全部子回复,不保留占位、不可恢复。刷屏垃圾请用此操作。"
|
||||
confirmLabel="彻底删除"
|
||||
danger
|
||||
busy={purging}
|
||||
onConfirm={handlePurgeConfirm}
|
||||
onCancel={() => {
|
||||
if (!purging) setPurgeTargetId(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: <CheckCircle2 size={15} /> };
|
||||
if (n.type === "rejected")
|
||||
if (n.type === "rejected" || n.type === "deleted")
|
||||
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <XCircle size={15} /> };
|
||||
if (n.type === "like")
|
||||
return { color: "var(--clay)", bg: "var(--clay-soft)", node: <Heart size={15} /> };
|
||||
|
||||
@@ -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<User | null>(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<HTMLDivElement>(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({
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="删除帖子"
|
||||
message="确定要删除这篇帖子吗?此操作不可恢复。"
|
||||
message="确定要删除这篇帖子吗?列表将不再展示,打开原链接会提示已删除。"
|
||||
confirmLabel="确认删除"
|
||||
danger
|
||||
busy={deleting}
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => void runDelete()}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
|
||||
<StaffDeletePostModal
|
||||
open={staffDeleteOpen}
|
||||
title={title}
|
||||
busy={deleting}
|
||||
onClose={() => setStaffDeleteOpen(false)}
|
||||
onConfirm={(opts) => void runDelete(opts)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
124
frontend/components/StaffDeletePostModal.tsx
Normal file
124
frontend/components/StaffDeletePostModal.tsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
open
|
||||
title="删除帖子"
|
||||
description={
|
||||
title
|
||||
? `将删除「${title}」。列表不再展示;原链接会显示删除类型与理由。`
|
||||
: "列表不再展示;原链接会显示删除类型与理由。"
|
||||
}
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
maxWidthClass="max-w-[440px]"
|
||||
panelClassName="p-5"
|
||||
ariaLabel="删除帖子"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={submit} disabled={busy}>
|
||||
{busy ? "删除中…" : "确认删除"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
|
||||
删除类型
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5 mb-4" role="radiogroup" aria-label="删除类型">
|
||||
{POST_DELETE_TYPES.map((t) => {
|
||||
const active = deleteType === t.value;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setDeleteType(t.value)}
|
||||
className="text-left rounded-lg px-3 py-2 text-[13px] transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="block mb-1.5 text-[12.5px]" style={{ color: "var(--ink)" }} htmlFor="delete-reason">
|
||||
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="delete-reason"
|
||||
className="field"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => {
|
||||
setReason(e.target.value);
|
||||
setErr("");
|
||||
}}
|
||||
placeholder="将展示在帖子专用页与作者通知中"
|
||||
maxLength={200}
|
||||
/>
|
||||
{err && (
|
||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user