feat(comments): 支持编辑与管理可见历史,采纳展示改用楼层号

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 08:05:41 +08:00
parent d032668c66
commit 1b2b87c976
14 changed files with 716 additions and 42 deletions

View File

@@ -106,6 +106,39 @@ func (h *Handlers) CreateComment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"comment": comment}) 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 软删评论(占位;自删与管理删均须填理由,管理删另须类型) // DeleteComment 软删评论(占位;自删与管理删均须填理由,管理删另须类型)
func (h *Handlers) DeleteComment(c *gin.Context) { func (h *Handlers) DeleteComment(c *gin.Context) {
claims := middleware.CurrentUser(c) claims := middleware.CurrentUser(c)

View File

@@ -187,6 +187,41 @@ func (h *Handlers) AdminPurgeComment(c *gin.Context) {
h.execContentAction(c, h.Moderation.PurgeComment) 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) { func (h *Handlers) execContentAction(c *gin.Context, fn func(*service.Actor, uint) error) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 { if err != nil || id == 0 {

View File

@@ -36,7 +36,7 @@ func InitDB(dsn string) error {
} }
if err := db.AutoMigrate( 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{}, &Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},

View File

@@ -321,6 +321,15 @@ type Comment struct {
User User `gorm:"foreignKey:UserID" json:"user,omitempty"` 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 点赞记录(联合唯一索引防止重复点赞) // Like 点赞记录(联合唯一索引防止重复点赞)
type Like struct { type Like struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`

View File

@@ -92,6 +92,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Type: realtime.EventNotificationNew, Type: realtime.EventNotificationNew,
}) })
} }
// 点赞通知延迟落库(冷却期内取消点赞则不通知)
service.StartLikeNotifyFlusher(notifSvc)
authMW := middleware.NewAuthMiddleware(authSvc) authMW := middleware.NewAuthMiddleware(authSvc)
@@ -150,6 +152,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.PUT("/posts/:id/recommend", h.ToggleRecommend) api.PUT("/posts/:id/recommend", h.ToggleRecommend)
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.PUT("/posts/:id/comments/:cid", middleware.RateLimitMiddleware(limiter, service.RateComment), h.UpdateComment)
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.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)
@@ -217,6 +220,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.DELETE("/content/comments/:id", h.AdminSoftDeleteComment) staffAPI.DELETE("/content/comments/:id", h.AdminSoftDeleteComment)
staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment) staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment)
staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment) 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) staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)

View File

@@ -442,5 +442,10 @@ func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
if !cm.DeletedAt.Valid { if !cm.DeletedAt.Valid {
return ErrContentNotDeleted 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
})
} }

View File

@@ -16,6 +16,8 @@ var (
ErrCommentDeleteMeta = errors.New("请填写删除理由") ErrCommentDeleteMeta = errors.New("请填写删除理由")
ErrCommentInvalidType = errors.New("无效的删除类型") ErrCommentInvalidType = errors.New("无效的删除类型")
ErrCommentNotDeleted = errors.New("评论未被删除") ErrCommentNotDeleted = errors.New("评论未被删除")
ErrCommentNotChanged = errors.New("评论内容未变更")
ErrCommentDeleted = errors.New("已删除的评论不可编辑")
) )
// CommentService 评论服务 // CommentService 评论服务
@@ -32,6 +34,7 @@ type CommentNode struct {
model.Comment model.Comment
Deleted bool `json:"deleted,omitempty"` // 软删占位 Deleted bool `json:"deleted,omitempty"` // 软删占位
StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者 StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
Edited bool `json:"edited,omitempty"` // 相对创建已编辑
DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开) DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开)
Replies []CommentNode `json:"replies,omitempty"` Replies []CommentNode `json:"replies,omitempty"`
} }
@@ -177,8 +180,9 @@ func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActo
// toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。 // toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。
func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode { 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 { 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 staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID
if !revealAudit { if !revealAudit {
@@ -201,11 +205,50 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del
Comment: c, Comment: c,
Deleted: true, Deleted: true,
StaffDeleted: staff, StaffDeleted: staff,
Edited: edited,
DeletedByUser: deleter, DeletedByUser: deleter,
Replies: replies, 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 且(作者 / 该板可审); // applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。 // 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB { 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). Where("id IN ? AND status = ? AND deleted_at IS NULL", ids, model.ContentStatusPublished).
Count(&livePublished) Count(&livePublished)
if err := s.db.Unscoped().Delete(&model.Comment{}, ids).Error; err != 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 return err
} }
if livePublished > 0 { if livePublished > 0 {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID). return tx.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished)) UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished)).Error
} }
return nil 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
} }

View File

@@ -330,6 +330,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
type QuestionState struct { type QuestionState struct {
Solved bool `json:"solved"` Solved bool `json:"solved"`
AcceptedCommentID uint `json:"accepted_comment_id"` AcceptedCommentID uint `json:"accepted_comment_id"`
AcceptedFloor int `json:"accepted_floor"`
CanAccept bool `json:"can_accept"` CanAccept bool `json:"can_accept"`
CanSolve bool `json:"can_solve"` CanSolve bool `json:"can_solve"`
CanReopen bool `json:"can_reopen"` CanReopen bool `json:"can_reopen"`
@@ -366,6 +367,7 @@ type PollState struct {
type BountyState struct { type BountyState struct {
Points int `json:"points"` Points int `json:"points"`
AcceptedCommentID uint `json:"accepted_comment_id"` AcceptedCommentID uint `json:"accepted_comment_id"`
AcceptedFloor int `json:"accepted_floor"`
Escrowed bool `json:"escrowed"` Escrowed bool `json:"escrowed"`
Refunded bool `json:"refunded"` Refunded bool `json:"refunded"`
Expired bool `json:"expired"` Expired bool `json:"expired"`
@@ -425,6 +427,7 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques
return &QuestionState{ return &QuestionState{
Solved: m.Solved, Solved: m.Solved,
AcceptedCommentID: m.AcceptedCommentID, AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID),
CanAccept: canManage && !m.Solved, CanAccept: canManage && !m.Solved,
CanSolve: canManage && !m.Solved, CanSolve: canManage && !m.Solved,
CanReopen: canManage && m.Solved, CanReopen: canManage && m.Solved,
@@ -530,6 +533,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
return &BountyState{ return &BountyState{
Points: m.Points, Points: m.Points,
AcceptedCommentID: m.AcceptedCommentID, AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID),
Escrowed: m.Escrowed, Escrowed: m.Escrowed,
Refunded: m.Refunded, Refunded: m.Refunded,
Expired: m.Expired, Expired: m.Expired,

View File

@@ -7,6 +7,7 @@ import {
ChevronRight, ChevronRight,
FileText, FileText,
FolderOpen, FolderOpen,
History,
Loader2, Loader2,
MessagesSquare, MessagesSquare,
RotateCcw, RotateCcw,
@@ -36,6 +37,7 @@ 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"; import DeleteCommentModal from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
@@ -145,6 +147,7 @@ export default function ContentAdmin({
const [staffDeleteBusy, setStaffDeleteBusy] = useState(false); const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
const [staffDeleteComment, setStaffDeleteComment] = useState<AdminContentComment | null>(null); const [staffDeleteComment, setStaffDeleteComment] = useState<AdminContentComment | null>(null);
const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false); const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const refreshCounts = useCallback(() => { const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {}); apiAdminContentCounts().then(setCounts).catch(() => {});
@@ -570,6 +573,13 @@ export default function ContentAdmin({
</p> </p>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHistoryCommentId(c.id)}
>
<History size={14} /> 历史
</button>
{c.deleted ? ( {c.deleted ? (
<> <>
<button <button
@@ -713,6 +723,12 @@ export default function ContentAdmin({
.finally(() => setStaffDeleteCommentBusy(false)); .finally(() => setStaffDeleteCommentBusy(false));
}} }}
/> />
<CommentEditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
onClose={() => setHistoryCommentId(null)}
/>
</div> </div>
); );
} }

View File

@@ -657,6 +657,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
? post.question ?? { ? post.question ?? {
solved: false, solved: false,
accepted_comment_id: 0, accepted_comment_id: 0,
accepted_floor: 0,
can_accept: canManageTyped, can_accept: canManageTyped,
can_solve: canManageTyped, can_solve: canManageTyped,
can_reopen: false, can_reopen: false,
@@ -728,6 +729,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
post.bounty?.accepted_comment_id || post.bounty?.accepted_comment_id ||
0 0
} }
winnerUserIds={
post.lottery?.drawn
? (post.lottery.winners || []).map((w) => w.id)
: []
}
/> />
) : ( ) : (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区"> <section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">

View File

@@ -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<CommentEditHistoryItem[]>([]);
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 (
<Modal
open={open}
title="评论编辑历史"
description={total > 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={
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
关闭
</button>
}
>
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 meta text-[13px]">
<Loader2 size={16} className="animate-spin" />
加载中…
</div>
) : error && items.length === 0 ? (
<p className="text-[13.5px] py-6 text-center" style={{ color: "var(--danger)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13.5px] py-8 text-center">暂无编辑记录</p>
) : (
<div className="flex flex-col gap-4">
<ul className="flex flex-col gap-4">
{items.map((it) => {
const name = it.editor.nickname || it.editor.username || "用户";
return (
<li
key={it.id}
className="rounded-md p-3 sm:p-4"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 mb-2.5">
<Link href={`/u/${it.editor.id}`} className="shrink-0">
<Avatar name={name} src={it.editor.avatar || undefined} size={28} />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/u/${it.editor.id}`}
className="text-[13px] hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{name}
</Link>
<p className="meta text-[11.5px] tabular-nums">
{new Date(it.created_at).toLocaleString("zh-CN")} · 编辑前正文
</p>
</div>
</div>
<MarkdownBodyClient
content={it.old_content}
className="prose-content prose-comment"
/>
</li>
);
})}
</ul>
{error && items.length > 0 && (
<p className="text-[12.5px] text-center" style={{ color: "var(--danger)" }}>
{error}
</p>
)}
{hasMore && (
<div className="flex justify-center pt-1 pb-1">
<button
type="button"
className="btn btn-line btn-sm"
disabled={loadingMore}
onClick={() => void loadPage(page + 1, true)}
>
{loadingMore ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 size={14} className="animate-spin" />
加载中…
</span>
) : (
`加载更多(已显示 ${items.length}/${total})`
)}
</button>
</div>
)}
</div>
)}
</Modal>
);
}

View File

@@ -12,12 +12,15 @@ import {
BadgeCheck, BadgeCheck,
XCircle, XCircle,
RotateCcw, RotateCcw,
Pencil,
History,
} from "lucide-react"; } from "lucide-react";
import { import {
apiCreateComment, apiCreateComment,
apiDeleteComment, apiDeleteComment,
apiRestoreComment, apiRestoreComment,
apiPurgeComment, apiPurgeComment,
apiUpdateComment,
apiAcceptBounty, apiAcceptBounty,
apiAcceptQuestion, apiAcceptQuestion,
apiAdminApproveComment, apiAdminApproveComment,
@@ -34,6 +37,7 @@ 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"; import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
interface CommentSectionProps { interface CommentSectionProps {
postId: string; postId: string;
@@ -51,6 +55,8 @@ interface CommentSectionProps {
/** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */ /** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
acceptMode?: "bounty" | "question" | null; acceptMode?: "bounty" | "question" | null;
acceptedCommentId?: number; acceptedCommentId?: number;
/** 抽奖已开奖:中奖用户 ID,用于楼层旁显示「中奖」徽标 */
winnerUserIds?: number[];
/** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */ /** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */
allowComments?: boolean; allowComments?: boolean;
} }
@@ -109,8 +115,10 @@ export default function CommentSection({
user, user,
acceptMode = null, acceptMode = null,
acceptedCommentId = 0, acceptedCommentId = 0,
winnerUserIds = [],
allowComments: allowCommentsProp = true, allowComments: allowCommentsProp = true,
}: CommentSectionProps) { }: CommentSectionProps) {
const winnerIdSet = new Set(winnerUserIds.filter((id) => id > 0));
const router = useRouter(); const router = useRouter();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框) // 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
@@ -127,6 +135,10 @@ export default function CommentSection({
const [purging, setPurging] = useState(false); const [purging, setPurging] = useState(false);
const [restoreTargetId, setRestoreTargetId] = useState<number | null>(null); const [restoreTargetId, setRestoreTargetId] = useState<number | null>(null);
const [restoring, setRestoring] = useState(false); const [restoring, setRestoring] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editContent, setEditContent] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,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);
@@ -323,6 +335,10 @@ export default function CommentSection({
!!user && !!user &&
(user.id === comment.user?.id || canModerateBoard(user, boardId)); (user.id === comment.user?.id || canModerateBoard(user, boardId));
const canEdit = (comment: CommentNode) => canSoftDelete(comment);
const canViewHistory = !!user && canModerateBoard(user, boardId);
const canPurge = (comment: CommentNode) => const canPurge = (comment: CommentNode) =>
!!user && canModerateBoard(user, boardId); !!user && canModerateBoard(user, boardId);
@@ -347,10 +363,84 @@ export default function CommentSection({
comment.user?.id !== authorId; comment.user?.id !== authorId;
const openReply = (c: CommentNode) => { const openReply = (c: CommentNode) => {
setEditingId(null);
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username }); setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
setReplyContent(""); 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 (
<div className="mt-1">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={4}
className="input w-full text-sm"
disabled={savingEdit}
autoFocus
/>
<div className="flex gap-2 mt-2">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={savingEdit || !editContent.trim()}
onClick={() => void handleSaveEdit()}
>
{savingEdit ? "保存中…" : "保存"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingEdit}
onClick={cancelEdit}
>
取消
</button>
</div>
</div>
);
}
return <MarkdownBodyClient content={c.content} className="prose-content prose-comment" />;
};
const timeMeta = (c: CommentNode) => (
<span className="meta text-[11.5px] inline-flex items-center gap-1.5 flex-wrap">
<span>{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{c.edited && <span title="此评论曾被编辑">已编辑</span>}
</span>
);
// 切换某楼层子回复的展开/折叠 // 切换某楼层子回复的展开/折叠
const toggleFloor = (floorId: number) => { const toggleFloor = (floorId: number) => {
setExpandedFloors((prev) => { setExpandedFloors((prev) => {
@@ -525,9 +615,21 @@ export default function CommentSection({
恢复 恢复
</button> </button>
); );
const historyBtn = canViewHistory && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setHistoryCommentId(c.id)}
aria-label="编辑历史"
>
<History size={12} />
历史
</button>
);
const tombstoneActions = const tombstoneActions =
restoreBtn || purgeBtn ? ( restoreBtn || purgeBtn || historyBtn ? (
<div className="cmt-mod-actions"> <div className="cmt-mod-actions">
{historyBtn}
{restoreBtn} {restoreBtn}
{purgeBtn} {purgeBtn}
</div> </div>
@@ -542,7 +644,7 @@ export default function CommentSection({
<div className="mb-1">{nameRow}</div> <div className="mb-1">{nameRow}</div>
{tombstoneBody} {tombstoneBody}
<div className="flex items-center justify-between gap-3 mt-1.5 flex-wrap"> <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> {timeMeta(c)}
{tombstoneActions} {tombstoneActions}
</div> </div>
</div> </div>
@@ -576,7 +678,7 @@ export default function CommentSection({
</div> </div>
{tombstoneBody} {tombstoneBody}
<div className="flex items-center justify-between gap-3 mt-2 flex-wrap"> <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> {timeMeta(c)}
{tombstoneActions} {tombstoneActions}
</div> </div>
{node.replies && node.replies.length > 0 && ( {node.replies && node.replies.length > 0 && (
@@ -620,6 +722,9 @@ export default function CommentSection({
</span> </span>
</Link> </Link>
{c.status === "pending" && <span className="badge-pending shrink-0 !text-[10px] !py-0.5 !px-1.5">待审</span>} {c.status === "pending" && <span className="badge-pending shrink-0 !text-[10px] !py-0.5 !px-1.5">待审</span>}
{acceptedCommentId === c.id && (
<span className="badge-accepted shrink-0 !text-[10px] !py-0.5 !px-1.5">已采纳</span>
)}
{isReplyToReply && parent && ( {isReplyToReply && parent && (
<> <>
<ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" /> <ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" />
@@ -630,16 +735,13 @@ export default function CommentSection({
)} )}
</div> </div>
<div className="mt-0.5"> <div className="mt-0.5">
<MarkdownBodyClient {renderContentOrEditor(c)}
content={c.content}
className="prose-content prose-comment"
/>
</div> </div>
{/* 底部行:时间 · 回复(左),删除(右) */} {/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span> {timeMeta(c)}
{commentsOpen && user && ( {commentsOpen && user && editingId !== c.id && (
<button <button
onClick={() => openReply(c)} onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${ className={`cmt-reply-btn transition-colors ${
@@ -651,9 +753,6 @@ export default function CommentSection({
回复 回复
</button> </button>
)} )}
{acceptedCommentId === c.id && (
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
)}
{canAccept(c) && ( {canAccept(c) && (
<button <button
type="button" type="button"
@@ -690,8 +789,31 @@ export default function CommentSection({
</> </>
)} )}
</div> </div>
{(canSoftDelete(c) || (canPurge(c) && !c.deleted)) && ( {(canEdit(c) || canSoftDelete(c) || (canPurge(c) && !c.deleted) || canViewHistory) && (
<div className="cmt-mod-actions" role="group" aria-label="评论管理"> <div className="cmt-mod-actions" role="group" aria-label="评论管理">
{canEdit(c) && editingId !== c.id && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => startEdit(c)}
disabled={savingEdit}
aria-label="编辑评论"
>
<Pencil size={12} />
编辑
</button>
)}
{canViewHistory && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setHistoryCommentId(c.id)}
aria-label="编辑历史"
>
<History size={12} />
历史
</button>
)}
{canSoftDelete(c) && ( {canSoftDelete(c) && (
<button <button
type="button" type="button"
@@ -743,28 +865,31 @@ export default function CommentSection({
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={38} /> <Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={38} />
</Link> </Link>
<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 flex-wrap">
<Link href={`/u/${c.user.id}`} className="group"> <Link href={`/u/${c.user.id}`} className="group">
<span className="truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}> <span className="truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
{c.user.nickname || c.user.username} {c.user.nickname || c.user.username}
</span> </span>
</Link> </Link>
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>} {c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{acceptedCommentId === c.id && (
<span className="badge-accepted shrink-0">已采纳</span>
)}
{winnerIdSet.has(c.user.id) && (
<span className="badge-winner shrink-0">中奖楼层</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}
</a> </a>
)} )}
</div> </div>
<MarkdownBodyClient {renderContentOrEditor(c)}
content={c.content}
className="prose-content prose-comment"
/>
{/* 底部行:时间 · 回复(左),删除(右) */} {/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span> {timeMeta(c)}
{commentsOpen && user && ( {commentsOpen && user && editingId !== c.id && (
<button <button
onClick={() => openReply(c)} onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${ className={`cmt-reply-btn transition-colors ${
@@ -776,9 +901,6 @@ export default function CommentSection({
回复 回复
</button> </button>
)} )}
{acceptedCommentId === c.id && (
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
)}
{canAccept(c) && ( {canAccept(c) && (
<button <button
type="button" type="button"
@@ -815,8 +937,31 @@ export default function CommentSection({
</> </>
)} )}
</div> </div>
{(canSoftDelete(c) || canPurge(c)) && ( {(canEdit(c) || canSoftDelete(c) || canPurge(c) || canViewHistory) && (
<div className="cmt-mod-actions" role="group" aria-label="评论管理"> <div className="cmt-mod-actions" role="group" aria-label="评论管理">
{canEdit(c) && editingId !== c.id && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => startEdit(c)}
disabled={savingEdit}
aria-label="编辑评论"
>
<Pencil size={12} />
编辑
</button>
)}
{canViewHistory && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setHistoryCommentId(c.id)}
aria-label="编辑历史"
>
<History size={12} />
历史
</button>
)}
{canSoftDelete(c) && ( {canSoftDelete(c) && (
<button <button
type="button" type="button"
@@ -1021,6 +1166,12 @@ export default function CommentSection({
if (!purging) setPurgeTargetId(null); if (!purging) setPurgeTargetId(null);
}} }}
/> />
<CommentEditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
onClose={() => setHistoryCommentId(null)}
/>
</section> </section>
); );
} }

View File

@@ -81,7 +81,18 @@ function QuestionPanel({ postId, state }: { postId: number; state: QuestionState
<p className="meta text-[12.5px] mt-1"> <p className="meta text-[12.5px] mt-1">
{state.solved {state.solved
? state.accepted_comment_id ? state.accepted_comment_id
? `已采纳评论 #${state.accepted_comment_id}` ? (
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{state.accepted_floor || "?"}
</a>
</>
)
: "作者已标记为已解决" : "作者已标记为已解决"
: "可在评论旁采纳答案,或手动标记已解决"} : "可在评论旁采纳答案,或手动标记已解决"}
</p> </p>
@@ -295,7 +306,7 @@ function BountyPanel({ postId, state }: { postId: number; state: BountyState })
: state.refunded : state.refunded
? "已退回给发帖人" ? "已退回给发帖人"
: state.accepted_comment_id : state.accepted_comment_id
? `已采纳评论 #${state.accepted_comment_id}` ? null
: state.escrowed : state.escrowed
? "积分已托管,采纳优质回答后发放" ? "积分已托管,采纳优质回答后发放"
: "悬赏未托管"; : "悬赏未托管";
@@ -308,7 +319,20 @@ function BountyPanel({ postId, state }: { postId: number; state: BountyState })
悬赏 {state.points} 积分 悬赏 {state.points} 积分
</h2> </h2>
<p className="meta text-[12.5px] mt-1"> <p className="meta text-[12.5px] mt-1">
{statusText} {state.accepted_comment_id ? (
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{state.accepted_floor || "?"}
</a>
</>
) : (
statusText
)}
{state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""} {state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
</p> </p>
</div> </div>
@@ -404,11 +428,10 @@ function LotteryPanel({
<li key={w.id}> <li key={w.id}>
<Link <Link
href={`/u/${w.id}`} href={`/u/${w.id}`}
className="inline-flex items-center gap-2 px-2.5 py-1.5 rounded-full" className="badge-winner inline-flex items-center gap-2 !py-1.5 !px-2.5 !text-[13px] !font-medium"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
> >
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} /> <Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
<span className="text-[13px]">{w.nickname || w.username}</span> <span>{w.nickname || w.username}</span>
</Link> </Link>
</li> </li>
))} ))}

View File

@@ -238,6 +238,7 @@ export interface Post {
export interface QuestionState { export interface QuestionState {
solved: boolean; solved: boolean;
accepted_comment_id: number; accepted_comment_id: number;
accepted_floor: number;
can_accept: boolean; can_accept: boolean;
can_solve: boolean; can_solve: boolean;
can_reopen: boolean; can_reopen: boolean;
@@ -274,6 +275,7 @@ export interface PollState {
export interface BountyState { export interface BountyState {
points: number; points: number;
accepted_comment_id: number; accepted_comment_id: number;
accepted_floor: number;
escrowed: boolean; escrowed: boolean;
refunded: boolean; refunded: boolean;
expired?: boolean; expired?: boolean;
@@ -312,6 +314,9 @@ export interface Comment {
content: string; content: string;
status: string; status: string;
created_at: string; created_at: string;
updated_at?: string;
/** 相对创建已编辑(服务端判定) */
edited?: boolean;
user: User; user: User;
/** 软删占位 */ /** 软删占位 */
deleted?: boolean; deleted?: boolean;
@@ -1133,6 +1138,16 @@ export async function apiDeleteComment(
return res.json(); return res.json();
} }
/** 编辑评论(作者或版主) */
export async function apiUpdateComment(postId: string, commentId: number, content: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content }),
});
return res.json();
}
/** 恢复软删评论(自删作者或版主) */ /** 恢复软删评论(自删作者或版主) */
export async function apiRestoreComment(postId: string, commentId: number) { export async function apiRestoreComment(postId: string, commentId: number) {
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/restore`, { const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/restore`, {
@@ -1773,6 +1788,34 @@ export const apiAdminRestoreComment = (id: number) =>
export const apiAdminPurgeComment = (id: number) => export const apiAdminPurgeComment = (id: number) =>
contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE"); contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE");
export interface CommentEditHistoryItem {
id: number;
editor: {
id: number;
username: string;
nickname: string;
avatar: string;
};
old_content: string;
created_at: string;
}
/** 管理:评论编辑历史(分页;默认每页 10 条) */
export async function apiAdminCommentHistory(
commentId: number,
page = 1,
size = 10
): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(
`/api/admin/content/comments/${commentId}/history?${params}`,
{ headers: clientHeaders() }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
return data;
}
export async function fetchAdminContentCounts( export async function fetchAdminContentCounts(
cookieHeader?: string cookieHeader?: string
): Promise<AdminContentCounts> { ): Promise<AdminContentCounts> {