feat(comments): 软删占位、理由与恢复,管理删仅对访客打码

自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 07:43:48 +08:00
parent 5052bcd805
commit c6fdbb7193
11 changed files with 941 additions and 141 deletions

View File

@@ -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 硬删评论及子树(仅版主;不占位)

View File

@@ -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) {

View File

@@ -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:"-"`

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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>
); );
} }

View File

@@ -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;

View File

@@ -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}

View 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>
);
}

View File

@@ -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) =>