From 1b2b87c976725d1be62538221c23c8f245794057 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 16 Sep 2026 08:05:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(comments):=20=E6=94=AF=E6=8C=81=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=8E=E7=AE=A1=E7=90=86=E5=8F=AF=E8=A7=81=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=EF=BC=8C=E9=87=87=E7=BA=B3=E5=B1=95=E7=A4=BA=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E6=A5=BC=E5=B1=82=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/handler/comment.go | 33 +++ backend/handler/moderation.go | 35 +++ backend/model/db.go | 2 +- backend/model/models.go | 9 + backend/router/router.go | 4 + backend/service/admin_content.go | 7 +- backend/service/comment.go | 190 ++++++++++++++++- backend/service/post_interact.go | 4 + frontend/app/admin/content/ContentAdmin.tsx | 16 ++ frontend/app/post/[id]/page.tsx | 6 + .../components/CommentEditHistoryModal.tsx | 173 +++++++++++++++ frontend/components/CommentSection.tsx | 201 +++++++++++++++--- frontend/components/PostInteractPanels.tsx | 35 ++- frontend/lib/api.ts | 43 ++++ 14 files changed, 716 insertions(+), 42 deletions(-) create mode 100644 frontend/components/CommentEditHistoryModal.tsx diff --git a/backend/handler/comment.go b/backend/handler/comment.go index 6ab70f6..caa2e2b 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -106,6 +106,39 @@ func (h *Handlers) CreateComment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"comment": comment}) } +// UpdateComment 编辑评论(作者或版主) +func (h *Handlers) UpdateComment(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 + } + var body struct { + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + node, err := h.Comment.Update(h.loadActor(claims.ID), uint(cid), claims.ID, body.Content) + if err != nil { + switch { + case errors.Is(err, service.ErrCommentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentDeleted), + errors.Is(err, service.ErrCommentNotChanged): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } + return + } + c.JSON(http.StatusOK, gin.H{"comment": node}) +} + // DeleteComment 软删评论(占位;自删与管理删均须填理由,管理删另须类型) func (h *Handlers) DeleteComment(c *gin.Context) { claims := middleware.CurrentUser(c) diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index bf09866..cffab5e 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -187,6 +187,41 @@ func (h *Handlers) AdminPurgeComment(c *gin.Context) { h.execContentAction(c, h.Moderation.PurgeComment) } +// AdminCommentHistory 评论编辑历史(板域 scope;分页) +func (h *Handlers) AdminCommentHistory(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + items, total, err := h.Comment.ListEditHistory(middleware.CurrentActor(c), uint(id), page, size) + if err != nil { + switch { + case errors.Is(err, service.ErrCommentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"}) + } + return + } + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "size": size, + }) +} + func (h *Handlers) execContentAction(c *gin.Context, fn func(*service.Actor, uint) error) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { diff --git a/backend/model/db.go b/backend/model/db.go index 3caa021..b3fdae7 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -36,7 +36,7 @@ func InitDB(dsn string) error { } if err := db.AutoMigrate( - &User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, + &User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, diff --git a/backend/model/models.go b/backend/model/models.go index a4730e9..fee7b87 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -321,6 +321,15 @@ type Comment struct { User User `gorm:"foreignKey:UserID" json:"user,omitempty"` } +// CommentEditHistory 评论修订快照(每次编辑前的旧正文;仅管理可见) +type CommentEditHistory struct { + ID uint `gorm:"primaryKey" json:"id"` + CommentID uint `gorm:"index;not null" json:"comment_id"` + EditorID uint `gorm:"index;not null" json:"editor_id"` + OldContent string `gorm:"type:text;not null" json:"old_content"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + // Like 点赞记录(联合唯一索引防止重复点赞) type Like struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/backend/router/router.go b/backend/router/router.go index 1a7c896..5fda795 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -92,6 +92,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Type: realtime.EventNotificationNew, }) } + // 点赞通知延迟落库(冷却期内取消点赞则不通知) + service.StartLikeNotifyFlusher(notifSvc) authMW := middleware.NewAuthMiddleware(authSvc) @@ -150,6 +152,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.PUT("/posts/:id/recommend", h.ToggleRecommend) api.POST("/posts/:id/like", h.ToggleLike) api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment) + api.PUT("/posts/:id/comments/:cid", middleware.RateLimitMiddleware(limiter, service.RateComment), h.UpdateComment) 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) @@ -217,6 +220,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.DELETE("/content/comments/:id", h.AdminSoftDeleteComment) staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment) staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment) + staffAPI.GET("/content/comments/:id/history", h.AdminCommentHistory) // 积分与类型帖概览(管理员及以上) staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats) diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go index 036239f..fb83a74 100644 --- a/backend/service/admin_content.go +++ b/backend/service/admin_content.go @@ -442,5 +442,10 @@ func (s *ModerationService) PurgeComment(actor *Actor, id uint) error { if !cm.DeletedAt.Valid { return ErrContentNotDeleted } - return s.db.Unscoped().Delete(&model.Comment{}, id).Error + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("comment_id = ?", id).Delete(&model.CommentEditHistory{}).Error; err != nil { + return err + } + return tx.Unscoped().Delete(&model.Comment{}, id).Error + }) } diff --git a/backend/service/comment.go b/backend/service/comment.go index b670865..51bcc9d 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -16,6 +16,8 @@ var ( ErrCommentDeleteMeta = errors.New("请填写删除理由") ErrCommentInvalidType = errors.New("无效的删除类型") ErrCommentNotDeleted = errors.New("评论未被删除") + ErrCommentNotChanged = errors.New("评论内容未变更") + ErrCommentDeleted = errors.New("已删除的评论不可编辑") ) // CommentService 评论服务 @@ -32,6 +34,7 @@ type CommentNode struct { model.Comment Deleted bool `json:"deleted,omitempty"` // 软删占位 StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者 + Edited bool `json:"edited,omitempty"` // 相对创建已编辑 DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开) Replies []CommentNode `json:"replies,omitempty"` } @@ -177,8 +180,9 @@ func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActo // toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。 func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode { + edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute if !c.DeletedAt.Valid { - return CommentNode{Comment: c, Replies: replies} + return CommentNode{Comment: c, Edited: edited, Replies: replies} } staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID if !revealAudit { @@ -201,11 +205,50 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del Comment: c, Deleted: true, StaffDeleted: staff, + Edited: edited, DeletedByUser: deleter, Replies: replies, } } +// FloorNumber 本帖主评楼层号(含软删占位,按 created_at/id 升序);子评取其所属主楼。无则 0。 +func FloorNumber(db *gorm.DB, postID, commentID uint) int { + if db == nil || postID == 0 || commentID == 0 { + return 0 + } + var c model.Comment + if err := db.Unscoped().Select("id", "post_id", "parent_id", "root_id", "created_at"). + Where("id = ? AND post_id = ?", commentID, postID).First(&c).Error; err != nil { + return 0 + } + floorID := c.ID + floorCreated := c.CreatedAt + if c.ParentID != nil { + rootID := c.ID + if c.RootID != nil { + rootID = *c.RootID + } + var root model.Comment + if err := db.Unscoped().Select("id", "created_at"). + Where("id = ? AND post_id = ? AND parent_id IS NULL", rootID, postID).First(&root).Error; err != nil { + return 0 + } + floorID = root.ID + floorCreated = root.CreatedAt + } + var ahead int64 + db.Unscoped().Model(&model.Comment{}). + Where("post_id = ? AND parent_id IS NULL AND (created_at < ? OR (created_at = ? AND id < ?))", + postID, floorCreated, floorCreated, floorID). + Count(&ahead) + return int(ahead) + 1 +} + +// (s *CommentService) FloorNumber 见包级 FloorNumber +func (s *CommentService) FloorNumber(postID, commentID uint) int { + return FloorNumber(s.db, postID, commentID) +} + // applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审); // 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。 func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB { @@ -501,12 +544,141 @@ func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error { Where("id IN ? AND status = ? AND deleted_at IS NULL", ids, model.ContentStatusPublished). Count(&livePublished) - if err := s.db.Unscoped().Delete(&model.Comment{}, ids).Error; err != nil { - return err - } - if livePublished > 0 { - s.db.Model(&model.Post{}).Where("id = ?", comment.PostID). - UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished)) - } - return nil + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("comment_id IN ?", ids).Delete(&model.CommentEditHistory{}).Error; err != nil { + return err + } + if err := tx.Unscoped().Delete(&model.Comment{}, ids).Error; err != nil { + return err + } + if livePublished > 0 { + return tx.Model(&model.Post{}).Where("id = ?", comment.PostID). + UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished)).Error + } + return nil + }) +} + +// Update 编辑评论(作者本人或版主;已软删不可改);先写入旧正文快照再更新。 +func (s *CommentService) Update(actor *Actor, commentID, userID uint, content string) (*CommentNode, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, errors.New("评论内容不能为空") + } + var comment model.Comment + if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil { + return nil, ErrCommentNotFound + } + if comment.DeletedAt.Valid { + return nil, ErrCommentDeleted + } + if comment.UserID != userID { + var post model.Post + if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { + return nil, ErrCommentNotFound + } + if actor == nil || !actor.CanModerateBoard(post.BoardID) { + return nil, ErrCommentForbidden + } + } + if comment.Content == content { + return nil, ErrCommentNotChanged + } + + old := comment.Content + if err := s.db.Transaction(func(tx *gorm.DB) error { + hist := model.CommentEditHistory{ + CommentID: comment.ID, + EditorID: userID, + OldContent: old, + } + if err := tx.Create(&hist).Error; err != nil { + return err + } + return tx.Model(&comment).Update("content", content).Error + }); err != nil { + return nil, err + } + + if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil { + return nil, err + } + node := toCommentNode(comment, nil, false, nil) + return &node, nil +} + +// CommentEditHistoryItem 管理可见的评论修订记录 +type CommentEditHistoryItem struct { + ID uint `json:"id"` + Editor CommentActorBrief `json:"editor"` + OldContent string `json:"old_content"` + CreatedAt time.Time `json:"created_at"` +} + +// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页) +func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, size int) ([]CommentEditHistoryItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + var comment model.Comment + if err := s.db.Unscoped().Select("id", "post_id").First(&comment, commentID).Error; err != nil { + return nil, 0, ErrCommentNotFound + } + var post model.Post + if err := s.db.Unscoped().Select("id", "board_id").First(&post, comment.PostID).Error; err != nil { + return nil, 0, ErrCommentNotFound + } + if actor == nil || !actor.CanModerateBoard(post.BoardID) { + return nil, 0, ErrCommentForbidden + } + + q := s.db.Model(&model.CommentEditHistory{}).Where("comment_id = ?", commentID) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var rows []model.CommentEditHistory + if err := s.db.Where("comment_id = ?", commentID). + Order("created_at DESC"). + Offset((page - 1) * size).Limit(size). + Find(&rows).Error; err != nil { + return nil, 0, err + } + editorIDs := make([]uint, 0, len(rows)) + seen := map[uint]struct{}{} + for _, r := range rows { + if _, ok := seen[r.EditorID]; ok { + continue + } + seen[r.EditorID] = struct{}{} + editorIDs = append(editorIDs, r.EditorID) + } + editors := map[uint]CommentActorBrief{} + if len(editorIDs) > 0 { + var users []model.User + s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", editorIDs).Find(&users) + for _, u := range users { + editors[u.ID] = CommentActorBrief{ + ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar, + } + } + } + items := make([]CommentEditHistoryItem, 0, len(rows)) + for _, r := range rows { + ed := editors[r.EditorID] + if ed.ID == 0 { + ed = CommentActorBrief{ID: r.EditorID, Username: "已注销", Nickname: "已注销"} + } + items = append(items, CommentEditHistoryItem{ + ID: r.ID, + Editor: ed, + OldContent: r.OldContent, + CreatedAt: r.CreatedAt, + }) + } + return items, total, nil } diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go index 8acb5d9..d74db4a 100644 --- a/backend/service/post_interact.go +++ b/backend/service/post_interact.go @@ -330,6 +330,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) ( type QuestionState struct { Solved bool `json:"solved"` AcceptedCommentID uint `json:"accepted_comment_id"` + AcceptedFloor int `json:"accepted_floor"` CanAccept bool `json:"can_accept"` CanSolve bool `json:"can_solve"` CanReopen bool `json:"can_reopen"` @@ -366,6 +367,7 @@ type PollState struct { type BountyState struct { Points int `json:"points"` AcceptedCommentID uint `json:"accepted_comment_id"` + AcceptedFloor int `json:"accepted_floor"` Escrowed bool `json:"escrowed"` Refunded bool `json:"refunded"` Expired bool `json:"expired"` @@ -425,6 +427,7 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques return &QuestionState{ Solved: m.Solved, AcceptedCommentID: m.AcceptedCommentID, + AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID), CanAccept: canManage && !m.Solved, CanSolve: canManage && !m.Solved, CanReopen: canManage && m.Solved, @@ -530,6 +533,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty return &BountyState{ Points: m.Points, AcceptedCommentID: m.AcceptedCommentID, + AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID), Escrowed: m.Escrowed, Refunded: m.Refunded, Expired: m.Expired, diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index 972133a..c08753c 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -7,6 +7,7 @@ import { ChevronRight, FileText, FolderOpen, + History, Loader2, MessagesSquare, RotateCcw, @@ -36,6 +37,7 @@ import Avatar from "@/components/Avatar"; import ConfirmDialog from "@/components/ConfirmDialog"; import StaffDeletePostModal from "@/components/StaffDeletePostModal"; import DeleteCommentModal from "@/components/DeleteCommentModal"; +import CommentEditHistoryModal from "@/components/CommentEditHistoryModal"; const PAGE_SIZE = 15; @@ -145,6 +147,7 @@ export default function ContentAdmin({ const [staffDeleteBusy, setStaffDeleteBusy] = useState(false); const [staffDeleteComment, setStaffDeleteComment] = useState(null); const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false); + const [historyCommentId, setHistoryCommentId] = useState(null); const refreshCounts = useCallback(() => { apiAdminContentCounts().then(setCounts).catch(() => {}); @@ -570,6 +573,13 @@ export default function ContentAdmin({

+ {c.deleted ? ( <>
); } diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 1d0accf..d4e8b0d 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -657,6 +657,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps ? post.question ?? { solved: false, accepted_comment_id: 0, + accepted_floor: 0, can_accept: canManageTyped, can_solve: canManageTyped, can_reopen: false, @@ -728,6 +729,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps post.bounty?.accepted_comment_id || 0 } + winnerUserIds={ + post.lottery?.drawn + ? (post.lottery.winners || []).map((w) => w.id) + : [] + } /> ) : (
diff --git a/frontend/components/CommentEditHistoryModal.tsx b/frontend/components/CommentEditHistoryModal.tsx new file mode 100644 index 0000000..387b0bc --- /dev/null +++ b/frontend/components/CommentEditHistoryModal.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { Loader2 } from "lucide-react"; +import Modal from "@/components/Modal"; +import Avatar from "@/components/Avatar"; +import MarkdownBodyClient from "@/components/MarkdownBodyClient"; +import { + apiAdminCommentHistory, + type CommentEditHistoryItem, +} from "@/lib/api"; + +const PAGE_SIZE = 10; + +/** + * 版主可见:评论历次编辑前的旧正文快照(倒序分页)。 + * 宽度对齐详情页评论主栏(约 800px+),长文可滚动;底部「加载更多」。 + */ +export default function CommentEditHistoryModal({ + open, + commentId, + onClose, +}: { + open: boolean; + commentId: number | null; + onClose: () => void; +}) { + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(""); + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + + const hasMore = items.length < total; + + const loadPage = useCallback( + async (nextPage: number, append: boolean) => { + if (commentId == null) return; + if (append) setLoadingMore(true); + else { + setLoading(true); + setError(""); + } + try { + const res = await apiAdminCommentHistory(commentId, nextPage, PAGE_SIZE); + const batch = res.items || []; + setError(""); + setTotal(res.total ?? 0); + setPage(res.page ?? nextPage); + setItems((prev) => (append ? [...prev, ...batch] : batch)); + } catch (e) { + const msg = e instanceof Error ? e.message : "加载失败"; + if (append) { + // 追加失败不清空已有列表 + setError(msg); + } else { + setError(msg); + setItems([]); + setTotal(0); + } + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [commentId] + ); + + useEffect(() => { + if (!open || commentId == null) return; + setItems([]); + setPage(1); + setTotal(0); + setError(""); + void loadPage(1, false); + }, [open, commentId, loadPage]); + + return ( + 0 ? `共 ${total} 条修订记录` : undefined} + onClose={onClose} + // 对齐详情页中间评论栏可用宽度(1440 三栏布局下约 800px) + maxWidthClass="max-w-[min(840px,calc(100vw-2rem))]" + panelClassName="p-5 sm:p-6 max-h-[85vh] flex flex-col min-h-0 overflow-hidden" + bodyClassName="overflow-y-auto min-h-0 flex-1 -mx-1 px-1" + footer={ + + } + > + {loading ? ( +
+ + 加载中… +
+ ) : error && items.length === 0 ? ( +

+ {error} +

+ ) : items.length === 0 ? ( +

暂无编辑记录

+ ) : ( +
+
    + {items.map((it) => { + const name = it.editor.nickname || it.editor.username || "用户"; + return ( +
  • +
    + + + +
    + + {name} + +

    + {new Date(it.created_at).toLocaleString("zh-CN")} · 编辑前正文 +

    +
    +
    + +
  • + ); + })} +
+ + {error && items.length > 0 && ( +

+ {error} +

+ )} + + {hasMore && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 6eb4b0c..0075bba 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -12,12 +12,15 @@ import { BadgeCheck, XCircle, RotateCcw, + Pencil, + History, } from "lucide-react"; import { apiCreateComment, apiDeleteComment, apiRestoreComment, apiPurgeComment, + apiUpdateComment, apiAcceptBounty, apiAcceptQuestion, apiAdminApproveComment, @@ -34,6 +37,7 @@ import Pagination from "./Pagination"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import ConfirmDialog from "@/components/ConfirmDialog"; import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal"; +import CommentEditHistoryModal from "@/components/CommentEditHistoryModal"; interface CommentSectionProps { postId: string; @@ -51,6 +55,8 @@ interface CommentSectionProps { /** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */ acceptMode?: "bounty" | "question" | null; acceptedCommentId?: number; + /** 抽奖已开奖:中奖用户 ID,用于楼层旁显示「中奖」徽标 */ + winnerUserIds?: number[]; /** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */ allowComments?: boolean; } @@ -109,8 +115,10 @@ export default function CommentSection({ user, acceptMode = null, acceptedCommentId = 0, + winnerUserIds = [], allowComments: allowCommentsProp = true, }: CommentSectionProps) { + const winnerIdSet = new Set(winnerUserIds.filter((id) => id > 0)); const router = useRouter(); const [content, setContent] = useState(""); // 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框) @@ -127,6 +135,10 @@ export default function CommentSection({ const [purging, setPurging] = useState(false); const [restoreTargetId, setRestoreTargetId] = useState(null); const [restoring, setRestoring] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editContent, setEditContent] = useState(""); + const [savingEdit, setSavingEdit] = useState(false); + const [historyCommentId, setHistoryCommentId] = useState(null); // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) const [expandedFloors, setExpandedFloors] = useState>(new Set()); const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false); @@ -323,6 +335,10 @@ export default function CommentSection({ !!user && (user.id === comment.user?.id || canModerateBoard(user, boardId)); + const canEdit = (comment: CommentNode) => canSoftDelete(comment); + + const canViewHistory = !!user && canModerateBoard(user, boardId); + const canPurge = (comment: CommentNode) => !!user && canModerateBoard(user, boardId); @@ -347,10 +363,84 @@ export default function CommentSection({ comment.user?.id !== authorId; const openReply = (c: CommentNode) => { + setEditingId(null); setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username }); setReplyContent(""); }; + const startEdit = (c: CommentNode) => { + setReplyTarget(null); + setEditingId(c.id); + setEditContent(c.content); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditContent(""); + }; + + const handleSaveEdit = async () => { + if (editingId == null || !editContent.trim()) return; + setSavingEdit(true); + try { + const res = await apiUpdateComment(postId, editingId, editContent); + if (res.error) { + toast(res.error); + return; + } + toast("评论已更新", "ok"); + cancelEdit(); + router.refresh(); + } catch (e) { + toast(e instanceof Error ? e.message : "更新失败"); + } finally { + setSavingEdit(false); + } + }; + + const renderContentOrEditor = (c: CommentNode) => { + if (editingId === c.id) { + return ( +
+