feat(comments): 软删占位、理由与恢复,管理删仅对访客打码
自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -106,7 +106,7 @@ func (h *Handlers) CreateComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteComment 软删评论(占位)
|
// DeleteComment 软删评论(占位;自删与管理删均须填理由,管理删另须类型)
|
||||||
func (h *Handlers) DeleteComment(c *gin.Context) {
|
func (h *Handlers) DeleteComment(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||||||
@@ -114,18 +114,58 @@ func (h *Handlers) DeleteComment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.Comment.Delete(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil {
|
var body struct {
|
||||||
|
DeleteType string `json:"delete_type"`
|
||||||
|
DeleteReason string `json:"delete_reason"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
if err := h.Comment.Delete(h.loadActor(claims.ID), uint(cid), claims.ID, service.DeleteCommentOpts{
|
||||||
|
DeleteType: body.DeleteType,
|
||||||
|
DeleteReason: body.DeleteReason,
|
||||||
|
}); err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, service.ErrCommentNotFound):
|
case errors.Is(err, service.ErrCommentNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrCommentForbidden):
|
case errors.Is(err, service.ErrCommentForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrCommentDeleteMeta),
|
||||||
|
errors.Is(err, service.ErrCommentInvalidType):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
msg := err.Error()
|
||||||
|
if msg == "删除理由不能超过 200 字" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreComment 恢复软删评论(自删作者或版主)
|
||||||
|
func (h *Handlers) RestoreComment(c *gin.Context) {
|
||||||
|
claims := middleware.CurrentUser(c)
|
||||||
|
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Comment.Restore(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrCommentNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrCommentForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权限恢复此评论"})
|
||||||
|
case errors.Is(err, service.ErrCommentNotDeleted):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "已恢复"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurgeComment 硬删评论及子树(仅版主;不占位)
|
// PurgeComment 硬删评论及子树(仅版主;不占位)
|
||||||
|
|||||||
@@ -157,7 +157,26 @@ func (h *Handlers) AdminPurgePost(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) AdminSoftDeleteComment(c *gin.Context) {
|
func (h *Handlers) AdminSoftDeleteComment(c *gin.Context) {
|
||||||
h.execContentAction(c, h.Moderation.SoftDeleteComment)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
DeleteType string `json:"delete_type"`
|
||||||
|
DeleteReason string `json:"delete_reason"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请提交删除类型与理由"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := middleware.CurrentActor(c)
|
||||||
|
if err := h.Moderation.SoftDeleteComment(actor, uint(id), body.DeleteType, body.DeleteReason); err != nil {
|
||||||
|
h.writeModerationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.broadcastModerationChanged()
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) AdminRestoreComment(c *gin.Context) {
|
func (h *Handlers) AdminRestoreComment(c *gin.Context) {
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ type Comment struct {
|
|||||||
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
|
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||||
|
// 软删元数据:列表 Unscoped 作 tombstone;DeletedBy≠UserID 为管理删除
|
||||||
|
DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"`
|
||||||
|
DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"`
|
||||||
|
DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.POST("/posts/:id/like", h.ToggleLike)
|
api.POST("/posts/:id/like", h.ToggleLike)
|
||||||
api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment)
|
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", h.DeleteComment)
|
||||||
|
api.PUT("/posts/:id/comments/:cid/restore", h.RestoreComment)
|
||||||
api.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment)
|
api.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment)
|
||||||
// 通知
|
// 通知
|
||||||
api.GET("/notifications", h.Notifications)
|
api.GET("/notifications", h.Notifications)
|
||||||
|
|||||||
@@ -366,8 +366,8 @@ func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftDeleteComment 软删单条评论(不级联子树,后台点对点清理)
|
// SoftDeleteComment 软删单条评论(不级联子树);须选类型,「其他」才须填理由
|
||||||
func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error {
|
func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType, deleteReason string) error {
|
||||||
cm, _, err := s.loadCommentUnscoped(actor, id)
|
cm, _, err := s.loadCommentUnscoped(actor, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -375,6 +375,29 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error {
|
|||||||
if cm.DeletedAt.Valid {
|
if cm.DeletedAt.Valid {
|
||||||
return errors.New("评论已在回收站")
|
return errors.New("评论已在回收站")
|
||||||
}
|
}
|
||||||
|
deleteType = strings.TrimSpace(deleteType)
|
||||||
|
deleteReason = strings.TrimSpace(deleteReason)
|
||||||
|
if !model.ValidPostDeleteType(deleteType) {
|
||||||
|
return errors.New("无效的删除类型")
|
||||||
|
}
|
||||||
|
if deleteType == model.PostDeleteTypeOther {
|
||||||
|
if deleteReason == "" {
|
||||||
|
return errors.New("请填写删除理由")
|
||||||
|
}
|
||||||
|
if len([]rune(deleteReason)) > 200 {
|
||||||
|
return errors.New("删除理由不能超过 200 字")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteReason = ""
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"delete_type": deleteType,
|
||||||
|
"delete_reason": deleteReason,
|
||||||
|
"deleted_by": actor.ID,
|
||||||
|
}
|
||||||
|
if err := s.db.Model(cm).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := s.db.Delete(cm).Error; err != nil {
|
if err := s.db.Delete(cm).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -385,7 +408,7 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestoreComment 恢复单条评论
|
// RestoreComment 恢复单条评论,并清空删除元数据
|
||||||
func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
|
func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
|
||||||
cm, _, err := s.loadCommentUnscoped(actor, id)
|
cm, _, err := s.loadCommentUnscoped(actor, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -394,7 +417,13 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
|
|||||||
if !cm.DeletedAt.Valid {
|
if !cm.DeletedAt.Valid {
|
||||||
return errors.New("评论未被删除")
|
return errors.New("评论未被删除")
|
||||||
}
|
}
|
||||||
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
|
updates := map[string]interface{}{
|
||||||
|
"deleted_at": nil,
|
||||||
|
"delete_type": "",
|
||||||
|
"delete_reason": "",
|
||||||
|
"deleted_by": 0,
|
||||||
|
}
|
||||||
|
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cm.Status == model.ContentStatusPublished {
|
if cm.Status == model.ContentStatusPublished {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
var (
|
var (
|
||||||
ErrCommentNotFound = errors.New("评论不存在")
|
ErrCommentNotFound = errors.New("评论不存在")
|
||||||
ErrCommentForbidden = errors.New("无权限删除此评论")
|
ErrCommentForbidden = errors.New("无权限删除此评论")
|
||||||
|
ErrCommentDeleteMeta = errors.New("请填写删除理由")
|
||||||
|
ErrCommentInvalidType = errors.New("无效的删除类型")
|
||||||
|
ErrCommentNotDeleted = errors.New("评论未被删除")
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommentService 评论服务
|
// CommentService 评论服务
|
||||||
@@ -28,9 +31,19 @@ func NewCommentService(db *gorm.DB) *CommentService {
|
|||||||
type CommentNode struct {
|
type CommentNode struct {
|
||||||
model.Comment
|
model.Comment
|
||||||
Deleted bool `json:"deleted,omitempty"` // 软删占位
|
Deleted bool `json:"deleted,omitempty"` // 软删占位
|
||||||
|
StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
|
||||||
|
DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开)
|
||||||
Replies []CommentNode `json:"replies,omitempty"`
|
Replies []CommentNode `json:"replies,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommentActorBrief 评论相关公开用户摘要(删评管理员链接等)
|
||||||
|
type CommentActorBrief struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
|
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
|
||||||
const maxReplyDepth = 10
|
const maxReplyDepth = 10
|
||||||
|
|
||||||
@@ -92,19 +105,25 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
|||||||
children[*r.ParentID] = append(children[*r.ParentID], r)
|
children[*r.ParentID] = append(children[*r.ParentID], r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
allRows := make([]model.Comment, 0, len(roots)+len(replies))
|
||||||
|
allRows = append(allRows, roots...)
|
||||||
|
allRows = append(allRows, replies...)
|
||||||
|
deleters := s.loadCommentActorsByIDs(collectStaffDeleterIDs(allRows))
|
||||||
|
|
||||||
placed := make(map[uint]bool, len(roots)+len(replies))
|
placed := make(map[uint]bool, len(roots)+len(replies))
|
||||||
|
revealAudit := actor != nil && actor.CanModerateBoard(boardID)
|
||||||
var build func(pid uint) []CommentNode
|
var build func(pid uint) []CommentNode
|
||||||
build = func(pid uint) []CommentNode {
|
build = func(pid uint) []CommentNode {
|
||||||
out := []CommentNode{}
|
out := []CommentNode{}
|
||||||
for _, c := range children[pid] {
|
for _, c := range children[pid] {
|
||||||
out = append(out, toCommentNode(c, build(c.ID)))
|
out = append(out, toCommentNode(c, build(c.ID), revealAudit, deleters))
|
||||||
placed[c.ID] = true
|
placed[c.ID] = true
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
result := make([]CommentNode, len(roots))
|
result := make([]CommentNode, len(roots))
|
||||||
for i := range roots {
|
for i := range roots {
|
||||||
result[i] = toCommentNode(roots[i], build(roots[i].ID))
|
result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, deleters)
|
||||||
placed[roots[i].ID] = true
|
placed[roots[i].ID] = true
|
||||||
}
|
}
|
||||||
for _, r := range replies {
|
for _, r := range replies {
|
||||||
@@ -113,7 +132,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
|||||||
}
|
}
|
||||||
for i := range result {
|
for i := range result {
|
||||||
if result[i].ID == *r.RootID {
|
if result[i].ID == *r.RootID {
|
||||||
result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID)))
|
result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, deleters))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,14 +140,70 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
|||||||
return result, floors, totalComments, nil
|
return result, floors, totalComments, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toCommentNode 软删行清空正文与用户展示信息,标 deleted
|
func collectStaffDeleterIDs(rows []model.Comment) []uint {
|
||||||
func toCommentNode(c model.Comment, replies []CommentNode) CommentNode {
|
seen := map[uint]bool{}
|
||||||
|
var ids []uint
|
||||||
|
for _, c := range rows {
|
||||||
|
if !c.DeletedAt.Valid || c.DeletedBy == 0 || c.DeletedBy == c.UserID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seen[c.DeletedBy] {
|
||||||
|
seen[c.DeletedBy] = true
|
||||||
|
ids = append(ids, c.DeletedBy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActorBrief {
|
||||||
|
out := map[uint]CommentActorBrief{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
if err := s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
out[u.ID] = CommentActorBrief{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Nickname: u.Nickname,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。
|
||||||
|
func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode {
|
||||||
if !c.DeletedAt.Valid {
|
if !c.DeletedAt.Valid {
|
||||||
return CommentNode{Comment: c, Replies: replies}
|
return CommentNode{Comment: c, Replies: replies}
|
||||||
}
|
}
|
||||||
|
staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||||
|
if !revealAudit {
|
||||||
c.Content = ""
|
c.Content = ""
|
||||||
c.User = model.User{}
|
}
|
||||||
return CommentNode{Comment: c, Deleted: true, Replies: replies}
|
if !staff {
|
||||||
|
c.DeleteType = ""
|
||||||
|
if !revealAudit {
|
||||||
|
c.DeleteReason = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var deleter *CommentActorBrief
|
||||||
|
if staff {
|
||||||
|
if u, ok := deleters[c.DeletedBy]; ok {
|
||||||
|
cp := u
|
||||||
|
deleter = &cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CommentNode{
|
||||||
|
Comment: c,
|
||||||
|
Deleted: true,
|
||||||
|
StaffDeleted: staff,
|
||||||
|
DeletedByUser: deleter,
|
||||||
|
Replies: replies,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
||||||
@@ -265,20 +340,60 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
|||||||
return total, err
|
return total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 软删单条评论(作者本人或板块审核权),不级联子树;占位保留楼层。
|
// DeleteCommentOpts 软删选项:自删须填理由;staff 须选类型,「其他」才须填理由
|
||||||
func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
|
type DeleteCommentOpts struct {
|
||||||
|
DeleteType string
|
||||||
|
DeleteReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删单条评论(评论者本人或板块审核权),不级联子树;占位保留楼层。
|
||||||
|
func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts DeleteCommentOpts) error {
|
||||||
var comment model.Comment
|
var comment model.Comment
|
||||||
if err := s.db.First(&comment, commentID).Error; err != nil {
|
if err := s.db.First(&comment, commentID).Error; err != nil {
|
||||||
return ErrCommentNotFound
|
return ErrCommentNotFound
|
||||||
}
|
}
|
||||||
if comment.UserID != userID {
|
|
||||||
|
deleteType := ""
|
||||||
|
deleteReason := strings.TrimSpace(opts.DeleteReason)
|
||||||
|
if comment.UserID == userID {
|
||||||
|
if deleteReason == "" {
|
||||||
|
return ErrCommentDeleteMeta
|
||||||
|
}
|
||||||
|
if len([]rune(deleteReason)) > 200 {
|
||||||
|
return errors.New("删除理由不能超过 200 字")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
var post model.Post
|
var post model.Post
|
||||||
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
|
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
|
||||||
return ErrCommentNotFound
|
return ErrCommentNotFound
|
||||||
}
|
}
|
||||||
if !actor.CanModerateBoard(post.BoardID) {
|
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
|
||||||
return ErrCommentForbidden
|
return ErrCommentForbidden
|
||||||
}
|
}
|
||||||
|
deleteType = strings.TrimSpace(opts.DeleteType)
|
||||||
|
if !model.ValidPostDeleteType(deleteType) {
|
||||||
|
return ErrCommentInvalidType
|
||||||
|
}
|
||||||
|
if deleteType == model.PostDeleteTypeOther {
|
||||||
|
if deleteReason == "" {
|
||||||
|
return ErrCommentDeleteMeta
|
||||||
|
}
|
||||||
|
if len([]rune(deleteReason)) > 200 {
|
||||||
|
return errors.New("删除理由不能超过 200 字")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非「其他」仅展示类型标签,不落库自由文本
|
||||||
|
deleteReason = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"delete_type": deleteType,
|
||||||
|
"delete_reason": deleteReason,
|
||||||
|
"deleted_by": userID,
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&comment).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if err := s.db.Delete(&comment).Error; err != nil {
|
if err := s.db.Delete(&comment).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -290,6 +405,49 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore 恢复软删评论:作者可恢复自己的自删;版主可恢复本板任意软删(含管理删除)。
|
||||||
|
func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error {
|
||||||
|
var comment model.Comment
|
||||||
|
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
if !comment.DeletedAt.Valid {
|
||||||
|
return ErrCommentNotDeleted
|
||||||
|
}
|
||||||
|
|
||||||
|
staffDeleted := comment.DeletedBy != 0 && comment.DeletedBy != comment.UserID
|
||||||
|
allowed := false
|
||||||
|
if comment.UserID == userID && !staffDeleted {
|
||||||
|
allowed = true
|
||||||
|
} else {
|
||||||
|
var post model.Post
|
||||||
|
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
if actor != nil && actor.CanModerateBoard(post.BoardID) {
|
||||||
|
allowed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return ErrCommentForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"deleted_at": nil,
|
||||||
|
"delete_type": "",
|
||||||
|
"delete_reason": "",
|
||||||
|
"deleted_by": 0,
|
||||||
|
}
|
||||||
|
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if comment.Status == model.ContentStatusPublished {
|
||||||
|
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
||||||
|
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// collectDescendantIDs 收集以 root 为根的子树 ID(含自身)
|
// collectDescendantIDs 收集以 root 为根的子树 ID(含自身)
|
||||||
func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint {
|
func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint {
|
||||||
var all []model.Comment
|
var all []model.Comment
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { toast } from "@/lib/toast";
|
|||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
||||||
|
import DeleteCommentModal from "@/components/DeleteCommentModal";
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
@@ -142,6 +143,8 @@ export default function ContentAdmin({
|
|||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
const [staffDeletePost, setStaffDeletePost] = useState<AdminContentPost | null>(null);
|
const [staffDeletePost, setStaffDeletePost] = useState<AdminContentPost | null>(null);
|
||||||
const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
|
const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
|
||||||
|
const [staffDeleteComment, setStaffDeleteComment] = useState<AdminContentComment | null>(null);
|
||||||
|
const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false);
|
||||||
|
|
||||||
const refreshCounts = useCallback(() => {
|
const refreshCounts = useCallback(() => {
|
||||||
apiAdminContentCounts().then(setCounts).catch(() => {});
|
apiAdminContentCounts().then(setCounts).catch(() => {});
|
||||||
@@ -605,18 +608,7 @@ export default function ContentAdmin({
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line btn-sm"
|
className="btn btn-line btn-sm"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() => setStaffDeleteComment(c)}
|
||||||
askConfirm(
|
|
||||||
"移入回收站",
|
|
||||||
"确定软删这条评论吗?可在「已删除」中恢复。",
|
|
||||||
() =>
|
|
||||||
apiAdminSoftDeleteComment(c.id).then(() => {
|
|
||||||
toast("已移入回收站", "ok");
|
|
||||||
reload(commentsPage);
|
|
||||||
}),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 size={14} /> 删除
|
<Trash2 size={14} /> 删除
|
||||||
</button>
|
</button>
|
||||||
@@ -702,6 +694,25 @@ export default function ContentAdmin({
|
|||||||
.finally(() => setStaffDeleteBusy(false));
|
.finally(() => setStaffDeleteBusy(false));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteCommentModal
|
||||||
|
open={!!staffDeleteComment}
|
||||||
|
mode="staff"
|
||||||
|
busy={staffDeleteCommentBusy}
|
||||||
|
onClose={() => !staffDeleteCommentBusy && setStaffDeleteComment(null)}
|
||||||
|
onConfirm={(opts) => {
|
||||||
|
if (!staffDeleteComment) return;
|
||||||
|
setStaffDeleteCommentBusy(true);
|
||||||
|
apiAdminSoftDeleteComment(staffDeleteComment.id, opts)
|
||||||
|
.then(() => {
|
||||||
|
toast("已移入回收站", "ok");
|
||||||
|
setStaffDeleteComment(null);
|
||||||
|
reload(commentsPage);
|
||||||
|
})
|
||||||
|
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||||
|
.finally(() => setStaffDeleteCommentBusy(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2154,6 +2154,161 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 软删占位:头像/昵称马赛克,可读结构但不可辨认身份 */
|
||||||
|
.cmt-mosaic-avatar {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cmt-mosaic-avatar > * {
|
||||||
|
filter: blur(7px) saturate(0.35) contrast(0.9);
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
.cmt-mosaic-avatar::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
color-mix(in srgb, var(--ink) 18%, transparent) 0 1px,
|
||||||
|
transparent 1px 3px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--ink) 18%, transparent) 0 1px,
|
||||||
|
transparent 1px 3px
|
||||||
|
);
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cmt-mosaic-name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
filter: blur(5px);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scaleY(1.05);
|
||||||
|
}
|
||||||
|
.cmt-tombstone {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px dashed color-mix(in srgb, var(--ink-3) 45%, var(--line));
|
||||||
|
}
|
||||||
|
.cmt-tombstone-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.cmt-tombstone-reason {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.cmt-tombstone-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.cmt-tombstone-tag-self {
|
||||||
|
background: color-mix(in srgb, var(--ink-3) 16%, var(--panel));
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.cmt-tombstone-audit {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||||
|
}
|
||||||
|
.cmt-tombstone-audit-label {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.cmt-tombstone-orig {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.cmt-tombstone-orig .prose-comment {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.cmt-tombstone-mod {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.cmt-tombstone-mod a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cmt-tombstone-mod a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 评论管理操作:紧凑文字按钮组,取代孤立图标 */
|
||||||
|
.cmt-mod-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.cmt-mod-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink-2);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.cmt-mod-btn:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--danger) 10%, var(--panel));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.cmt-mod-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.cmt-mod-btn-danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--danger) 16%, var(--panel));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.cmt-replies-wrap {
|
.cmt-replies-wrap {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiCreateComment,
|
apiCreateComment,
|
||||||
apiDeleteComment,
|
apiDeleteComment,
|
||||||
|
apiRestoreComment,
|
||||||
apiPurgeComment,
|
apiPurgeComment,
|
||||||
apiAcceptBounty,
|
apiAcceptBounty,
|
||||||
apiAcceptQuestion,
|
apiAcceptQuestion,
|
||||||
apiAdminApproveComment,
|
apiAdminApproveComment,
|
||||||
apiAdminRejectComment,
|
apiAdminRejectComment,
|
||||||
|
postDeleteTypeLabel,
|
||||||
type CommentNode,
|
type CommentNode,
|
||||||
type User,
|
type User,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -30,6 +33,7 @@ import Avatar from "./Avatar";
|
|||||||
import Pagination from "./Pagination";
|
import Pagination from "./Pagination";
|
||||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
|
||||||
|
|
||||||
interface CommentSectionProps {
|
interface CommentSectionProps {
|
||||||
postId: string;
|
postId: string;
|
||||||
@@ -51,14 +55,48 @@ interface CommentSectionProps {
|
|||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
|
// 递归统计子树回复数(彻底删除级联提示用)
|
||||||
const CONFIRM_RESET_MS = 3000;
|
|
||||||
|
|
||||||
// 递归统计子树回复数(删除级联提示用)
|
|
||||||
function countReplies(node: CommentNode): number {
|
function countReplies(node: CommentNode): number {
|
||||||
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
|
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findComment(nodes: CommentNode[], id: number): CommentNode | null {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.id === id) return n;
|
||||||
|
if (n.replies?.length) {
|
||||||
|
const hit = findComment(n.replies, id);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 马赛克头像:保留轮廓,不可辨认 */
|
||||||
|
function MosaicAvatar({
|
||||||
|
name,
|
||||||
|
src,
|
||||||
|
size,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
src?: string;
|
||||||
|
size: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="cmt-mosaic-avatar" style={{ width: size, height: size }} aria-hidden>
|
||||||
|
<Avatar name={name || "用户"} src={src || undefined} size={size} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 马赛克昵称 */
|
||||||
|
function MosaicName({ text, className = "" }: { text: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`cmt-mosaic-name ${className}`} style={{ color: "var(--ink-2)" }} aria-hidden>
|
||||||
|
{text || "用户"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function CommentSection({
|
export default function CommentSection({
|
||||||
postId,
|
postId,
|
||||||
boardId,
|
boardId,
|
||||||
@@ -79,14 +117,16 @@ export default function CommentSection({
|
|||||||
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
||||||
const [replyContent, setReplyContent] = useState("");
|
const [replyContent, setReplyContent] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||||
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
||||||
const [approvingId, setApprovingId] = useState<number | null>(null);
|
const [approvingId, setApprovingId] = useState<number | null>(null);
|
||||||
const [rejectTargetId, setRejectTargetId] = useState<number | null>(null);
|
const [rejectTargetId, setRejectTargetId] = useState<number | null>(null);
|
||||||
const [rejecting, setRejecting] = useState(false);
|
const [rejecting, setRejecting] = useState(false);
|
||||||
const [purgeTargetId, setPurgeTargetId] = useState<number | null>(null);
|
const [purgeTargetId, setPurgeTargetId] = useState<number | null>(null);
|
||||||
const [purging, setPurging] = useState(false);
|
const [purging, setPurging] = useState(false);
|
||||||
|
const [restoreTargetId, setRestoreTargetId] = useState<number | null>(null);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||||
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
|
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
|
||||||
@@ -164,19 +204,20 @@ export default function CommentSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (commentId: number) => {
|
const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null;
|
||||||
if (confirmingId !== commentId) {
|
const deleteMode: "self" | "staff" =
|
||||||
setConfirmingId(commentId);
|
deleteTarget && user && user.id === deleteTarget.user_id ? "self" : "staff";
|
||||||
setTimeout(() => {
|
const purgeTarget = purgeTargetId != null ? findComment(comments, purgeTargetId) : null;
|
||||||
setConfirmingId((cur) => (cur === commentId ? null : cur));
|
const purgeReplyCount = purgeTarget ? countReplies(purgeTarget) : 0;
|
||||||
}, CONFIRM_RESET_MS);
|
|
||||||
return;
|
const handleDeleteConfirm = async (opts: DeleteCommentPayload) => {
|
||||||
}
|
if (deleteTargetId == null) return;
|
||||||
setConfirmingId(null);
|
setDeleting(true);
|
||||||
setDeletingId(commentId);
|
|
||||||
try {
|
try {
|
||||||
const res = await apiDeleteComment(postId, commentId);
|
const res = await apiDeleteComment(postId, deleteTargetId, opts);
|
||||||
if (res.message) {
|
if (res.message) {
|
||||||
|
toast(deleteMode === "self" ? "已删除评论" : "已删除该评论", "ok");
|
||||||
|
setDeleteTargetId(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast(res.error || "删除失败");
|
toast(res.error || "删除失败");
|
||||||
@@ -184,7 +225,7 @@ export default function CommentSection({
|
|||||||
} catch {
|
} catch {
|
||||||
toast("网络错误,删除失败");
|
toast("网络错误,删除失败");
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,6 +297,25 @@ export default function CommentSection({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRestoreConfirm = async () => {
|
||||||
|
if (restoreTargetId == null) return;
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
const res = await apiRestoreComment(postId, restoreTargetId);
|
||||||
|
if (res.message) {
|
||||||
|
toast("评论已恢复", "ok");
|
||||||
|
setRestoreTargetId(null);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast(res.error || "恢复失败");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "恢复失败");
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
||||||
|
|
||||||
const canSoftDelete = (comment: CommentNode) =>
|
const canSoftDelete = (comment: CommentNode) =>
|
||||||
@@ -266,6 +326,13 @@ export default function CommentSection({
|
|||||||
const canPurge = (comment: CommentNode) =>
|
const canPurge = (comment: CommentNode) =>
|
||||||
!!user && canModerateBoard(user, boardId);
|
!!user && canModerateBoard(user, boardId);
|
||||||
|
|
||||||
|
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */
|
||||||
|
const canRestore = (comment: CommentNode) => {
|
||||||
|
if (!comment.deleted || !user) return false;
|
||||||
|
if (canModerateBoard(user, boardId)) return true;
|
||||||
|
return user.id === comment.user_id && !comment.staff_deleted;
|
||||||
|
};
|
||||||
|
|
||||||
const canApprove = (comment: CommentNode) =>
|
const canApprove = (comment: CommentNode) =>
|
||||||
!comment.deleted &&
|
!comment.deleted &&
|
||||||
!!user &&
|
!!user &&
|
||||||
@@ -348,42 +415,135 @@ export default function CommentSection({
|
|||||||
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
|
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) {
|
if (c.deleted) {
|
||||||
const tombstoneBody = (
|
const displayName = c.user?.nickname || c.user?.username || "用户";
|
||||||
<div
|
const typeLabel = postDeleteTypeLabel(c.delete_type);
|
||||||
className="rounded-lg px-3 py-2.5 text-[13px]"
|
const isMod = !!user && canModerateBoard(user, boardId);
|
||||||
style={{
|
const isCommenter = !!user && user.id === c.user_id;
|
||||||
background: "var(--panel-2)",
|
// 管理删:仅普通访客打码;管理员与评论者本人看真实身份
|
||||||
color: "var(--ink-3)",
|
const showMosaic = !!c.staff_deleted && !isMod && !isCommenter;
|
||||||
border: "1px dashed var(--line)",
|
const selfReasonForStaff = !c.staff_deleted && !!c.delete_reason && isMod;
|
||||||
}}
|
const staffPublicMeta = c.staff_deleted && (!!typeLabel || !!c.delete_reason);
|
||||||
|
const deleter = c.deleted_by_user;
|
||||||
|
const deleterName = deleter
|
||||||
|
? deleter.nickname || deleter.username || "管理员"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const identity = showMosaic ? (
|
||||||
|
<MosaicAvatar name={displayName} src={c.user?.avatar} size={isFloor ? 38 : 28} />
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/u/${c.user_id}`}
|
||||||
|
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
|
||||||
|
aria-label={displayName}
|
||||||
>
|
>
|
||||||
此评论已删除
|
<Avatar name={displayName} src={c.user?.avatar || undefined} size={isFloor ? 38 : 28} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameRow = showMosaic ? (
|
||||||
|
<MosaicName text={displayName} className={isFloor ? "text-sm" : "cmt-reply-name"} />
|
||||||
|
) : (
|
||||||
|
<Link href={`/u/${c.user_id}`} className="group">
|
||||||
|
<span
|
||||||
|
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
|
||||||
|
isFloor ? "text-sm" : "cmt-reply-name"
|
||||||
|
}`}
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tombstoneBody = (
|
||||||
|
<div className="cmt-tombstone">
|
||||||
|
<p className="cmt-tombstone-title">
|
||||||
|
{c.staff_deleted ? "管理员已删除此评论" : "评论者已删除此评论"}
|
||||||
|
</p>
|
||||||
|
{staffPublicMeta ? (
|
||||||
|
<p className="cmt-tombstone-reason">
|
||||||
|
{typeLabel ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "var(--ink-2)", fontWeight: 600 }}>{typeLabel}</span>
|
||||||
|
{c.delete_reason ? " · " : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{c.delete_reason || null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{c.staff_deleted && deleter ? (
|
||||||
|
<div className="cmt-tombstone-mod">
|
||||||
|
<span>处理人</span>
|
||||||
|
<Link href={`/u/${deleter.id}`} aria-label={`查看管理员 ${deleterName}`}>
|
||||||
|
<Avatar name={deleterName} src={deleter.avatar || undefined} size={20} />
|
||||||
|
{deleterName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selfReasonForStaff ? (
|
||||||
|
<div className="cmt-tombstone-audit">
|
||||||
|
<span className="cmt-tombstone-audit-label">自删理由 · 仅管理可见</span>
|
||||||
|
<p className="cmt-tombstone-reason !mt-1">{c.delete_reason}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isMod && c.content ? (
|
||||||
|
<div className="cmt-tombstone-orig">
|
||||||
|
<span className="cmt-tombstone-audit-label">原文 · 仅管理可见</span>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<MarkdownBodyClient content={c.content} className="prose-content prose-comment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{c.staff_deleted ? (
|
||||||
|
<span className="cmt-tombstone-tag">管理删除</span>
|
||||||
|
) : (
|
||||||
|
<span className="cmt-tombstone-tag cmt-tombstone-tag-self">评论者删除</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const purgeBtn = canPurge(c) && (
|
const purgeBtn = canPurge(c) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="cmt-reply-btn !text-[var(--danger)]"
|
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||||
disabled={purging}
|
disabled={purging || restoring}
|
||||||
onClick={() => setPurgeTargetId(c.id)}
|
onClick={() => setPurgeTargetId(c.id)}
|
||||||
data-tip="彻底删除,不占位"
|
|
||||||
>
|
>
|
||||||
彻底删除
|
清除
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
const restoreBtn = canRestore(c) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cmt-mod-btn"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
disabled={restoring || purging}
|
||||||
|
onClick={() => setRestoreTargetId(c.id)}
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
恢复
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const tombstoneActions =
|
||||||
|
restoreBtn || purgeBtn ? (
|
||||||
|
<div className="cmt-mod-actions">
|
||||||
|
{restoreBtn}
|
||||||
|
{purgeBtn}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (!isFloor) {
|
if (!isFloor) {
|
||||||
return (
|
return (
|
||||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24 opacity-80">
|
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
<span className="shrink-0 w-7 h-7 rounded-full" style={{ background: "var(--line)" }} aria-hidden />
|
{identity}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="mb-1">{nameRow}</div>
|
||||||
{tombstoneBody}
|
{tombstoneBody}
|
||||||
<div className="flex items-center gap-3 mt-1.5">
|
<div className="flex items-center justify-between gap-3 mt-1.5 flex-wrap">
|
||||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||||
{purgeBtn}
|
{tombstoneActions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,16 +560,14 @@ export default function CommentSection({
|
|||||||
<div
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
id={`comment-${c.id}`}
|
id={`comment-${c.id}`}
|
||||||
className="py-5 border-t first:border-t-0 scroll-mt-24 opacity-90"
|
className="py-5 border-t first:border-t-0 scroll-mt-24"
|
||||||
style={{ borderColor: "var(--line)" }}
|
style={{ borderColor: "var(--line)" }}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<span className="shrink-0 w-[38px] h-[38px] rounded-full mt-0.5" style={{ background: "var(--line)" }} aria-hidden />
|
{identity}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2.5 mb-1.5">
|
<div className="flex items-center gap-2.5 mb-1.5">
|
||||||
<span className="text-sm" style={{ color: "var(--ink-3)" }}>
|
{nameRow}
|
||||||
已删除
|
|
||||||
</span>
|
|
||||||
{floor && (
|
{floor && (
|
||||||
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
||||||
#{floor}
|
#{floor}
|
||||||
@@ -417,9 +575,9 @@ export default function CommentSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tombstoneBody}
|
{tombstoneBody}
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center justify-between gap-3 mt-2 flex-wrap">
|
||||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||||
{purgeBtn}
|
{tombstoneActions}
|
||||||
</div>
|
</div>
|
||||||
{node.replies && node.replies.length > 0 && (
|
{node.replies && node.replies.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -532,36 +690,34 @@ export default function CommentSection({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(canSoftDelete(c) || (canPurge(c) && !c.deleted)) && (
|
||||||
|
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
|
||||||
{canSoftDelete(c) && (
|
{canSoftDelete(c) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(c.id)}
|
type="button"
|
||||||
disabled={deletingId === c.id}
|
className="cmt-mod-btn"
|
||||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
onClick={() => setDeleteTargetId(c.id)}
|
||||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
disabled={deleting}
|
||||||
}`}
|
aria-label="删除评论"
|
||||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
|
||||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
|
||||||
>
|
>
|
||||||
{confirmingId === c.id ? (
|
<Trash2 size={12} />
|
||||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
删除
|
||||||
) : (
|
|
||||||
<Trash2 size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canPurge(c) && (
|
{canPurge(c) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||||
onClick={() => setPurgeTargetId(c.id)}
|
onClick={() => setPurgeTargetId(c.id)}
|
||||||
disabled={purging}
|
disabled={purging}
|
||||||
className="meta p-1 hover:text-[var(--danger)] transition-colors"
|
|
||||||
data-tip="彻底删除,不占位"
|
|
||||||
aria-label="彻底删除"
|
aria-label="彻底删除"
|
||||||
>
|
>
|
||||||
<XCircle size={14} />
|
清除
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{replyTarget?.id === c.id && inlineReplyForm}
|
{replyTarget?.id === c.id && inlineReplyForm}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -659,36 +815,34 @@ export default function CommentSection({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(canSoftDelete(c) || canPurge(c)) && (
|
||||||
|
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
|
||||||
{canSoftDelete(c) && (
|
{canSoftDelete(c) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(c.id)}
|
type="button"
|
||||||
disabled={deletingId === c.id}
|
className="cmt-mod-btn"
|
||||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
onClick={() => setDeleteTargetId(c.id)}
|
||||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
disabled={deleting}
|
||||||
}`}
|
aria-label="删除评论"
|
||||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
|
||||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
|
||||||
>
|
>
|
||||||
{confirmingId === c.id ? (
|
<Trash2 size={12} />
|
||||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
删除
|
||||||
) : (
|
|
||||||
<Trash2 size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canPurge(c) && (
|
{canPurge(c) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||||
onClick={() => setPurgeTargetId(c.id)}
|
onClick={() => setPurgeTargetId(c.id)}
|
||||||
disabled={purging}
|
disabled={purging}
|
||||||
className="meta p-1 hover:text-[var(--danger)] transition-colors"
|
|
||||||
data-tip="彻底删除,不占位"
|
|
||||||
aria-label="彻底删除"
|
aria-label="彻底删除"
|
||||||
>
|
>
|
||||||
<XCircle size={14} />
|
清除
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{replyTarget?.id === c.id && inlineReplyForm}
|
{replyTarget?.id === c.id && inlineReplyForm}
|
||||||
|
|
||||||
{/* 折叠/展开子回复 */}
|
{/* 折叠/展开子回复 */}
|
||||||
@@ -829,11 +983,37 @@ export default function CommentSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteCommentModal
|
||||||
|
open={deleteTargetId != null}
|
||||||
|
mode={deleteMode}
|
||||||
|
busy={deleting}
|
||||||
|
onClose={() => {
|
||||||
|
if (!deleting) setDeleteTargetId(null);
|
||||||
|
}}
|
||||||
|
onConfirm={(opts) => void handleDeleteConfirm(opts)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={restoreTargetId != null}
|
||||||
|
title="恢复评论"
|
||||||
|
message="确定恢复这条评论?正文与评论者信息将重新公开显示。"
|
||||||
|
confirmLabel="确认恢复"
|
||||||
|
busy={restoring}
|
||||||
|
onConfirm={handleRestoreConfirm}
|
||||||
|
onCancel={() => {
|
||||||
|
if (!restoring) setRestoreTargetId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={purgeTargetId != null}
|
open={purgeTargetId != null}
|
||||||
title="彻底删除评论"
|
title="彻底删除评论"
|
||||||
message="将从数据库永久删除此评论及其全部子回复,不保留占位、不可恢复。刷屏垃圾请用此操作。"
|
message={
|
||||||
confirmLabel="彻底删除"
|
purgeReplyCount > 0
|
||||||
|
? `将永久清除此评论及其 ${purgeReplyCount} 条回复,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。`
|
||||||
|
: "将永久清除此评论,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。"
|
||||||
|
}
|
||||||
|
confirmLabel="永久清除"
|
||||||
danger
|
danger
|
||||||
busy={purging}
|
busy={purging}
|
||||||
onConfirm={handlePurgeConfirm}
|
onConfirm={handlePurgeConfirm}
|
||||||
|
|||||||
172
frontend/components/DeleteCommentModal.tsx
Normal file
172
frontend/components/DeleteCommentModal.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { POST_DELETE_TYPES } from "@/lib/api";
|
||||||
|
|
||||||
|
export type DeleteCommentPayload = {
|
||||||
|
delete_type: string;
|
||||||
|
delete_reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评论软删弹窗:
|
||||||
|
* - 评论者自删:必填理由(仅管理可见)
|
||||||
|
* - 管理删:必选类型;仅「其他」须填理由
|
||||||
|
*/
|
||||||
|
export default function DeleteCommentModal({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
/** self=评论者自删;staff=版主/后台删除 */
|
||||||
|
mode: "self" | "staff";
|
||||||
|
busy?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (opts: DeleteCommentPayload) => void;
|
||||||
|
}) {
|
||||||
|
const [deleteType, setDeleteType] = useState("spam");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDeleteType("spam");
|
||||||
|
setReason("");
|
||||||
|
setErr("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const isStaff = mode === "staff";
|
||||||
|
const reasonRequired = !isStaff || deleteType === "other";
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const r = reason.trim();
|
||||||
|
if (reasonRequired) {
|
||||||
|
if (!r) {
|
||||||
|
setErr("请填写删除理由");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ([...r].length > 200) {
|
||||||
|
setErr("删除理由不能超过 200 字");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onConfirm({
|
||||||
|
delete_type: isStaff ? deleteType : "",
|
||||||
|
delete_reason: reasonRequired ? r : "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={isStaff ? "删除评论" : "删除我的评论"}
|
||||||
|
description={
|
||||||
|
isStaff
|
||||||
|
? "评论区保留马赛克占位;读者可见删除类型,「其他」时还会展示补充理由。子回复不受影响。"
|
||||||
|
: "评论区保留马赛克占位,读者仅见「评论者已删除此评论」。你填写的理由仅管理员可见。"
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
busy={busy}
|
||||||
|
maxWidthClass="max-w-[440px]"
|
||||||
|
panelClassName="p-5"
|
||||||
|
ariaLabel={isStaff ? "删除评论" : "删除我的评论"}
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isStaff ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
删除类型
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
|
||||||
|
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);
|
||||||
|
setErr("");
|
||||||
|
if (t.value !== "other") setReason("");
|
||||||
|
}}
|
||||||
|
className="text-left rounded-lg px-3 py-2.5 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>
|
||||||
|
<p className="meta text-[11.5px] mb-4 leading-relaxed">
|
||||||
|
选「其他」时需补充理由;其余类型直接按标签展示给读者。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{reasonRequired ? (
|
||||||
|
<>
|
||||||
|
<label
|
||||||
|
className="block mb-1.5 text-[12.5px] font-medium"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
htmlFor="cmt-delete-reason"
|
||||||
|
>
|
||||||
|
{isStaff ? "补充理由" : "删除理由"}{" "}
|
||||||
|
<span style={{ color: "var(--danger)" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="cmt-delete-reason"
|
||||||
|
className="field"
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => {
|
||||||
|
setReason(e.target.value);
|
||||||
|
setErr("");
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
isStaff
|
||||||
|
? "向读者说明删除原因(将展示在占位中)"
|
||||||
|
: "例如:发错了 / 不想保留(仅管理员可见)"
|
||||||
|
}
|
||||||
|
maxLength={200}
|
||||||
|
autoFocus={!isStaff}
|
||||||
|
/>
|
||||||
|
<p className="meta text-[11.5px] mt-1.5">
|
||||||
|
{isStaff ? "将公开显示在评论占位旁。" : "普通读者看不到这条理由。"}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{err ? (
|
||||||
|
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -315,6 +315,18 @@ export interface Comment {
|
|||||||
user: User;
|
user: User;
|
||||||
/** 软删占位 */
|
/** 软删占位 */
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
|
/** 管理删除(DeletedBy≠评论者) */
|
||||||
|
staff_deleted?: boolean;
|
||||||
|
delete_type?: string;
|
||||||
|
delete_reason?: string;
|
||||||
|
deleted_by?: number;
|
||||||
|
/** 管理删执行者(公开,用于主页快捷入口) */
|
||||||
|
deleted_by_user?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 评论树节点:主评论为根,replies 为其全部子回复(时间正序,全量 SSR 直出)
|
// 评论树节点:主评论为根,replies 为其全部子回复(时间正序,全量 SSR 直出)
|
||||||
@@ -1105,9 +1117,26 @@ export async function apiToggleLike(postId: string) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDeleteComment(postId: string, commentId: number) {
|
export async function apiDeleteComment(
|
||||||
|
postId: string,
|
||||||
|
commentId: number,
|
||||||
|
opts: { delete_type?: string; delete_reason: string }
|
||||||
|
) {
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
|
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
delete_type: opts.delete_type || "",
|
||||||
|
delete_reason: opts.delete_reason || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复软删评论(自删作者或版主) */
|
||||||
|
export async function apiRestoreComment(postId: string, commentId: number) {
|
||||||
|
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/restore`, {
|
||||||
|
method: "PUT",
|
||||||
headers: clientHeaders(),
|
headers: clientHeaders(),
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -1735,8 +1764,10 @@ export const apiAdminRestorePost = (id: number) =>
|
|||||||
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
|
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
|
||||||
export const apiAdminPurgePost = (id: number) =>
|
export const apiAdminPurgePost = (id: number) =>
|
||||||
contentAction(`/api/admin/content/posts/${id}/purge`, "DELETE");
|
contentAction(`/api/admin/content/posts/${id}/purge`, "DELETE");
|
||||||
export const apiAdminSoftDeleteComment = (id: number) =>
|
export const apiAdminSoftDeleteComment = (
|
||||||
contentAction(`/api/admin/content/comments/${id}`, "DELETE");
|
id: number,
|
||||||
|
opts: { delete_type: string; delete_reason: string }
|
||||||
|
) => contentAction(`/api/admin/content/comments/${id}`, "DELETE", opts);
|
||||||
export const apiAdminRestoreComment = (id: number) =>
|
export const apiAdminRestoreComment = (id: number) =>
|
||||||
contentAction(`/api/admin/content/comments/${id}/restore`, "PUT");
|
contentAction(`/api/admin/content/comments/${id}/restore`, "PUT");
|
||||||
export const apiAdminPurgeComment = (id: number) =>
|
export const apiAdminPurgeComment = (id: number) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user