feat: 评论/帖子编辑历史与编辑标记,内容锁定

- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
This commit is contained in:
2026-09-25 23:41:45 +08:00
parent cab8230803
commit 5270fee2b7
40 changed files with 2521 additions and 350 deletions

View File

@@ -2,6 +2,7 @@ package handler
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -83,7 +84,7 @@ func (h *Handlers) PostComments(c *gin.Context) {
// CreateCommentRequest 评论请求 // CreateCommentRequest 评论请求
type CreateCommentRequest struct { type CreateCommentRequest struct {
Content string `json:"content" binding:"required,min=1"` Content string `json:"content" binding:"required,min=1,max=5000"`
ParentID *uint `json:"parent_id"` // 非空 = 回复该评论(发为其子评论) ParentID *uint `json:"parent_id"` // 非空 = 回复该评论(发为其子评论)
} }
@@ -109,11 +110,24 @@ func (h *Handlers) CreateComment(c *gin.Context) {
} }
// staff 直发;已过审用户在站点开启免审时可直发;其余进待审队列(通过时才发业务通知) // staff 直发;已过审用户在站点开启免审时可直发;其余进待审队列(通过时才发业务通知)
status := h.resolvePublishStatus(claims.ID, claims.Role) status := h.resolvePublishStatus(claims.ID, claims.Role)
comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status) comment, parent, necroPoints, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status, model.Role(claims.Role))
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) switch {
case errors.Is(err, service.ErrPostReplyLocked):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrInsufficientPoints):
// 旧帖回复扣分余额不足(文案含具体规则)
c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return return
} }
// 旧帖回复扣分:系统提醒本人(ActorID=0,不经过自我通知过滤)
if necroPoints > 0 {
h.Notification.CreateSystem(claims.ID, model.NotificationTypeNecroReply, uint(id), comment.ID,
fmt.Sprintf("您回复了长期没有新回复的旧帖,已扣除 %d 积分", necroPoints))
}
if comment.Status == model.ContentStatusPublished { if comment.Status == model.ContentStatusPublished {
if parent == nil { if parent == nil {
// 主评论:通知帖子作者(排除自己评论自己的帖子) // 主评论:通知帖子作者(排除自己评论自己的帖子)
@@ -146,7 +160,7 @@ func (h *Handlers) UpdateComment(c *gin.Context) {
return return
} }
var body struct { var body struct {
Content string `json:"content"` Content string `json:"content" binding:"omitempty,min=1,max=5000"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -157,7 +171,9 @@ func (h *Handlers) UpdateComment(c *gin.Context) {
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),
errors.Is(err, service.ErrCommentPostLocked),
errors.Is(err, service.ErrCommentEditLocked):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentDeleted), case errors.Is(err, service.ErrCommentDeleted),
errors.Is(err, service.ErrCommentNotChanged): errors.Is(err, service.ErrCommentNotChanged):
@@ -256,3 +272,39 @@ func (h *Handlers) PurgeComment(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"message": "已彻底删除"}) c.JSON(http.StatusOK, gin.H{"message": "已彻底删除"})
} }
// CommentEditHistory 评论编辑历史(登录即可见;分页)
func (h *Handlers) CommentEditHistory(c *gin.Context) {
claims := middleware.CurrentUser(c)
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil || cid == 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(h.loadActor(claims.ID), uint(cid), 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,
})
}

View File

@@ -193,41 +193,6 @@ 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

@@ -89,6 +89,7 @@ func (h *Handlers) PostDetail(c *gin.Context) {
if claims != nil { if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID) post.Liked = h.Like.HasLiked(post.ID, claims.ID)
} }
h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post}) c.JSON(http.StatusOK, gin.H{"post": post})
} }
@@ -96,7 +97,7 @@ func (h *Handlers) PostDetail(c *gin.Context) {
type CreatePostRequest struct { type CreatePostRequest struct {
BoardID uint `json:"board_id" binding:"required"` BoardID uint `json:"board_id" binding:"required"`
Title string `json:"title" binding:"required,min=1,max=256"` Title string `json:"title" binding:"required,min=1,max=256"`
Content string `json:"content" binding:"required,min=1"` Content string `json:"content" binding:"required,min=1,max=20000"`
Tags string `json:"tags"` Tags string `json:"tags"`
PostType string `json:"post_type"` PostType string `json:"post_type"`
TypeMeta string `json:"type_meta"` TypeMeta string `json:"type_meta"`
@@ -138,7 +139,7 @@ func (h *Handlers) CreatePost(c *gin.Context) {
// UpdatePostRequest 更新帖子请求 // UpdatePostRequest 更新帖子请求
type UpdatePostRequest struct { type UpdatePostRequest struct {
Title string `json:"title" binding:"omitempty,min=1,max=256"` Title string `json:"title" binding:"omitempty,min=1,max=256"`
Content string `json:"content" binding:"omitempty,min=1"` Content string `json:"content" binding:"omitempty,min=1,max=20000"`
Tags string `json:"tags"` Tags string `json:"tags"`
TypeMeta *string `json:"type_meta"` TypeMeta *string `json:"type_meta"`
AttachmentIDs *[]uint `json:"attachment_ids"` AttachmentIDs *[]uint `json:"attachment_ids"`
@@ -172,6 +173,42 @@ func (h *Handlers) UpdatePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"post": post}) c.JSON(http.StatusOK, gin.H{"post": post})
} }
// PostEditHistory 帖子编辑历史(登录即可见;分页)
func (h *Handlers) PostEditHistory(c *gin.Context) {
claims := middleware.CurrentUser(c)
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.Post.ListEditHistory(h.loadActor(claims.ID), uint(id), page, size)
if err != nil {
switch {
case errors.Is(err, service.ErrPostNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPostForbidden):
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,
})
}
// UnlockPostContent 积分解锁正文 // UnlockPostContent 积分解锁正文
func (h *Handlers) UnlockPostContent(c *gin.Context) { func (h *Handlers) UnlockPostContent(c *gin.Context) {
claims := middleware.CurrentUser(c) claims := middleware.CurrentUser(c)
@@ -239,6 +276,7 @@ func (h *Handlers) UnlockPostPassword(c *gin.Context) {
if claims != nil { if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID) post.Liked = h.Like.HasLiked(post.ID, claims.ID)
} }
h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post}) c.JSON(http.StatusOK, gin.H{"post": post})
} }
@@ -539,6 +577,52 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"recommended": rec}) c.JSON(http.StatusOK, gin.H{"recommended": rec})
} }
// TogglePostLock 切换帖子手动锁定(管理员及以上;锁定后普通用户不可编辑/回复,staff 豁免)
func (h *Handlers) TogglePostLock(c *gin.Context) {
if !h.requireAdminOrAbove(c) {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
locked, err := h.Post.ToggleLock(uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"locked": locked})
}
// attachNecroReplyHint 旧帖回复确认提示:仅当 viewer 已登录、非 staff、规则开启、
// 帖子判定为旧帖、且其评论将直发(待审不扣分不提醒,不弹窗)时返回
func (h *Handlers) attachNecroReplyHint(claims *service.UserClaims, detail *service.PostDetail) {
if claims == nil || detail == nil || detail.Tombstone || detail.NecroReply != nil {
return
}
if model.IsStaff(model.Role(claims.Role)) {
return
}
afterHours, err1 := h.Setting.NecroReplyAfterHours()
penalty, err2 := h.Setting.NecroReplyPenalty()
if err1 != nil || err2 != nil || afterHours <= 0 || penalty <= 0 {
return
}
// 旧帖基准:最后回复时间;无回复(null)回落发帖时间
baseline := detail.CreatedAt
if detail.LastReplyAt != nil {
baseline = *detail.LastReplyAt
}
if !service.IsNecroReply(baseline, afterHours, time.Now()) {
return
}
if h.resolvePublishStatus(claims.ID, claims.Role) != model.ContentStatusPublished {
return
}
detail.NecroReply = &service.NecroReplyHint{AfterHours: afterHours, Penalty: penalty}
}
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准 // requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准
func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool { func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool {
claims := middleware.CurrentUser(c) claims := middleware.CurrentUser(c)
@@ -556,7 +640,9 @@ func respondPostModError(c *gin.Context, err error) {
case errors.Is(err, service.ErrPostNotFound): case errors.Is(err, service.ErrPostNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPostForbidden), case errors.Is(err, service.ErrPostForbidden),
errors.Is(err, service.ErrAuthorProtected): errors.Is(err, service.ErrAuthorProtected),
errors.Is(err, service.ErrPostLocked),
errors.Is(err, service.ErrPostEditLocked):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPollOptionsLocked), case errors.Is(err, service.ErrPollOptionsLocked),
errors.Is(err, service.ErrLotteryPrizesLocked): errors.Is(err, service.ErrLotteryPrizesLocked):

View File

@@ -68,6 +68,11 @@ type updateSettingsRequest struct {
PointsReplyDailyCap *int `json:"points_reply_daily_cap"` PointsReplyDailyCap *int `json:"points_reply_daily_cap"`
PointsRecommendReward *int `json:"points_recommend_reward"` PointsRecommendReward *int `json:"points_recommend_reward"`
PostEditLockHours *int `json:"post_edit_lock_hours"`
CommentEditLockHours *int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours *int `json:"necro_reply_after_hours"`
NecroReplyPenalty *int `json:"necro_reply_penalty"`
Levels *[]model.LevelDef `json:"levels"` Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"` LevelsFx *bool `json:"levels_fx"`
} }
@@ -120,6 +125,11 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"points_reply_daily_cap": saved.PointsReplyDailyCap, "points_reply_daily_cap": saved.PointsReplyDailyCap,
"points_recommend_reward": saved.PointsRecommendReward, "points_recommend_reward": saved.PointsRecommendReward,
"post_edit_lock_hours": saved.PostEditLockHours,
"comment_edit_lock_hours": saved.CommentEditLockHours,
"necro_reply_after_hours": saved.NecroReplyAfterHours,
"necro_reply_penalty": saved.NecroReplyPenalty,
"levels": saved.Levels, "levels": saved.Levels,
"levels_fx": saved.LevelsFx, "levels_fx": saved.LevelsFx,
} }
@@ -142,7 +152,9 @@ func (req *updateSettingsRequest) hasAny() bool {
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil || req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil || req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil || req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil
} }
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致) // AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
@@ -247,6 +259,29 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return return
} }
} }
lockRules := []struct {
req *int
set func(int) error
hint string
}{
{req.PostEditLockHours, h.Setting.SetPostEditLockHours, "帖子可编辑时长须为 0–8760 小时"},
{req.CommentEditLockHours, h.Setting.SetCommentEditLockHours, "评论可编辑时长须为 0–8760 小时"},
{req.NecroReplyAfterHours, h.Setting.SetNecroReplyAfterHours, "旧帖回复阈值须为 0–8760 小时"},
{req.NecroReplyPenalty, h.Setting.SetNecroReplyPenalty, "旧帖回复扣分须为 0–100"},
}
for _, r := range lockRules {
if r.req == nil {
continue
}
if err := r.set(*r.req); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {
c.JSON(http.StatusBadRequest, gin.H{"error": r.hint})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
pointsRules := []struct { pointsRules := []struct {
req *int req *int
set func(int) error set func(int) error

View File

@@ -57,7 +57,7 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate( if err := db.AutoMigrate(
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},

View File

@@ -220,6 +220,7 @@ type Post struct {
TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳) TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳)
Pinned int `gorm:"default:0" json:"pinned"` Pinned int `gorm:"default:0" json:"pinned"`
Recommended bool `gorm:"default:false;index" json:"recommended"` Recommended bool `gorm:"default:false;index" json:"recommended"`
Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `gorm:"size:16;default:published;index" json:"status"` Status string `gorm:"size:16;default:published;index" json:"status"`
LikeCount int `gorm:"default:0" json:"like_count"` LikeCount int `gorm:"default:0" json:"like_count"`
ViewCount int `gorm:"default:0" json:"view_count"` ViewCount int `gorm:"default:0" json:"view_count"`
@@ -327,6 +328,7 @@ const (
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成 PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次) PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额) PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭)
) )
// PostPollVote 投票记录(多选时同一用户多行) // PostPollVote 投票记录(多选时同一用户多行)
@@ -412,6 +414,16 @@ type CommentEditHistory struct {
CreatedAt time.Time `gorm:"index" json:"created_at"` CreatedAt time.Time `gorm:"index" json:"created_at"`
} }
// PostEditHistory 帖子修订快照(每次编辑前的旧标题/旧正文;登录用户可见)
type PostEditHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldTitle string `gorm:"size:256;not null;default:''" json:"old_title"`
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"`
@@ -440,6 +452,7 @@ const (
NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者) NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者)
NotificationTypeBadge = "badge" // 获得管理员颁发的徽章 NotificationTypeBadge = "badge" // 获得管理员颁发的徽章
NotificationTypeDeleted = "deleted" // 帖子被管理员删除 NotificationTypeDeleted = "deleted" // 帖子被管理员删除
NotificationTypeNecroReply = "necro_reply" // 回复旧帖提醒(系统,ActorID=0)
) )
// Notification 站内通知 // Notification 站内通知

View File

@@ -204,6 +204,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.PUT("/profile", h.UpdateProfile) api.PUT("/profile", h.UpdateProfile)
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost) api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
api.PUT("/posts/:id", h.UpdatePost) api.PUT("/posts/:id", h.UpdatePost)
api.GET("/posts/:id/history", h.PostEditHistory)
// 评论编辑历史(登录即可见;顶层路由:管理后台无 post_id 上下文)
api.GET("/comments/:cid/history", h.CommentEditHistory)
api.POST("/posts/:id/unlock", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.UnlockPostContent) api.POST("/posts/:id/unlock", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.UnlockPostContent)
api.POST("/posts/:id/poll/vote", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.VotePoll) api.POST("/posts/:id/poll/vote", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.VotePoll)
api.POST("/posts/:id/poll/close", h.ClosePoll) api.POST("/posts/:id/poll/close", h.ClosePoll)
@@ -215,6 +218,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.DELETE("/posts/:id", h.DeletePost) api.DELETE("/posts/:id", h.DeletePost)
api.PUT("/posts/:id/pin", h.TogglePin) api.PUT("/posts/:id/pin", h.TogglePin)
api.PUT("/posts/:id/recommend", h.ToggleRecommend) api.PUT("/posts/:id/recommend", h.ToggleRecommend)
api.PUT("/posts/:id/lock", h.TogglePostLock)
api.POST("/posts/:id/like", h.ToggleLike) api.POST("/posts/:id/like", h.ToggleLike)
api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment) api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment)
api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment) api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment)
@@ -293,7 +297,6 @@ 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

@@ -81,6 +81,7 @@ type AdminContentComment struct {
BoardID uint `json:"board_id"` BoardID uint `json:"board_id"`
Content string `json:"content"` Content string `json:"content"`
Status string `json:"status"` Status string `json:"status"`
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
Deleted bool `json:"deleted"` Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -223,13 +224,14 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
Content string Content string
Status string Status string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt DeletedAt gorm.DeletedAt
BoardID uint BoardID uint
PostTitle string PostTitle string
UserID uint UserID uint
} }
var rows []row var rows []row
if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id"). if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.updated_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id").
Order("comments.id DESC"). Order("comments.id DESC").
Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize). Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize).
Scan(&rows).Error; err != nil { Scan(&rows).Error; err != nil {
@@ -276,6 +278,7 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
BoardID: r.BoardID, BoardID: r.BoardID,
Content: r.Content, Content: r.Content,
Status: r.Status, Status: r.Status,
Edited: r.UpdatedAt.Sub(r.CreatedAt) > time.Minute,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
Board: boards[r.BoardID], Board: boards[r.BoardID],
User: users[r.UserID], User: users[r.UserID],
@@ -354,7 +357,8 @@ func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, de
"delete_reason": deleteReason, "delete_reason": deleteReason,
"deleted_by": actor.ID, "deleted_by": actor.ID,
} }
if err := s.db.Model(post).Updates(updates).Error; err != nil { // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导)
if err := s.db.Model(post).UpdateColumns(updates).Error; err != nil {
return 0, err return 0, err
} }
if err := s.db.Delete(post).Error; err != nil { if err := s.db.Delete(post).Error; err != nil {
@@ -375,7 +379,8 @@ func (s *ModerationService) RestorePost(actor *Actor, id uint) error {
if !post.DeletedAt.Valid { if !post.DeletedAt.Valid {
return errors.New("帖子未被删除") return errors.New("帖子未被删除")
} }
return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error // UpdateColumn:恢复非内容编辑,不刷新 updated_at
return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).UpdateColumn("deleted_at", nil).Error
} }
// PurgePost 彻底删除已软删帖子及其评论(仅站长) // PurgePost 彻底删除已软删帖子及其评论(仅站长)
@@ -391,6 +396,9 @@ func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
return ErrContentNotDeleted return ErrContentNotDeleted
} }
return s.db.Transaction(func(tx *gorm.DB) error { return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.PostEditHistory{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil { if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil {
return err return err
} }
@@ -430,7 +438,8 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType,
"delete_reason": deleteReason, "delete_reason": deleteReason,
"deleted_by": actor.ID, "deleted_by": actor.ID,
} }
if err := s.db.Model(cm).Updates(updates).Error; err != nil { // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Model(cm).UpdateColumns(updates).Error; err != nil {
return err return err
} }
if err := s.db.Delete(cm).Error; err != nil { if err := s.db.Delete(cm).Error; err != nil {
@@ -461,7 +470,8 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
"delete_reason": "", "delete_reason": "",
"deleted_by": 0, "deleted_by": 0,
} }
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Updates(updates).Error; err != nil { // UpdateColumns:恢复非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).UpdateColumns(updates).Error; err != nil {
return err return err
} }
if cm.Status == model.ContentStatusPublished { if cm.Status == model.ContentStatusPublished {

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"errors" "errors"
"strings" "strings"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/markdown" "github.com/freefire/jiang13-bbs/markdown"
"github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/model"
@@ -49,6 +50,9 @@ func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
if in.Content == "" { if in.Content == "" {
return errors.New("内容不能为空") return errors.New("内容不能为空")
} }
if utf8.RuneCountInString(in.Content) > 20000 {
return errors.New("内容不能超过 20000 字")
}
if err := markdown.ValidateHideContent(in.Content); err != nil { if err := markdown.ValidateHideContent(in.Content); err != nil {
return err return err
} }

View File

@@ -2,6 +2,7 @@ package service
import ( import (
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -18,8 +19,29 @@ var (
ErrCommentNotDeleted = errors.New("评论未被删除") ErrCommentNotDeleted = errors.New("评论未被删除")
ErrCommentNotChanged = errors.New("评论内容未变更") ErrCommentNotChanged = errors.New("评论内容未变更")
ErrCommentDeleted = errors.New("已删除的评论不可编辑") ErrCommentDeleted = errors.New("已删除的评论不可编辑")
ErrPostReplyLocked = errors.New("帖子已被锁定,无法回复")
ErrCommentPostLocked = errors.New("帖子已被锁定,评论无法编辑")
ErrCommentEditLocked = errors.New("评论已超过可编辑时限,无法编辑")
) )
// commentEditLockHours 评论可编辑时长(setting 未注入或读取出错时=0 关闭)
func commentEditLockHours(setting *SettingService) int {
if setting == nil {
return 0
}
hours, err := setting.CommentEditLockHours()
if err != nil {
return 0
}
return hours
}
// IsNecroReply 旧帖判定:lastReplyAt(最后回复时间,无回复回落发帖时间)距 now 超过 afterHours。
// staff 豁免、规则开关与回落基准由调用方负责。
func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool {
return afterHours > 0 && now.Sub(lastReplyAt) > time.Duration(afterHours)*time.Hour
}
// CommentService 评论服务 // CommentService 评论服务
type CommentService struct { type CommentService struct {
db *gorm.DB db *gorm.DB
@@ -303,17 +325,18 @@ func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Acto
// Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)。 // Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)。
// status 由 handler 按角色计算:管理团队直发 published,普通用户进入 pending; // status 由 handler 按角色计算:管理团队直发 published,普通用户进入 pending;
// pending 评论不计入 comment_count,审核通过时才 +1。 // pending 评论不计入 comment_count,审核通过时才 +1。
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人) // actorRole 用于锁帖与旧帖扣分规则的 staff 豁免判断。
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) { // 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)、旧帖扣分值(0=未触发)
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string, actorRole model.Role) (*model.Comment, *model.Comment, int, error) {
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil { if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
return nil, nil, err return nil, nil, 0, err
} }
content = strings.TrimSpace(content) content = strings.TrimSpace(content)
if content == "" { if content == "" {
return nil, nil, errors.New("评论内容不能为空") return nil, nil, 0, errors.New("评论内容不能为空")
} }
if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil { if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil {
return nil, nil, err return nil, nil, 0, err
} }
if status != model.ContentStatusPending && status != model.ContentStatusPublished { if status != model.ContentStatusPending && status != model.ContentStatusPublished {
status = model.ContentStatusPending status = model.ContentStatusPending
@@ -322,10 +345,14 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
// 检查帖子存在且已发布(待审/被拒帖子不接受评论) // 检查帖子存在且已发布(待审/被拒帖子不接受评论)
var post model.Post var post model.Post
if err := s.db.First(&post, postID).Error; err != nil { if err := s.db.First(&post, postID).Error; err != nil {
return nil, nil, errors.New("帖子不存在") return nil, nil, 0, errors.New("帖子不存在")
} }
if post.Status != model.ContentStatusPublished { if post.Status != model.ContentStatusPublished {
return nil, nil, errors.New("帖子不存在") return nil, nil, 0, errors.New("帖子不存在")
}
// 管理员手动锁帖:普通用户禁止回复,staff 豁免
if post.Locked && !model.IsStaff(actorRole) {
return nil, nil, 0, ErrPostReplyLocked
} }
comment := &model.Comment{ comment := &model.Comment{
@@ -342,10 +369,10 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
if err := s.db. if err := s.db.
Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished). Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished).
First(parent).Error; err != nil { First(parent).Error; err != nil {
return nil, nil, errors.New("回复的评论不存在") return nil, nil, 0, errors.New("回复的评论不存在")
} }
if parent.Depth >= maxReplyDepth { if parent.Depth >= maxReplyDepth {
return nil, nil, errors.New("回复层级过深") return nil, nil, 0, errors.New("回复层级过深")
} }
rootID := parent.ID rootID := parent.ID
if parent.RootID != nil { if parent.RootID != nil {
@@ -356,26 +383,57 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
comment.Depth = parent.Depth + 1 comment.Depth = parent.Depth + 1
} }
if err := s.db.Create(comment).Error; err != nil { // 旧帖回复扣分(staff 豁免):仅已发布评论生效,待审不发不提醒。
return nil, nil, err // 旧帖判定:最后一条已发布评论时间距现在超过阈值;无回复(MAX 为 NULL)回落发帖时间。
necroAfterHours, necroPoints := 0, 0
if comment.Status == model.ContentStatusPublished && s.setting != nil && !model.IsStaff(actorRole) {
afterHours, afterErr := s.setting.NecroReplyAfterHours()
penalty, penaltyErr := s.setting.NecroReplyPenalty()
var lastAt *time.Time
if err := s.db.Raw(`SELECT MAX(created_at) FROM comments WHERE post_id = ? AND status = 'published' AND deleted_at IS NULL`, postID).Scan(&lastAt).Error; err != nil || lastAt == nil {
lastAt = &post.CreatedAt
}
if afterErr == nil && penaltyErr == nil &&
IsNecroReply(*lastAt, afterHours, time.Now()) {
necroAfterHours, necroPoints = afterHours, penalty
}
} }
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
if comment.Status == model.ContentStatusPublished { // 原子事务:评论落库 + 计数 + 旧帖扣分 + 回复奖励(扣分余额不足整体回滚,禁止回复)
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")) if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(comment).Error; err != nil {
return err
}
if comment.Status != model.ContentStatusPublished {
return nil
}
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
if err := tx.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil {
return err
}
if necroPoints > 0 {
if _, err := DebitTx(tx, userID, necroPoints, model.PointReasonNecroReply, "post", post.ID,
fmt.Sprintf("回复超过 %d 小时无新回复的旧帖", necroAfterHours)); err != nil {
return err
}
}
// 回复奖励(可配置;待审不发且过审不补发,防拒审刷分) // 回复奖励(可配置;待审不发且过审不补发,防拒审刷分)
if s.setting != nil { if s.setting != nil {
if rules, err := s.setting.PointsRules(); err == nil { if rules, err := s.setting.PointsRules(); err == nil {
s.db.Transaction(func(tx *gorm.DB) error { RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap,
RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap, model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title)
model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title)
return nil
})
} }
} }
return nil
}); err != nil {
if necroPoints > 0 && errors.Is(err, ErrInsufficientPoints) {
return nil, nil, 0, fmt.Errorf("%w:该帖已超过 %d 小时没有新回复,回复需扣除 %d 积分", ErrInsufficientPoints, necroAfterHours, necroPoints)
}
return nil, nil, 0, err
} }
// 预加载用户 // 预加载用户
s.db.Preload("User").Preload("User.Badges.Badge").First(comment, comment.ID) s.db.Preload("User").Preload("User.Badges.Badge").First(comment, comment.ID)
return comment, parent, nil return comment, parent, necroPoints, nil
} }
// UserCommentItem 用户评论列表项(含帖子标题便于跳转) // UserCommentItem 用户评论列表项(含帖子标题便于跳转)
@@ -484,7 +542,8 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts Delet
"delete_reason": deleteReason, "delete_reason": deleteReason,
"deleted_by": userID, "deleted_by": userID,
} }
if err := s.db.Model(&comment).Updates(updates).Error; err != nil { // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Model(&comment).UpdateColumns(updates).Error; err != nil {
return err return err
} }
if err := s.db.Delete(&comment).Error; err != nil { if err := s.db.Delete(&comment).Error; err != nil {
@@ -537,7 +596,8 @@ func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error {
"delete_reason": "", "delete_reason": "",
"deleted_by": 0, "deleted_by": 0,
} }
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil { // UpdateColumns:恢复非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumns(updates).Error; err != nil {
return err return err
} }
if comment.Status == model.ContentStatusPublished { if comment.Status == model.ContentStatusPublished {
@@ -637,14 +697,23 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st
if comment.DeletedAt.Valid { if comment.DeletedAt.Valid {
return nil, ErrCommentDeleted return nil, ErrCommentDeleted
} }
var post model.Post
if err := s.db.Select("id", "board_id", "created_at", "locked").First(&post, comment.PostID).Error; err != nil {
return nil, ErrCommentNotFound
}
if comment.UserID != userID { if comment.UserID != userID {
var post model.Post // 版主编辑他人评论不受锁帖/时限约束(本就为 staff)
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) { if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return nil, ErrCommentForbidden return nil, ErrCommentForbidden
} }
} else if !model.IsStaff(actor.Role) {
// 作者本人编辑:锁帖禁止、超过可编辑时限禁止,staff 豁免
if post.Locked {
return nil, ErrCommentPostLocked
}
if editLocked(comment.CreatedAt, false, commentEditLockHours(s.setting), time.Now()) {
return nil, ErrCommentEditLocked
}
} }
if comment.Content == content { if comment.Content == content {
return nil, ErrCommentNotChanged return nil, ErrCommentNotChanged
@@ -672,7 +741,7 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st
return &node, nil return &node, nil
} }
// CommentEditHistoryItem 管理可见的评论修订记录 // CommentEditHistoryItem 登录可见的评论修订记录
type CommentEditHistoryItem struct { type CommentEditHistoryItem struct {
ID uint `json:"id"` ID uint `json:"id"`
Editor CommentActorBrief `json:"editor"` Editor CommentActorBrief `json:"editor"`
@@ -680,7 +749,7 @@ type CommentEditHistoryItem struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页) // ListEditHistory 评论编辑历史(登录即可见;含软删评论;按创建时间倒序分页)
func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, size int) ([]CommentEditHistoryItem, int64, error) { func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, size int) ([]CommentEditHistoryItem, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -689,14 +758,10 @@ func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, siz
size = 10 size = 10
} }
var comment model.Comment var comment model.Comment
if err := s.db.Unscoped().Select("id", "post_id").First(&comment, commentID).Error; err != nil { if err := s.db.Unscoped().Select("id").First(&comment, commentID).Error; err != nil {
return nil, 0, ErrCommentNotFound return nil, 0, ErrCommentNotFound
} }
var post model.Post if actor == nil {
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 return nil, 0, ErrCommentForbidden
} }

View File

@@ -0,0 +1,74 @@
package service
import (
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
)
// TestEditLocked 内容自动编辑锁定规则(帖子与评论共用):
// staff 豁免、hours<=0 关闭、以创建时间为基准计时
func TestEditLocked(t *testing.T) {
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.Local)
createdAt := now.Add(-48 * time.Hour) // 两天前创建
cases := []struct {
name string
createdAt time.Time
isStaff bool
hours int
now time.Time
want bool
}{
{"未启用", createdAt, false, 0, now, false},
{"负值视为关闭", createdAt, false, -1, now, false},
{"超过时限锁定", createdAt, false, 24, now, true},
{"恰好到期未锁(严格大于)", createdAt, false, 48, now, false},
{"未到期不锁", createdAt, false, 72, now, false},
{"staff 超时也豁免", createdAt, true, 1, now, false},
{"staff 管理员角色豁免", createdAt, model.IsStaff(model.RoleAdmin), 1, now, false},
{"普通用户非豁免", createdAt, model.IsStaff(model.RoleUser), 1, now, true},
{"板块管理员豁免", createdAt, model.IsStaff(model.RoleBoardAdmin), 1, now, false},
{"刚创建且时限极短不锁", now, false, 1, now, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := editLocked(c.createdAt, c.isStaff, c.hours, c.now); got != c.want {
t.Fatalf("editLocked() = %v, want %v", got, c.want)
}
})
}
}
// TestIsNecroReply 旧帖判定:最后回复时间(无回复回落发帖时间)距 now 超过阈值。
// staff 豁免、规则开关、回落基准由调用方负责,不在本 helper 内。
func TestIsNecroReply(t *testing.T) {
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.Local)
lastReplyAt := now.Add(-48 * time.Hour) // 最后回复在两天前
cases := []struct {
name string
lastReplyAt time.Time
afterHours int
now time.Time
want bool
}{
{"阈值关闭", lastReplyAt, 0, now, false},
{"阈值负值视为关闭", lastReplyAt, -1, now, false},
{"超过阈值触发", lastReplyAt, 24, now, true},
{"恰好到期不触发(严格大于)", lastReplyAt, 48, now, false},
{"未到期不触发", lastReplyAt, 72, now, false},
{"无回复回落发帖时间同理(基准即发帖时间)", now.Add(-72 * time.Hour), 48, now, true},
{"刚回复不久不触发", now.Add(-time.Hour), 2, now, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := IsNecroReply(c.lastReplyAt, c.afterHours, c.now); got != c.want {
t.Fatalf("IsNecroReply() = %v, want %v", got, c.want)
}
})
}
}

View File

@@ -180,7 +180,8 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, er
if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok { if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok {
updates["type_meta"] = raw updates["type_meta"] = raw
} }
if err := tx.Model(&post).Updates(updates).Error; err != nil { // UpdateColumns:审核非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导)
if err := tx.Model(&post).UpdateColumns(updates).Error; err != nil {
return err return err
} }
boardID = post.BoardID boardID = post.BoardID
@@ -204,7 +205,8 @@ func (s *ModerationService) RejectPost(actor *Actor, id uint) error {
if post.Status != model.ContentStatusPending { if post.Status != model.ContentStatusPending {
return ErrNotPending return ErrNotPending
} }
if err := tx.Model(&post).Update("status", model.ContentStatusRejected).Error; err != nil { // UpdateColumn:审核非内容编辑,不刷新 updated_at
if err := tx.Model(&post).UpdateColumn("status", model.ContentStatusRejected).Error; err != nil {
return err return err
} }
s.notif.Create(post.UserID, actor.ID, model.NotificationTypeRejected, post.ID, 0, s.notif.Create(post.UserID, actor.ID, model.NotificationTypeRejected, post.ID, 0,
@@ -230,7 +232,8 @@ func (s *ModerationService) ApproveComment(actor *Actor, id uint) (postID, board
if cm.Status != model.ContentStatusPending { if cm.Status != model.ContentStatusPending {
return ErrNotPending return ErrNotPending
} }
if err := tx.Model(&cm).Update("status", model.ContentStatusPublished).Error; err != nil { // UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定)
if err := tx.Model(&cm).UpdateColumn("status", model.ContentStatusPublished).Error; err != nil {
return err return err
} }
if err := tx.Model(&model.Post{}).Where("id = ?", cm.PostID). if err := tx.Model(&model.Post{}).Where("id = ?", cm.PostID).
@@ -273,7 +276,8 @@ func (s *ModerationService) RejectComment(actor *Actor, id uint) error {
if cm.Status != model.ContentStatusPending { if cm.Status != model.ContentStatusPending {
return ErrNotPending return ErrNotPending
} }
if err := tx.Model(&cm).Update("status", model.ContentStatusRejected).Error; err != nil { // UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定)
if err := tx.Model(&cm).UpdateColumn("status", model.ContentStatusRejected).Error; err != nil {
return err return err
} }
s.notif.Create(cm.UserID, actor.ID, model.NotificationTypeRejected, cm.PostID, cm.ID, s.notif.Create(cm.UserID, actor.ID, model.NotificationTypeRejected, cm.PostID, cm.ID,

View File

@@ -100,6 +100,31 @@ func (s *NotificationService) Create(userID, actorID uint, notifType string, pos
} }
} }
// CreateSystem 系统通知(ActorID=0,提醒对象即本人,绕过自我通知检查;失败静默)
func (s *NotificationService) CreateSystem(userID uint, notifType string, postID, commentID uint, content string) {
if userID == 0 {
return
}
if len(content) > 200 {
content = content[:200]
}
n := &model.Notification{
UserID: userID,
ActorID: 0,
Type: notifType,
PostID: postID,
CommentID: commentID,
Content: content,
IsRead: false,
}
if err := s.db.Create(n).Error; err != nil {
return
}
if s.OnNotifyNew != nil {
s.OnNotifyNew(userID)
}
}
// CreateMention 创建群聊 @ 提醒(PostID=0,用 RoomID/MessageID 关联)。 // CreateMention 创建群聊 @ 提醒(PostID=0,用 RoomID/MessageID 关联)。
// 返回创建出的通知 ID(0 表示未创建),供 handler 实时推送。 // 返回创建出的通知 ID(0 表示未创建),供 handler 实时推送。
func (s *NotificationService) CreateMention(userID, actorID, roomID, messageID uint, content string) uint { func (s *NotificationService) CreateMention(userID, actorID, roomID, messageID uint, content string) uint {

View File

@@ -17,8 +17,27 @@ var (
ErrPostForbidden = errors.New("无权限操作此帖子") ErrPostForbidden = errors.New("无权限操作此帖子")
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由") ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
ErrPostInvalidType = errors.New("无效的删除类型") ErrPostInvalidType = errors.New("无效的删除类型")
ErrPostLocked = errors.New("帖子已被锁定,无法编辑")
ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑")
) )
// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用
func editLocked(createdAt time.Time, isStaff bool, hours int, now time.Time) bool {
return !isStaff && hours > 0 && now.Sub(createdAt) > time.Duration(hours)*time.Hour
}
// postEditLockHours 帖子可编辑时长(setting 未注入或读取出错时=0 关闭)
func postEditLockHours(setting *SettingService) int {
if setting == nil {
return 0
}
hours, err := setting.PostEditLockHours()
if err != nil {
return 0
}
return hours
}
// PostService 帖子服务 // PostService 帖子服务
type PostService struct { type PostService struct {
db *gorm.DB db *gorm.DB
@@ -323,13 +342,30 @@ func (s *PostService) TogglePin(id uint) (int, error) {
} else { } else {
newPinned = 1 newPinned = 1
} }
result := s.db.Model(&post).Update("pinned", newPinned) // UpdateColumn:置顶非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("pinned", newPinned)
if result.Error != nil { if result.Error != nil {
return 0, result.Error return 0, result.Error
} }
return newPinned, nil return newPinned, nil
} }
// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。
// 锁定后普通用户不可编辑帖子、不可回复;staff 豁免。
func (s *PostService) ToggleLock(id uint) (bool, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return false, err
}
newVal := !post.Locked
// UpdateColumn:锁定非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("locked", newVal)
if result.Error != nil {
return false, result.Error
}
return newVal, nil
}
// ToggleRecommend 切换帖子加精;加精时向作者发放可配置的推荐奖励(每帖仅一次,自荐不发) // ToggleRecommend 切换帖子加精;加精时向作者发放可配置的推荐奖励(每帖仅一次,自荐不发)
func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) { func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) {
var post model.Post var post model.Post
@@ -337,7 +373,8 @@ func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) {
return false, err return false, err
} }
newVal := !post.Recommended newVal := !post.Recommended
result := s.db.Model(&post).Update("recommended", newVal) // UpdateColumn:加精非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("recommended", newVal)
if result.Error != nil { if result.Error != nil {
return false, result.Error return false, result.Error
} }
@@ -414,17 +451,23 @@ type PostDetail struct {
TypeStatus string `json:"type_status,omitempty"` TypeStatus string `json:"type_status,omitempty"`
Pinned int `json:"pinned"` Pinned int `json:"pinned"`
Recommended bool `json:"recommended"` Recommended bool `json:"recommended"`
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `json:"status"` Status string `json:"status"`
LikeCount int `json:"like_count"` LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"` ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"` CommentCount int `json:"comment_count"`
Liked bool `json:"liked"` Liked bool `json:"liked"`
CreatedAt time.Time `json:"created_at"` Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
UpdatedAt time.Time `json:"updated_at"` // 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
Board model.Board `json:"board"` LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
User model.User `json:"user"` // 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
ContentLocked bool `json:"content_locked"` NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
AccessHint string `json:"access_hint,omitempty"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
ContentLocked bool `json:"content_locked"`
AccessHint string `json:"access_hint,omitempty"`
Attachments []PostAttachmentDTO `json:"attachments"` Attachments []PostAttachmentDTO `json:"attachments"`
Question *QuestionState `json:"question,omitempty"` Question *QuestionState `json:"question,omitempty"`
Poll *PollState `json:"poll,omitempty"` Poll *PollState `json:"poll,omitempty"`
@@ -439,6 +482,12 @@ type PostDetail struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
} }
// NecroReplyHint 旧帖回复确认提示(详情接口按查看者实时计算;命中才返回)
type NecroReplyHint struct {
AfterHours int `json:"after_hours"`
Penalty int `json:"penalty"`
}
// CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生) // CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生)
type CreatePostInput struct { type CreatePostInput struct {
UserID uint UserID uint
@@ -498,6 +547,12 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
post.ViewCount++ post.ViewCount++
detail := buildPostDetail(&post) detail := buildPostDetail(&post)
detail.Edited = s.hasEditHistory(post.ID)
// 最后一条已发布评论时间(旧帖判定基准;无回复为 nil,调用方回落 created_at)
var lastReplyAt *time.Time
if err := s.db.Raw(`SELECT MAX(created_at) FROM comments WHERE post_id = ? AND status = 'published' AND deleted_at IS NULL`, post.ID).Scan(&lastReplyAt).Error; err == nil {
detail.LastReplyAt = lastReplyAt
}
sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor, pwdUnlocked) sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor, pwdUnlocked)
detail.Content = sanitized detail.Content = sanitized
detail.ContentLocked = fullyLocked detail.ContentLocked = fullyLocked
@@ -545,7 +600,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
ContentAccess: model.NormalizeContentAccess(post.ContentAccess), ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta, AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta), TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status, Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount, LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt, Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
Board: post.Board, User: post.User, Board: post.Board, User: post.User,
@@ -919,6 +974,15 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) { if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
return nil, ErrPostForbidden return nil, ErrPostForbidden
} }
editorIsStaff := model.IsStaff(actor.Role)
// 手动锁定:仅管理团队可编辑
if post.Locked && !editorIsStaff {
return nil, ErrPostLocked
}
// 自动编辑锁定:非管理团队的作者超时不可编辑;管理成员(版主代编等)豁免
if post.UserID == userID && editLocked(post.CreatedAt, editorIsStaff, postEditLockHours(s.setting), time.Now()) {
return nil, ErrPostEditLocked
}
updates := map[string]interface{}{} updates := map[string]interface{}{}
if in.Title != "" { if in.Title != "" {
@@ -1042,6 +1106,26 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
} }
err := s.db.Transaction(func(tx *gorm.DB) error { err := s.db.Transaction(func(tx *gorm.DB) error {
// 仅标题/正文实际变化时留修订快照(tags/type_meta/附件变更不留痕)
newTitle, _ := updates["title"].(string)
if newTitle == "" {
newTitle = post.Title
}
newContent, _ := updates["content"].(string)
if newContent == "" {
newContent = post.Content
}
if newTitle != post.Title || newContent != post.Content {
hist := model.PostEditHistory{
PostID: post.ID,
EditorID: userID, // 执行编辑者(可能是版主代编)
OldTitle: post.Title,
OldContent: post.Content,
}
if err := tx.Create(&hist).Error; err != nil {
return err
}
}
if err := tx.Model(&post).Updates(updates).Error; err != nil { if err := tx.Model(&post).Updates(updates).Error; err != nil {
return err return err
} }
@@ -1085,6 +1169,87 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
return detail, nil return detail, nil
} }
// PostEditHistoryItem 登录可见的帖子修订记录
type PostEditHistoryItem struct {
ID uint `json:"id"`
Editor CommentActorBrief `json:"editor"`
OldTitle string `json:"old_title"`
OldContent string `json:"old_content"`
CreatedAt time.Time `json:"created_at"`
}
// hasEditHistory 帖子是否存在修订快照;updated_at 会被置顶/计数等写操作推动,不可作为编辑依据
func (s *PostService) hasEditHistory(postID uint) bool {
var n int64
s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID).Limit(1).Count(&n)
return n > 0
}
// ListEditHistory 帖子编辑历史(登录即可见;含软删帖子;按创建时间倒序分页)
func (s *PostService) ListEditHistory(actor *Actor, postID uint, page, size int) ([]PostEditHistoryItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 10
}
var post model.Post
if err := s.db.Unscoped().Select("id").First(&post, postID).Error; err != nil {
return nil, 0, ErrPostNotFound
}
if actor == nil {
return nil, 0, ErrPostForbidden
}
q := s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.PostEditHistory
if err := s.db.Where("post_id = ?", postID).
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([]PostEditHistoryItem, 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, PostEditHistoryItem{
ID: r.ID,
Editor: ed,
OldTitle: r.OldTitle,
OldContent: r.OldContent,
CreatedAt: r.CreatedAt,
})
}
return items, total, nil
}
// EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量) // EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error { func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error {
var post model.Post var post model.Post
@@ -1184,7 +1349,8 @@ func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostO
"delete_reason": deleteReason, "delete_reason": deleteReason,
"deleted_by": userID, "deleted_by": userID,
} }
if err := s.db.Model(&post).Updates(updates).Error; err != nil { // UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导)
if err := s.db.Model(&post).UpdateColumns(updates).Error; err != nil {
return false, authorID, err return false, authorID, err
} }
if err := s.db.Delete(&post).Error; err != nil { if err := s.db.Delete(&post).Error; err != nil {

View File

@@ -59,6 +59,12 @@ const (
// SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90 // SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
SettingKeyAnalyticsRetentionDays = "analytics_retention_days" SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
// 内容锁定与旧帖回复(全部缺行=0 关闭)
SettingKeyPostEditLockHours = "post_edit_lock_hours" // 帖子可编辑时长;0=不锁定
SettingKeyCommentEditLockHours = "comment_edit_lock_hours" // 评论可编辑时长;0=不锁定
SettingKeyNecroReplyAfterHours = "necro_reply_after_hours" // 超过该时长回复视为旧帖回复;0=关闭
SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分
// 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值) // 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值)
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5 SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7 SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7
@@ -100,6 +106,9 @@ const (
MinAnalyticsRetentionDays = 7 MinAnalyticsRetentionDays = 7
MaxAnalyticsRetentionDays = 365 MaxAnalyticsRetentionDays = 365
// MaxContentLockHours 内容锁定/旧帖阈值小时数上限(一年)
MaxContentLockHours = 8760
DefaultPointsCheckinBase = 5 DefaultPointsCheckinBase = 5
DefaultPointsStreakEveryDays = 7 DefaultPointsStreakEveryDays = 7
MaxPointsRule = 100 // 各积分规则值/上限的统一上界 MaxPointsRule = 100 // 各积分规则值/上限的统一上界
@@ -150,6 +159,12 @@ type PublicSiteSettings struct {
AttachmentMaxMB int `json:"attachment_max_mb"` AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"` AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"` ImageMaxMB int `json:"image_max_mb"`
// 内容锁定与旧帖回复(0=关闭;公开保持规则透明)
PostEditLockHours int `json:"post_edit_lock_hours"`
CommentEditLockHours int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
NecroReplyPenalty int `json:"necro_reply_penalty"`
BgSiteURL string `json:"bg_site_url"` BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"` BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"` BgAdminURL string `json:"bg_admin_url"`
@@ -438,6 +453,27 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
} }
out.ImageMaxMB = imgMB out.ImageMaxMB = imgMB
postLock, err := s.PostEditLockHours()
if err != nil {
return out, err
}
out.PostEditLockHours = postLock
commentLock, err := s.CommentEditLockHours()
if err != nil {
return out, err
}
out.CommentEditLockHours = commentLock
necroAfter, err := s.NecroReplyAfterHours()
if err != nil {
return out, err
}
out.NecroReplyAfterHours = necroAfter
necroPenalty, err := s.NecroReplyPenalty()
if err != nil {
return out, err
}
out.NecroReplyPenalty = necroPenalty
siteURL, err := s.BgSiteURL() siteURL, err := s.BgSiteURL()
if err != nil { if err != nil {
return out, err return out, err
@@ -941,6 +977,91 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
return int64(mb) << 20, nil return int64(mb) << 20, nil
} }
// getLockHours 读取小时数配置(缺行/非法=0 关闭,超上限钳制)
func (s *SettingService) getLockHours(key string) (int, error) {
v, found, err := s.getValue(key)
if err != nil {
return 0, err
}
if !found {
return 0, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return 0, nil
}
if n > MaxContentLockHours {
return MaxContentLockHours, nil
}
return n, nil
}
func (s *SettingService) setLockHours(key string, hours int) error {
if hours < 0 || hours > MaxContentLockHours {
return ErrInvalidSiteSetting
}
if hours == 0 {
return s.deleteKey(key)
}
return s.putValue(key, strconv.Itoa(hours))
}
// PostEditLockHours 帖子可编辑时长(小时)。缺行=0(不锁定)。
func (s *SettingService) PostEditLockHours() (int, error) {
return s.getLockHours(SettingKeyPostEditLockHours)
}
func (s *SettingService) SetPostEditLockHours(hours int) error {
return s.setLockHours(SettingKeyPostEditLockHours, hours)
}
// CommentEditLockHours 评论可编辑时长(小时)。缺行=0(不锁定)。
func (s *SettingService) CommentEditLockHours() (int, error) {
return s.getLockHours(SettingKeyCommentEditLockHours)
}
func (s *SettingService) SetCommentEditLockHours(hours int) error {
return s.setLockHours(SettingKeyCommentEditLockHours, hours)
}
// NecroReplyAfterHours 旧帖回复阈值(小时)。缺行=0(关闭)。
func (s *SettingService) NecroReplyAfterHours() (int, error) {
return s.getLockHours(SettingKeyNecroReplyAfterHours)
}
func (s *SettingService) SetNecroReplyAfterHours(hours int) error {
return s.setLockHours(SettingKeyNecroReplyAfterHours, hours)
}
// NecroReplyPenalty 旧帖回复扣分。缺行=0(仅提醒不扣分)。
func (s *SettingService) NecroReplyPenalty() (int, error) {
v, found, err := s.getValue(SettingKeyNecroReplyPenalty)
if err != nil {
return 0, err
}
if !found {
return 0, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return 0, nil
}
if n > MaxPointsRule {
return MaxPointsRule, nil
}
return n, nil
}
func (s *SettingService) SetNecroReplyPenalty(n int) error {
if n < 0 || n > MaxPointsRule {
return ErrInvalidSiteSetting
}
if n == 0 {
return s.deleteKey(SettingKeyNecroReplyPenalty)
}
return s.putValue(SettingKeyNecroReplyPenalty, strconv.Itoa(n))
}
// AnalyticsEnabled 访问统计采集开关。缺行视为开启。 // AnalyticsEnabled 访问统计采集开关。缺行视为开启。
func (s *SettingService) AnalyticsEnabled() (bool, error) { func (s *SettingService) AnalyticsEnabled() (bool, error) {
v, found, err := s.getValue(SettingKeyAnalyticsEnabled) v, found, err := s.getValue(SettingKeyAnalyticsEnabled)

View File

@@ -82,6 +82,9 @@ func (in *SitePageInput) normalize(existing *model.SitePage) error {
if in.Content == "" { if in.Content == "" {
return errors.New("内容不能为空") return errors.New("内容不能为空")
} }
if utf8.RuneCountInString(in.Content) > 20000 {
return errors.New("内容不能超过 20000 字")
}
if err := rejectHideBlocks(in.Content, ErrSitePageHideNotAllowed); err != nil { if err := rejectHideBlocks(in.Content, ErrSitePageHideNotAllowed); err != nil {
return err return err
} }

View File

@@ -21,6 +21,9 @@ import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColor
type PublishKey = "published" | "draft"; type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft"; type StatusFilter = "all" | "published" | "draft";
/** 公告正文字符上限(与后端 service/announcement.go 校验一致) */
const CONTENT_MAX = 20000;
type FormState = { type FormState = {
title: string; title: string;
content: string; content: string;
@@ -328,8 +331,9 @@ export default function AnnouncementComposeClient({
value={form.content} value={form.content}
onChange={(content) => { onChange={(content) => {
setErrors((prev) => ({ ...prev, content: undefined })); setErrors((prev) => ({ ...prev, content: undefined }));
patchForm({ content }); patchForm({ content: content.slice(0, CONTENT_MAX) });
}} }}
maxChars={CONTENT_MAX}
allowHide allowHide
allowReplyHide={false} allowReplyHide={false}
placeholder="请输入公告正文,支持 Markdown…" placeholder="请输入公告正文,支持 Markdown…"

View File

@@ -200,7 +200,7 @@ export default function BadgesClient() {
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。 管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
</p> </p>
</div> </div>
<button type="button" className="btn btn-solid btn-sm" onClick={openCreate}> <button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建徽章 <Plus size={14} /> 新建徽章
</button> </button>
</div> </div>
@@ -290,7 +290,7 @@ export default function BadgesClient() {
) : ( ) : (
<button <button
type="button" type="button"
className="btn btn-solid btn-sm" className="btn btn-primary btn-sm"
disabled={grantingId === u.id} disabled={grantingId === u.id}
onClick={() => void grant(u.id)} onClick={() => void grant(u.id)}
> >
@@ -416,7 +416,7 @@ export default function BadgesClient() {
</button> </button>
<button <button
type="button" type="button"
className="btn btn-solid btn-sm" className="btn btn-primary btn-sm"
disabled={saving || runeCount(form.name.trim()) === 0} disabled={saving || runeCount(form.name.trim()) === 0}
onClick={() => void saveForm()} onClick={() => void saveForm()}
> >

View File

@@ -25,6 +25,7 @@ import {
apiAdminContentPosts, apiAdminContentPosts,
apiAdminContentComments, apiAdminContentComments,
apiAdminSoftDeletePost, apiAdminSoftDeletePost,
apiCommentHistory,
apiAdminRestorePost, apiAdminRestorePost,
apiAdminPurgePost, apiAdminPurgePost,
apiAdminSoftDeleteComment, apiAdminSoftDeleteComment,
@@ -48,7 +49,7 @@ import AutolinkText from "@/components/AutolinkText";
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"; import EditHistoryModal from "@/components/EditHistoryModal";
import { import {
AdminEmpty, AdminEmpty,
AdminPageHeader, AdminPageHeader,
@@ -320,6 +321,7 @@ export default function ContentAdmin({
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 [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const [historyCommentContent, setHistoryCommentContent] = useState("");
const refreshCounts = useCallback(() => { const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {}); apiAdminContentCounts().then(setCounts).catch(() => {});
@@ -710,11 +712,17 @@ export default function ContentAdmin({
onReject={() => askReject("comments", c.id)} onReject={() => askReject("comments", c.id)}
/> />
) : null} ) : null}
<ContentAction {/* 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录 */}
label="历史" {c.edited ? (
icon={<History size={13} />} <ContentAction
onClick={() => setHistoryCommentId(c.id)} label="历史"
/> icon={<History size={13} />}
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
/>
) : null}
{c.deleted ? ( {c.deleted ? (
<> <>
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? ( {canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
@@ -930,9 +938,14 @@ export default function ContentAdmin({
}} }}
/> />
<CommentEditHistoryModal <EditHistoryModal
open={historyCommentId != null} open={historyCommentId != null}
commentId={historyCommentId} title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={(p, s) => {
if (historyCommentId == null) return Promise.reject(new Error("未选择评论"));
return apiCommentHistory(historyCommentId, p, s);
}}
onClose={() => setHistoryCommentId(null)} onClose={() => setHistoryCommentId(null)}
/> />
</div> </div>

View File

@@ -20,6 +20,9 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
type PublishKey = "published" | "draft"; type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft"; type StatusFilter = "all" | "published" | "draft";
/** 单页正文字符上限(与后端 service/site_page.go 校验一致) */
const CONTENT_MAX = 20000;
type FormState = { type FormState = {
slug: string; slug: string;
title: string; title: string;
@@ -365,8 +368,9 @@ export default function PageComposeClient({
value={form.content} value={form.content}
onChange={(content) => { onChange={(content) => {
setErrors((prev) => ({ ...prev, content: undefined })); setErrors((prev) => ({ ...prev, content: undefined }));
patchForm({ content }); patchForm({ content: content.slice(0, CONTENT_MAX) });
}} }}
maxChars={CONTENT_MAX}
allowHide={false} allowHide={false}
placeholder="请输入页面正文,支持 Markdown…" placeholder="请输入页面正文,支持 Markdown…"
rows={16} rows={16}

View File

@@ -56,7 +56,7 @@ export default function PointsTabs({
: TAB_OPTIONS.filter((o) => o.key === "overview"); : TAB_OPTIONS.filter((o) => o.key === "overview");
return ( return (
<div> <div className={tab === "users" ? "admin-points-users-tab" : undefined}>
{canManage ? ( {canManage ? (
<div className="mb-4"> <div className="mb-4">
<AdminSegmented <AdminSegmented

View File

@@ -24,6 +24,15 @@ const fmt = (n?: number) => (Number(n) || 0).toLocaleString("zh-CN");
type PointsSortKey = "id" | "points" | "total_points"; type PointsSortKey = "id" | "points" | "total_points";
/**
* 表头吸顶样式:sticky 相对 .admin-canvas 滚动口生效(lg 起外层不再是滚动容器,见下方包装层)。
* 分隔线用 inset 阴影而非 border——border-collapse: collapse 下吸顶 th 的边线会丢失。
*/
const thStickStyle = {
background: "var(--panel)",
boxShadow: "inset 0 -1px 0 var(--line)",
} as const;
/** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */ /** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */
function SortHeader({ function SortHeader({
label, label,
@@ -158,24 +167,40 @@ export default function UserPointsTab() {
/> />
</div> </div>
<div className="panel p-0 overflow-hidden"> <div className="panel p-0">
<div className="overflow-x-auto"> {/* lg 起放开横向滚动容器:sticky th 才能相对 admin-canvas 吸顶;窄屏保留横向滚动(不吸顶) */}
<div className="overflow-x-auto lg:overflow-x-visible">
<table className="w-full text-[13px]" style={{ borderCollapse: "collapse" }}> <table className="w-full text-[13px]" style={{ borderCollapse: "collapse" }}>
<thead> <thead>
<tr style={{ borderBottom: "1px solid var(--line)" }}> <tr>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}> <th
className="sticky top-0 z-10 px-4 py-2.5 text-left font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
用户 用户
</th> </th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}> <th
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
<SortHeader label="余额" active={sort === "points"} dir={dir} onClick={() => toggleSort("points")} /> <SortHeader label="余额" active={sort === "points"} dir={dir} onClick={() => toggleSort("points")} />
</th> </th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}> <th
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
<SortHeader label="累计获得" active={sort === "total_points"} dir={dir} onClick={() => toggleSort("total_points")} /> <SortHeader label="累计获得" active={sort === "total_points"} dir={dir} onClick={() => toggleSort("total_points")} />
</th> </th>
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}> <th
className="sticky top-0 z-10 px-4 py-2.5 text-left font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
等级 等级
</th> </th>
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}> <th
className="sticky top-0 z-10 px-4 py-2.5 text-right font-medium"
style={{ color: "var(--ink-3)", ...thStickStyle }}
>
操作 操作
</th> </th>
</tr> </tr>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer } from "lucide-react"; import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer, Lock, Hourglass, Coins, History } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
AdminSettingsGroup, AdminSettingsGroup,
@@ -18,6 +18,59 @@ function InstantNote({ saving }: { saving?: boolean }) {
return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>; return <span className="admin-settings-instant">{saving ? "正在保存…" : "修改后立即生效"}</span>;
} }
/** 内容锁定 4 键(小时上限 8760 / 积分上限 100,后端裁剪) */
type LockKeys = Pick<
PublicSettings,
"post_edit_lock_hours" | "comment_edit_lock_hours" | "necro_reply_after_hours" | "necro_reply_penalty"
>;
type LockKey = keyof LockKeys;
const LOCK_META: { key: LockKey; icon: React.ReactNode; label: string; hint: string; max: number; unit: string; toast: (v: number) => string }[] = [
{
key: "post_edit_lock_hours",
icon: <Lock size={15} />,
label: "帖子可编辑时长",
hint: "发帖后仅在该时长内可编辑(0=不限制,上限 8760 小时),到期后编辑入口隐藏;管理团队不受限。",
max: 8760,
unit: "小时",
toast: (v) => (v === 0 ? "已关闭帖子编辑时限" : `帖子可编辑时长已设为 ${v} 小时`),
},
{
key: "comment_edit_lock_hours",
icon: <Hourglass size={15} />,
label: "评论可编辑时长",
hint: "评论发布后仅在该时长内可编辑(0=不限制,上限 8760 小时);管理团队不受限。",
max: 8760,
unit: "小时",
toast: (v) => (v === 0 ? "已关闭评论编辑时限" : `评论可编辑时长已设为 ${v} 小时`),
},
{
key: "necro_reply_after_hours",
icon: <History size={15} />,
label: "旧帖回复判定时长",
hint: "回复超过该时长的帖子将提醒作者并按下方分值扣分(0=关闭,上限 8760 小时);管理团队不受限。",
max: 8760,
unit: "小时",
toast: (v) => (v === 0 ? "已关闭旧帖回复提醒" : `旧帖判定时长已设为 ${v} 小时`),
},
{
key: "necro_reply_penalty",
icon: <Coins size={15} />,
label: "旧帖回复扣除积分",
hint: "触发旧帖回复时扣除的积分(0=仅提醒不扣分,上限 100);积分不足将无法回复。",
max: 100,
unit: "分",
toast: (v) => (v === 0 ? "旧帖回复仅提醒不扣分" : `旧帖回复将扣除 ${v} 积分`),
},
];
const pickLock = (s: PublicSettings): LockKeys => ({
post_edit_lock_hours: s.post_edit_lock_hours,
comment_edit_lock_hours: s.comment_edit_lock_hours,
necro_reply_after_hours: s.necro_reply_after_hours,
necro_reply_penalty: s.necro_reply_penalty,
});
/** 内容互动开关 + 访问安全模块 */ /** 内容互动开关 + 访问安全模块 */
export default function AccessSettings({ initial }: { initial: PublicSettings }) { export default function AccessSettings({ initial }: { initial: PublicSettings }) {
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false); const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
@@ -37,8 +90,12 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
const [togglingTrust, setTogglingTrust] = useState(false); const [togglingTrust, setTogglingTrust] = useState(false);
const [togglingWithoutReview, setTogglingWithoutReview] = useState(false); const [togglingWithoutReview, setTogglingWithoutReview] = useState(false);
const [savingCooldown, setSavingCooldown] = useState(false); const [savingCooldown, setSavingCooldown] = useState(false);
const [lockForm, setLockForm] = useState<LockKeys>(() => pickLock(initial));
const [savedLock, setSavedLock] = useState<LockKeys>(() => pickLock(initial));
const [savingLockKey, setSavingLockKey] = useState<LockKey | null>(null);
const [moduleDirty, setModuleDirty] = useState(false); const [moduleDirty, setModuleDirty] = useState(false);
const leave = useUnsavedGuard(moduleDirty); const lockDirty = (Object.keys(savedLock) as LockKey[]).some((k) => lockForm[k] !== savedLock[k]);
const leave = useUnsavedGuard(moduleDirty || lockDirty);
const toggleComments = async () => { const toggleComments = async () => {
if (togglingComments) return; if (togglingComments) return;
@@ -164,6 +221,23 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
} }
}; };
const saveLock = async (key: LockKey) => {
if (savingLockKey) return;
const n = Math.max(0, Math.min(8760, Math.round(Number(lockForm[key])) || 0));
setSavingLockKey(key);
try {
const res = await apiUpdateSiteSettings({ [key]: n });
const saved = pickLock(res);
setLockForm((f) => ({ ...f, [key]: saved[key] }));
setSavedLock((f) => ({ ...f, [key]: saved[key] }));
toast(LOCK_META.find((m) => m.key === key)?.toast(saved[key]) || "已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingLockKey(null);
}
};
return ( return (
<div className="admin-settings-stack"> <div className="admin-settings-stack">
<AdminSettingsGroup title="内容与互动"> <AdminSettingsGroup title="内容与互动">
@@ -308,6 +382,31 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
</div> </div>
</AdminSettingsRow> </AdminSettingsRow>
</AdminSettingsGroup> </AdminSettingsGroup>
<AdminSettingsGroup title="内容锁定与旧帖回复">
{LOCK_META.map((m) => (
<AdminSettingsRow key={m.key} label={<span className="inline-flex items-center gap-1.5">{m.icon} {m.label}</span>} hintBelow={m.hint}>
<div className="flex items-center gap-2 flex-wrap">
<AdminStepper
value={lockForm[m.key]}
onChange={(v) => setLockForm((f) => ({ ...f, [m.key]: v }))}
min={0}
max={m.max}
unit={m.unit}
ariaLabel={`${m.label}${m.unit}`}
disabled={savingLockKey !== null}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingLockKey !== null || lockForm[m.key] === savedLock[m.key]}
onClick={() => void saveLock(m.key)}
>
{savingLockKey === m.key ? "保存中…" : "保存"}
</button>
</div>
</AdminSettingsRow>
))}
</AdminSettingsGroup>
<OperationsPanel name="security" onDirtyChange={setModuleDirty} /> <OperationsPanel name="security" onDirtyChange={setModuleDirty} />
<ConfirmDialog <ConfirmDialog
open={leave.leaveOpen} open={leave.leaveOpen}

View File

@@ -212,7 +212,7 @@ export default function EconomySettings({ initial }: { initial: PublicSettings }
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
className="btn btn-solid btn-sm" className="btn btn-primary btn-sm"
disabled={saving || !dirty} disabled={saving || !dirty}
onClick={() => void save()} onClick={() => void save()}
> >

View File

@@ -3335,6 +3335,12 @@ body:has([data-admin-viewport]) {
padding-top: 0; padding-top: 0;
} }
/* 用户积分表头吸顶:同 admin-users-stick,画布顶内边距必须归零 sticky 才能贴紧顶栏
(内边距挪到 .admin-points-users-tab;标记类仅在用户积分标签激活时挂载,不影响其他标签) */
.admin-canvas:has(.admin-points-users-tab) {
padding-top: 0;
}
/* 移动端导航抽屉 */ /* 移动端导航抽屉 */
.admin-drawer { .admin-drawer {
position: fixed; position: fixed;
@@ -5692,6 +5698,21 @@ a.j13-mention-token:hover {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
/* 用户积分标签:画布顶内边距挪到这里(见未分层区 canvas:has 规则),吸顶 th 才能贴齐 admin-topbar */
.admin-points-users-tab {
padding-top: 1.25rem;
}
@media (min-width: 640px) {
.admin-points-users-tab {
padding-top: 1.5rem;
}
}
@media (min-width: 1024px) {
.admin-points-users-tab {
padding-top: 1.75rem;
}
}
/* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */ /* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */
.admin-content-page { .admin-content-page {
display: flex; display: flex;
@@ -8409,6 +8430,78 @@ button.cmt-edited {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
} }
/* 评论编辑历史:git diff 风格修订视图 */
.cmt-diff {
display: flex;
flex-direction: column;
gap: 4px;
}
.cmt-diff-stat {
display: flex;
gap: 8px;
font-size: 11.5px;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.cmt-diff-stat-add {
color: var(--ok);
}
.cmt-diff-stat-del {
color: var(--danger);
}
.cmt-diff-body {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12.5px;
line-height: 1.55;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px 0;
}
.cmt-diff-line {
display: flex;
gap: 8px;
padding: 0 10px;
white-space: pre-wrap;
word-break: break-word;
min-height: 1.55em;
}
.cmt-diff-sign {
flex-shrink: 0;
width: 1ch;
user-select: none;
opacity: 0.65;
}
.cmt-diff-text {
min-width: 0;
flex: 1;
}
.cmt-diff-line.is-del {
background: color-mix(in srgb, var(--danger-soft) 60%, transparent);
}
.cmt-diff-line.is-add {
background: color-mix(in srgb, var(--ok-soft) 60%, transparent);
}
.cmt-diff-line.is-ctx {
color: var(--ink-2);
}
.cmt-diff-chg {
border-radius: 3px;
padding: 0 1px;
background: color-mix(in srgb, var(--danger) 24%, transparent);
color: inherit;
}
.cmt-diff-line.is-add .cmt-diff-chg {
background: color-mix(in srgb, var(--ok) 24%, transparent);
}
.cmt-diff-fold {
padding: 2px 10px;
font-size: 11.5px;
text-align: center;
color: var(--ink-2);
background: color-mix(in srgb, var(--ink) 3%, transparent);
}
.cmt-badge { .cmt-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react"; import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api"; import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
/** /**
@@ -76,22 +76,26 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
<div className="panel overflow-hidden p-0"> <div className="panel overflow-hidden p-0">
{notifications.map((n, i) => { {notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected"; const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const iconNode = n.type === "approved" const iconNode = n.type === "approved"
? <CheckCircle2 size={16} /> ? <CheckCircle2 size={16} />
: n.type === "rejected" || n.type === "deleted" : n.type === "rejected" || n.type === "deleted"
? <XCircle size={16} /> ? <XCircle size={16} />
: n.type === "like" : n.type === "necro_reply"
? <Heart size={16} /> ? <AlertTriangle size={16} />
: n.type === "mention" : n.type === "like"
? <AtSign size={16} /> ? <Heart size={16} />
: n.type === "pending_review" : n.type === "mention"
? <ClipboardList size={16} /> ? <AtSign size={16} />
: n.type === "badge" : n.type === "pending_review"
? <Award size={16} /> ? <ClipboardList size={16} />
: <MessageCircle size={16} />; : n.type === "badge"
? <Award size={16} />
: <MessageCircle size={16} />;
const iconColor = n.type === "approved" const iconColor = n.type === "approved"
? "var(--ok)" ? "var(--ok)"
: n.type === "rejected" || n.type === "deleted" : n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger)" ? "var(--danger)"
: n.type === "like" : n.type === "like"
? "var(--clay)" ? "var(--clay)"
@@ -100,7 +104,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
: "var(--accent)"; : "var(--accent)";
const iconBg = n.type === "approved" const iconBg = n.type === "approved"
? "var(--ok-soft)" ? "var(--ok-soft)"
: n.type === "rejected" || n.type === "deleted" : n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger-soft)" ? "var(--danger-soft)"
: n.type === "like" : n.type === "like"
? "var(--clay-soft)" ? "var(--clay-soft)"
@@ -133,6 +137,10 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
> >
{n.type === "approved" ? "审核通过" : "未通过审核"} {n.type === "approved" ? "审核通过" : "未通过审核"}
</span> </span>
) : system ? (
<span className="font-bold" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : ( ) : (
<> <>
<Link <Link

View File

@@ -37,6 +37,7 @@ import BadgeChip from "@/components/BadgeChip";
import MarkdownBody from "@/components/MarkdownBody"; import MarkdownBody from "@/components/MarkdownBody";
import { publicMarkdownForSeo } from "@/lib/hideBlocks"; import { publicMarkdownForSeo } from "@/lib/hideBlocks";
import PostAttachments from "@/components/PostAttachments"; import PostAttachments from "@/components/PostAttachments";
import PostEditedMark from "@/components/PostEditedMark";
import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels"; import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels";
import AcceptedAnswerCard from "@/components/AcceptedAnswerCard"; import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
import { canModerateBoard } from "@/lib/roles"; import { canModerateBoard } from "@/lib/roles";
@@ -323,8 +324,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const toc = extractToc(publicBody); const toc = extractToc(publicBody);
const headingToc = markdownHeadingToc(toc); const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(publicBody, post.title.length); const readingMinutes = estimateReadingMinutes(publicBody, post.title.length);
const edited = const edited = post.edited ?? false;
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
// 侧栏布局:post 范围左右两列(toc 需在下方已就绪) // 侧栏布局:post 范围左右两列(toc 需在下方已就绪)
const sidebarLists = sidebarScopeLists(sidebar.config, "post"); const sidebarLists = sidebarScopeLists(sidebar.config, "post");
@@ -560,6 +560,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
pinned={post.pinned} pinned={post.pinned}
recommended={post.recommended} recommended={post.recommended}
status={post.status} status={post.status}
createdAt={post.created_at}
locked={post.locked}
postEditLockHours={publicSettings.post_edit_lock_hours}
title={post.title} title={post.title}
/> />
</div> </div>
@@ -586,6 +589,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<> <>
{" "} {" "}
· 更新于 <time dateTime={post.updated_at}>{formatDateTime(post.updated_at)}</time> · 更新于 <time dateTime={post.updated_at}>{formatDateTime(post.updated_at)}</time>
{me.user && (
<>
{" "}·{" "}
<PostEditedMark
postId={post.id}
currentTitle={post.title}
currentContent={post.content || ""}
/>
</>
)}
</> </>
)} )}
</p> </p>
@@ -670,6 +683,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
page={commentsData.page} page={commentsData.page}
size={commentsData.size} size={commentsData.size}
user={me.user} user={me.user}
postLocked={post.locked}
commentEditLockHours={publicSettings.comment_edit_lock_hours}
necroReply={post.necro_reply}
acceptMode={acceptMode} acceptMode={acceptMode}
acceptedCommentId={ acceptedCommentId={
post.question?.accepted_comment_id || post.question?.accepted_comment_id ||

View File

@@ -1,173 +0,0 @@
"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

@@ -1,4 +1,4 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -7,6 +7,7 @@ import {
MessagesSquare, MessagesSquare,
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
Lock,
} from "lucide-react"; } from "lucide-react";
import { import {
apiCreateComment, apiCreateComment,
@@ -16,13 +17,14 @@ import {
apiUpdateComment, apiUpdateComment,
apiAcceptBounty, apiAcceptBounty,
apiAcceptQuestion, apiAcceptQuestion,
apiCommentHistory,
apiAdminApproveComment, apiAdminApproveComment,
apiAdminRejectComment, apiAdminRejectComment,
postDeleteTypeLabel, postDeleteTypeLabel,
type CommentNode, type CommentNode,
type User, type User,
} from "@/lib/api"; } from "@/lib/api";
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles"; import { canModerateBoard, canStaffSoftDelete, canPurgeContent, isStaff } from "@/lib/roles";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { formatCommentTime, formatDateTime } from "@/lib/format"; import { formatCommentTime, formatDateTime } from "@/lib/format";
import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider"; import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider";
@@ -36,7 +38,7 @@ import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import MarkdownEditor from "@/components/MarkdownEditor"; import MarkdownEditor from "@/components/MarkdownEditor";
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"; import EditHistoryModal from "@/components/EditHistoryModal";
interface CommentSectionProps { interface CommentSectionProps {
postId: string; postId: string;
@@ -56,6 +58,12 @@ interface CommentSectionProps {
acceptedCommentId?: number; acceptedCommentId?: number;
/** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */ /** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */
winnerCommentIds?: number[]; winnerCommentIds?: number[];
/** 帖子是否被管理员手动锁定(普通用户不可回复/编辑评论,staff 豁免) */
postLocked: boolean;
/** 评论可编辑时长(小时);0=不锁定;staff 豁免 */
commentEditLockHours: number;
/** 旧帖回复确认提示(后端按查看者计算;存在时提交回复前需用户确认,确认后发送并扣分) */
necroReply?: { after_hours: number; penalty: number } | null;
} }
// 递归统计子树回复数(彻底删除级联提示用) // 递归统计子树回复数(彻底删除级联提示用)
@@ -63,6 +71,9 @@ 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;
} }
/** 评论正文字符上限(与后端 handler/comment.go 校验一致) */
const COMMENT_MAX = 5000;
function findComment(nodes: CommentNode[], id: number): CommentNode | null { function findComment(nodes: CommentNode[], id: number): CommentNode | null {
for (const n of nodes) { for (const n of nodes) {
if (n.id === id) return n; if (n.id === id) return n;
@@ -196,11 +207,22 @@ export default function CommentSection({
acceptMode = null, acceptMode = null,
acceptedCommentId = 0, acceptedCommentId = 0,
winnerCommentIds = [], winnerCommentIds = [],
postLocked,
commentEditLockHours,
necroReply = null,
}: CommentSectionProps) { }: CommentSectionProps) {
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0)); const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
const router = useRouter(); const router = useRouter();
const commentsOpen = useAllowComments(); const commentsOpen = useAllowComments();
const commentsRequireLogin = useCommentsRequireLogin(); const commentsRequireLogin = useCommentsRequireLogin();
const staffViewer = !!user && isStaff(user.role);
/** 锁帖后普通用户禁止回复与编辑自己的评论,staff 豁免 */
const replyBlocked = postLocked && !staffViewer;
/** 评论是否已超过可编辑时限(staff 豁免;0=不启用) */
const commentTimeLocked = (createdAt: string) =>
!staffViewer &&
commentEditLockHours > 0 &&
Date.now() - new Date(createdAt).getTime() > commentEditLockHours * 3_600_000;
const [content, setContent] = useState(""); const [content, setContent] = useState("");
// 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离 // 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离
const [replyTarget, setReplyTarget] = useState<{ const [replyTarget, setReplyTarget] = useState<{
@@ -221,6 +243,8 @@ export default function CommentSection({
const [replyUploading, setReplyUploading] = useState(false); const [replyUploading, setReplyUploading] = useState(false);
const [editUploading, setEditUploading] = useState(false); const [editUploading, setEditUploading] = useState(false);
const [awayHref, setAwayHref] = useState<string | null>(null); const [awayHref, setAwayHref] = useState<string | null>(null);
// 旧帖回复确认:记录待确认的提交入口(主楼层 / 内联回复)
const [necroConfirm, setNecroConfirm] = useState<null | "floor" | "reply">(null);
const [discardReplyOpen, setDiscardReplyOpen] = useState(false); const [discardReplyOpen, setDiscardReplyOpen] = useState(false);
const [discardEditOpen, setDiscardEditOpen] = useState(false); const [discardEditOpen, setDiscardEditOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -237,6 +261,7 @@ export default function CommentSection({
const [editContent, setEditContent] = useState(""); const [editContent, setEditContent] = useState("");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null); const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const [historyCommentContent, setHistoryCommentContent] = useState("");
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set()); const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
@@ -304,6 +329,11 @@ export default function CommentSection({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// 旧帖回复:确认后才发送并扣分(后端按查看者下发提示,staff/待审用户不会拿到)
if (necroReply) {
setNecroConfirm("floor");
return;
}
void submitFloor(); void submitFloor();
}; };
@@ -347,6 +377,11 @@ export default function CommentSection({
const handleSubmitReply = (e: React.FormEvent) => { const handleSubmitReply = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// 旧帖回复:确认后才发送并扣分
if (necroReply) {
setNecroConfirm("reply");
return;
}
void submitReply(); void submitReply();
}; };
@@ -472,9 +507,16 @@ export default function CommentSection({
(user.id === comment.user?.id || (user.id === comment.user?.id ||
canStaffSoftDelete(user, boardId, comment.user?.role)); canStaffSoftDelete(user, boardId, comment.user?.role));
const canEdit = (comment: CommentNode) => canSoftDelete(comment); const canEdit = (comment: CommentNode) => {
if (!canSoftDelete(comment)) return false;
// 作者本人编辑:锁帖与可编辑时限限制(staff 豁免;版主编辑他人不受此限)
if (user?.id === comment.user?.id && (replyBlocked || commentTimeLocked(comment.created_at))) {
return false;
}
return true;
};
const canViewHistory = !!user && canModerateBoard(user, boardId); const canViewHistory = !!user;
const canPurge = (comment: CommentNode) => const canPurge = (comment: CommentNode) =>
!!user && canPurgeContent(user.role) && canModerateBoard(user, boardId); !!user && canPurgeContent(user.role) && canModerateBoard(user, boardId);
@@ -615,9 +657,10 @@ export default function CommentSection({
density="comment" density="comment"
value={editContent} value={editContent}
onChange={(v) => { onChange={(v) => {
setEditContent(v); setEditContent(v.slice(0, COMMENT_MAX));
if (editError) setEditError(""); if (editError) setEditError("");
}} }}
maxChars={COMMENT_MAX}
disabled={savingEdit} disabled={savingEdit}
autoFocus autoFocus
allowHide={false} allowHide={false}
@@ -671,7 +714,14 @@ export default function CommentSection({
if (!c.edited) return null; if (!c.edited) return null;
if (canViewHistory) { if (canViewHistory) {
return ( return (
<button type="button" className="cmt-edited" onClick={() => setHistoryCommentId(c.id)}> <button
type="button"
className="cmt-edited"
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
>
已编辑 已编辑
</button> </button>
); );
@@ -693,11 +743,15 @@ export default function CommentSection({
onSelect: () => startEdit(c), onSelect: () => startEdit(c),
}); });
} }
if (canViewHistory) { // 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录
if (canViewHistory && c.edited) {
items.push({ items.push({
key: "history", key: "history",
label: "编辑历史", label: "编辑历史",
onSelect: () => setHistoryCommentId(c.id), onSelect: () => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
},
}); });
} }
if (canRestore(c)) { if (canRestore(c)) {
@@ -758,9 +812,10 @@ export default function CommentSection({
density="comment" density="comment"
value={replyContent} value={replyContent}
onChange={(v) => { onChange={(v) => {
setReplyContent(v); setReplyContent(v.slice(0, COMMENT_MAX));
if (replyError) setReplyError(""); if (replyError) setReplyError("");
}} }}
maxChars={COMMENT_MAX}
disabled={submitting} disabled={submitting}
autoFocus autoFocus
allowHide={false} allowHide={false}
@@ -1015,7 +1070,7 @@ export default function CommentSection({
</div> </div>
{renderContentOrEditor(c)} {renderContentOrEditor(c)}
<div className="cmt-actions"> <div className="cmt-actions">
{commentsOpen && user && editingId !== c.id && ( {commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button <button
type="button" type="button"
onClick={() => openReply(c, floor)} onClick={() => openReply(c, floor)}
@@ -1117,7 +1172,7 @@ export default function CommentSection({
</div> </div>
{renderContentOrEditor(c)} {renderContentOrEditor(c)}
<div className="cmt-actions"> <div className="cmt-actions">
{commentsOpen && user && editingId !== c.id && ( {commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button <button
type="button" type="button"
onClick={() => openReply(c, floor)} onClick={() => openReply(c, floor)}
@@ -1214,7 +1269,18 @@ export default function CommentSection({
) : null} ) : null}
</div> </div>
{user ? ( {replyBlocked ? (
<div
className="mb-8 px-5 py-4 rounded-2xl flex items-center gap-2.5 border"
style={{ borderColor: "var(--line)", background: "transparent" }}
role="status"
>
<Lock size={15} style={{ color: "var(--ink-3)" }} className="shrink-0" />
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
帖子已被锁定,暂时无法回复
</span>
</div>
) : user ? (
<form onSubmit={handleSubmit} className="mb-6"> <form onSubmit={handleSubmit} className="mb-6">
<div className="cmt-md-editor cmt-md-editor-floor scroll-mt-28"> <div className="cmt-md-editor cmt-md-editor-floor scroll-mt-28">
<MarkdownEditor <MarkdownEditor
@@ -1222,9 +1288,10 @@ export default function CommentSection({
density="comment" density="comment"
value={content} value={content}
onChange={(v) => { onChange={(v) => {
setContent(v); setContent(v.slice(0, COMMENT_MAX));
if (floorError) setFloorError(""); if (floorError) setFloorError("");
}} }}
maxChars={COMMENT_MAX}
disabled={submitting} disabled={submitting}
allowHide={false} allowHide={false}
ariaLabel="发表评论" ariaLabel="发表评论"
@@ -1355,9 +1422,29 @@ export default function CommentSection({
onCancel={() => setDiscardEditOpen(false)} onCancel={() => setDiscardEditOpen(false)}
/> />
<CommentEditHistoryModal {/* 旧帖回复确认:确认后才发送并扣分(提示由后端按查看者下发) */}
<ConfirmDialog
open={necroConfirm !== null}
title="回复旧帖提醒"
message={`该帖已超过 ${necroReply?.after_hours ?? 0} 小时没有新回复,回复将扣除 ${necroReply?.penalty ?? 0} 积分。确定继续吗?`}
confirmLabel="继续回复"
onConfirm={() => {
const kind = necroConfirm;
setNecroConfirm(null);
if (kind === "floor") void submitFloor();
else if (kind === "reply") void submitReply();
}}
onCancel={() => setNecroConfirm(null)}
/>
<EditHistoryModal
open={historyCommentId != null} open={historyCommentId != null}
commentId={historyCommentId} title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={(p, s) => {
if (historyCommentId == null) return Promise.reject(new Error("未选择评论"));
return apiCommentHistory(historyCommentId, p, s);
}}
onClose={() => setHistoryCommentId(null)} onClose={() => setHistoryCommentId(null)}
/> />
</section> </section>

View File

@@ -0,0 +1,433 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
const PAGE_SIZE = 10;
export interface EditHistoryItem {
id: number;
editor: {
id: number;
username: string;
nickname: string;
avatar: string;
};
old_title?: string; // 帖子场景有;评论场景无
old_content: string;
created_at: string;
}
export type FetchHistoryPage = (
page: number,
size: number
) => Promise<{
items: EditHistoryItem[];
total: number;
page: number;
size: number;
}>;
/* ---------------- 行级 diff(LCS),git diff 风格渲染 ---------------- */
type DiffOp = "del" | "add" | "ctx";
interface DiffLine {
op: DiffOp;
text: string;
}
interface Seg {
chg: boolean;
text: string;
}
type Row =
| { kind: "line"; op: DiffOp; text: string; pair: string | null }
| { kind: "fold"; count: number };
// 超大文本退化为整段替换,避免 O(n·m) 内存膨胀
const MAX_DIFF_CELLS = 400_000;
// 单行超过该长度不做行内变更高亮
const MAX_INLINE_DIFF_LEN = 2000;
// 相同内容行超过该数量时折叠中段
const CONTEXT_FOLD_LIMIT = 8;
function splitLines(text: string): string[] {
if (!text) return [];
return text.replace(/\r\n/g, "\n").split("\n");
}
function diffLines(oldText: string, newText: string): DiffLine[] {
const a = splitLines(oldText);
const b = splitLines(newText);
const n = a.length;
const m = b.length;
if (n * m > MAX_DIFF_CELLS) {
return [
...a.map((text) => ({ op: "del" as const, text })),
...b.map((text) => ({ op: "add" as const, text })),
];
}
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
a[i] === b[j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ op: "ctx", text: a[i] });
i += 1;
j += 1;
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
out.push({ op: "del", text: a[i] });
i += 1;
} else {
out.push({ op: "add", text: b[j] });
j += 1;
}
}
while (i < n) {
out.push({ op: "del", text: a[i] });
i += 1;
}
while (j < m) {
out.push({ op: "add", text: b[j] });
j += 1;
}
return normalizeBlocks(out);
}
// 每个连续变更块内统一「先删后增」,与 git diff 呈现一致
function normalizeBlocks(lines: DiffLine[]): DiffLine[] {
const out: DiffLine[] = [];
let k = 0;
while (k < lines.length) {
if (lines[k].op === "ctx") {
out.push(lines[k]);
k += 1;
continue;
}
let end = k;
while (end < lines.length && lines[end].op !== "ctx") end += 1;
for (let t = k; t < end; t++) if (lines[t].op === "del") out.push(lines[t]);
for (let t = k; t < end; t++) if (lines[t].op === "add") out.push(lines[t]);
k = end;
}
return out;
}
// 行内变更高亮:取公共前/后缀,中间差异段标记 chg
function segmentLine(line: string, other: string | null): Seg[] {
if (
!other ||
line === other ||
line.length + other.length > MAX_INLINE_DIFF_LEN * 2
) {
return [{ chg: false, text: line }];
}
const min = Math.min(line.length, other.length);
let p = 0;
while (p < min && line[p] === other[p]) p += 1;
let s = 0;
while (
s < min - p &&
line[line.length - 1 - s] === other[other.length - 1 - s]
) {
s += 1;
}
const segs: Seg[] = [];
if (p > 0) segs.push({ chg: false, text: line.slice(0, p) });
const mid = line.slice(p, line.length - s);
if (mid) segs.push({ chg: true, text: mid });
if (s > 0) segs.push({ chg: false, text: line.slice(line.length - s) });
return segs.length > 0 ? segs : [{ chg: false, text: line }];
}
// 折叠长段相同内容;同块内删除/新增按序号配对做行内高亮
function buildRows(lines: DiffLine[]): Row[] {
const rows: Row[] = [];
let k = 0;
while (k < lines.length) {
if (lines[k].op === "ctx") {
let end = k;
while (end < lines.length && lines[end].op === "ctx") end += 1;
const run = end - k;
if (run > CONTEXT_FOLD_LIMIT) {
for (let t = k; t < k + 3; t++) {
rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null });
}
rows.push({ kind: "fold", count: run - 6 });
for (let t = end - 3; t < end; t++) {
rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null });
}
} else {
for (let t = k; t < end; t++) {
rows.push({ kind: "line", op: lines[t].op, text: lines[t].text, pair: null });
}
}
k = end;
} else {
let end = k;
while (end < lines.length && lines[end].op !== "ctx") end += 1;
const dels = lines.slice(k, end).filter((l) => l.op === "del");
const adds = lines.slice(k, end).filter((l) => l.op === "add");
const pairFor = new Map<DiffLine, string | null>();
const cnt = Math.min(dels.length, adds.length);
for (let t = 0; t < cnt; t++) {
pairFor.set(dels[t], adds[t].text);
pairFor.set(adds[t], dels[t].text);
}
for (let t = k; t < end; t++) {
rows.push({
kind: "line",
op: lines[t].op,
text: lines[t].text,
pair: pairFor.get(lines[t]) ?? null,
});
}
k = end;
}
}
return rows;
}
function DiffBody({ before, after }: { before: string; after: string }) {
const rows = useMemo(() => buildRows(diffLines(before, after)), [before, after]);
const delCount = rows.filter((r) => r.kind === "line" && r.op === "del").length;
const addCount = rows.filter((r) => r.kind === "line" && r.op === "add").length;
return (
<div className="cmt-diff">
<div className="cmt-diff-stat">
<span className="cmt-diff-stat-add">+{addCount}</span>
<span className="cmt-diff-stat-del">−{delCount}</span>
</div>
<div className="cmt-diff-body">
{rows.map((r, idx) =>
r.kind === "fold" ? (
<div key={idx} className="cmt-diff-fold">
⋯ 已省略 {r.count} 行相同内容 ⋯
</div>
) : (
<div key={idx} className={`cmt-diff-line is-${r.op}`}>
<span className="cmt-diff-sign" aria-hidden>
{r.op === "del" ? "-" : r.op === "add" ? "+" : " "}
</span>
<span className="cmt-diff-text">
{segmentLine(r.text, r.pair).map((s, si) =>
s.chg ? (
<mark key={si} className="cmt-diff-chg">
{s.text}
</mark>
) : (
<span key={si}>{s.text}</span>
)
)}
</span>
</div>
)
)}
</div>
</div>
);
}
/* ---------------- 弹窗 ---------------- */
/**
* 登录可见:历次编辑的 git diff 风格修订视图(倒序分页)。
* 后端只存每次编辑前的旧快照:第 i 条的「改后内容」= 第 i-1 条(更新的一条)的快照,
* 最新一条的「改后内容」= 当前正文(由调用方传入)。帖子场景另传 currentTitle 渲染标题 diff。
*/
export default function EditHistoryModal({
open,
title,
currentTitle,
currentContent,
fetchPage,
onClose,
}: {
open: boolean;
title: string;
currentTitle?: string;
currentContent: string;
fetchPage: FetchHistoryPage;
onClose: () => void;
}) {
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [items, setItems] = useState<EditHistoryItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const hasMore = items.length < total;
const loadPage = useCallback(
async (nextPage: number, append: boolean) => {
if (!open) return;
if (append) setLoadingMore(true);
else {
setLoading(true);
setError("");
}
try {
const res = await fetchPage(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);
}
},
[open, fetchPage]
);
useEffect(() => {
if (!open) return;
setItems([]);
setPage(1);
setTotal(0);
setError("");
void loadPage(1, false);
}, [open, loadPage]);
const hasTitle = typeof currentTitle === "string";
return (
<Modal
open={open}
title={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, idx) => {
const name = it.editor.nickname || it.editor.username || "用户";
// 本次编辑的改后内容:上一条(更新的)快照;最新一条为当前正文
const afterContent = idx === 0 ? currentContent : items[idx - 1].old_content;
const afterTitle = hasTitle
? idx === 0
? (currentTitle ?? "")
: (items[idx - 1].old_title ?? "")
: undefined;
const titleChanged = hasTitle && it.old_title !== afterTitle;
const contentChanged = it.old_content !== afterContent;
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")} · 第{" "}
{total - idx} 次编辑
</p>
</div>
</div>
{titleChanged && (
<div className="mb-3">
<p className="meta text-[11.5px] mb-1">标题</p>
<DiffBody before={it.old_title ?? ""} after={afterTitle ?? ""} />
</div>
)}
{contentChanged ? (
<DiffBody before={it.old_content} after={afterContent} />
) : (
!titleChanged && (
<p className="meta text-[12.5px]">本次仅变更了标签或其他元数据,未修改标题/正文。</p>
)
)}
</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

@@ -71,6 +71,8 @@ type MarkdownEditorProps = {
shortcutName?: string; shortcutName?: string;
/** 底栏右侧操作(发表 / 保存),纳入编辑器边框内 */ /** 底栏右侧操作(发表 / 保存),纳入编辑器边框内 */
footer?: ReactNode; footer?: ReactNode;
/** 正文字符数上限;设置后计数器显示「已输入 / 上限」并限制输入 */
maxChars?: number;
/** 提交失败等原因,显示在编辑器内 */ /** 提交失败等原因,显示在编辑器内 */
error?: string; error?: string;
onUploadingChange?: (uploading: boolean) => void; onUploadingChange?: (uploading: boolean) => void;
@@ -143,6 +145,7 @@ export default function MarkdownEditor({
hint, hint,
shortcutName, shortcutName,
footer, footer,
maxChars,
error, error,
onUploadingChange, onUploadingChange,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
@@ -1247,6 +1250,7 @@ export default function MarkdownEditor({
value={value} value={value}
disabled={disabled} disabled={disabled}
rows={isComment ? 3 : rows} rows={isComment ? 3 : rows}
maxLength={maxChars}
placeholder={placeholder} placeholder={placeholder}
aria-label={ariaLabel || placeholder || "正文"} aria-label={ariaLabel || placeholder || "正文"}
className="j13-md-textarea" className="j13-md-textarea"
@@ -1278,7 +1282,9 @@ export default function MarkdownEditor({
: "支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交")} : "支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交")}
</span> </span>
<span className="j13-md-foot-actions"> <span className="j13-md-foot-actions">
<span className="j13-md-count tabular-nums">{value.length} 字</span> <span className="j13-md-count tabular-nums">
{maxChars != null ? `${value.length} / ${maxChars}` : `${value.length} 字`}
</span>
{footer} {footer}
</span> </span>
</div> </div>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react"; import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
export type ModalProps = { export type ModalProps = {
@@ -102,7 +103,7 @@ export default function Modal({
overlayPressedRef.current = false; overlayPressedRef.current = false;
}; };
return ( return createPortal(
<div <div
className={`fixed inset-0 ${zIndexClass} flex items-center justify-center p-4`} className={`fixed inset-0 ${zIndexClass} flex items-center justify-center p-4`}
style={{ background: "rgba(0,0,0,0.55)" }} style={{ background: "rgba(0,0,0,0.55)" }}
@@ -150,7 +151,8 @@ export default function Modal({
<div className="flex items-center justify-end gap-2 mt-5">{footer}</div> <div className="flex items-center justify-end gap-2 mt-5">{footer}</div>
) : null} ) : null}
</div> </div>
</div> </div>,
document.body
); );
} }

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react"; import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react";
import { import {
apiFetchNotifications, apiFetchNotifications,
apiUnreadCount, apiUnreadCount,
@@ -137,17 +137,22 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "pending_review") return "有新内容待审核"; if (n.type === "pending_review") return "有新内容待审核";
if (n.type === "deleted") return "删除了你的帖子"; if (n.type === "deleted") return "删除了你的帖子";
if (n.type === "badge") return "向你颁布了徽章"; if (n.type === "badge") return "向你颁布了徽章";
if (n.type === "necro_reply") return "旧帖回复提醒";
return "点赞了你的帖子"; return "点赞了你的帖子";
}; };
// 审核结果类通知:无互动对象语义,使用专用图标与语义色 // 审核结果类通知:无互动对象语义,使用专用图标与语义色
const isApproval = (n: NotificationItem) => const isApproval = (n: NotificationItem) =>
n.type === "approved" || n.type === "rejected" || n.type === "deleted"; n.type === "approved" || n.type === "rejected" || n.type === "deleted";
// 系统提醒(ActorID=0):同样不渲染操作者链接
const isSystemNotice = (n: NotificationItem) => n.type === "necro_reply";
const iconStyle = (n: NotificationItem): { color: string; bg: string; node: React.ReactNode } => { const iconStyle = (n: NotificationItem): { color: string; bg: string; node: React.ReactNode } => {
if (n.type === "approved") if (n.type === "approved")
return { color: "var(--ok)", bg: "var(--ok-soft)", node: <CheckCircle2 size={15} /> }; return { color: "var(--ok)", bg: "var(--ok-soft)", node: <CheckCircle2 size={15} /> };
if (n.type === "rejected" || n.type === "deleted") if (n.type === "rejected" || n.type === "deleted")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <XCircle size={15} /> }; return { color: "var(--danger)", bg: "var(--danger-soft)", node: <XCircle size={15} /> };
if (n.type === "necro_reply")
return { color: "var(--danger)", bg: "var(--danger-soft)", node: <AlertTriangle size={15} /> };
if (n.type === "like") if (n.type === "like")
return { color: "var(--clay)", bg: "var(--clay-soft)", node: <Heart size={15} /> }; return { color: "var(--clay)", bg: "var(--clay-soft)", node: <Heart size={15} /> };
if (n.type === "mention") if (n.type === "mention")
@@ -223,7 +228,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
) : ( ) : (
notifications.map((n) => { notifications.map((n) => {
const ic = iconStyle(n); const ic = iconStyle(n);
const approval = isApproval(n); const approval = isApproval(n) || isSystemNotice(n);
return ( return (
<button <button
key={n.id} key={n.id}

View File

@@ -3,9 +3,9 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react"; import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck, Lock, LockOpen } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api"; import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiTogglePostLock, apiAdminApprovePost, type User } from "@/lib/api";
import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove } from "@/lib/roles"; import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove, isStaff } from "@/lib/roles";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal"; import StaffDeletePostModal from "@/components/StaffDeletePostModal";
@@ -19,6 +19,12 @@ interface PostActionsProps {
recommended: boolean; recommended: boolean;
/** 帖子审核状态:pending 时对可审者显示「通过评审」 */ /** 帖子审核状态:pending 时对可审者显示「通过评审」 */
status: string; status: string;
/** 帖子创建时间:自动编辑锁计时基准(staff 豁免) */
createdAt: string;
/** 帖子是否被管理员手动锁定(staff 豁免) */
locked: boolean;
/** 帖子可编辑时长(小时);0=不锁定;staff 豁免 */
postEditLockHours: number;
/** 供删除确认展示 */ /** 供删除确认展示 */
title?: string; title?: string;
} }
@@ -31,6 +37,9 @@ export default function PostActions({
pinned, pinned,
recommended, recommended,
status, status,
createdAt,
locked,
postEditLockHours,
title, title,
}: PostActionsProps) { }: PostActionsProps) {
const router = useRouter(); const router = useRouter();
@@ -39,6 +48,7 @@ export default function PostActions({
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0); const [isPinned, setIsPinned] = useState(pinned > 0);
const [isRecommended, setIsRecommended] = useState(recommended); const [isRecommended, setIsRecommended] = useState(recommended);
const [isLocked, setIsLocked] = useState(locked);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [staffDeleteOpen, setStaffDeleteOpen] = useState(false); const [staffDeleteOpen, setStaffDeleteOpen] = useState(false);
@@ -70,6 +80,13 @@ export default function PostActions({
!!user && !!user &&
(user.id === authorId || canStaffSoftDelete(user, boardId, authorRole)); (user.id === authorId || canStaffSoftDelete(user, boardId, authorRole));
const isAdmin = !!user && isAdminOrAbove(user.role); const isAdmin = !!user && isAdminOrAbove(user.role);
const staffUser = !!user && isStaff(user.role);
// 编辑入口:手动锁帖或超过可编辑时限时对普通用户隐藏,staff 豁免
const editTimeLocked =
!staffUser &&
postEditLockHours > 0 &&
Date.now() - new Date(createdAt).getTime() > postEditLockHours * 3_600_000;
const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked;
const canApprove = const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId); !!user && status === "pending" && canModerateBoard(user, boardId);
// staff 删他人帖:走类型+理由;作者自删:简单确认 // staff 删他人帖:走类型+理由;作者自删:简单确认
@@ -139,6 +156,21 @@ export default function PostActions({
} }
}; };
const handleLock = async () => {
setActing(true);
setOpen(false);
try {
const res = await apiTogglePostLock(postId);
setIsLocked(res.locked);
toast(res.locked ? "已锁定,普通用户不可编辑与回复" : "已解除锁定", "ok");
router.refresh();
} catch {
toast("操作失败,请稍后重试");
} finally {
setActing(false);
}
};
if (!canManage) return null; if (!canManage) return null;
const itemCls = const itemCls =
@@ -212,18 +244,35 @@ export default function PostActions({
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span> <span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>
{isRecommended && <Check size={14} className="shrink-0" />} {isRecommended && <Check size={14} className="shrink-0" />}
</button> </button>
<button
type="button"
role="menuitem"
onClick={handleLock}
className={itemCls}
style={isLocked ? { color: "var(--danger)" } : { color: "var(--ink)" }}
>
{isLocked ? (
<LockOpen size={14} className="shrink-0" />
) : (
<Lock size={14} className="shrink-0" />
)}
<span className="flex-1 text-left">{isLocked ? "解除锁定" : "锁定"}</span>
{isLocked && <Check size={14} className="shrink-0" />}
</button>
<div style={{ height: 1, background: "var(--line)" }} /> <div style={{ height: 1, background: "var(--line)" }} />
</> </>
)} )}
<Link {showEdit && (
href={`/post/${postId}/edit`} <Link
role="menuitem" href={`/post/${postId}/edit`}
className={`${itemCls} items-center`} role="menuitem"
style={{ color: "var(--ink)" }} className={`${itemCls} items-center`}
> style={{ color: "var(--ink)" }}
<Pencil size={14} className="shrink-0" /> >
<span className="flex-1 text-left">编辑帖子</span> <Pencil size={14} className="shrink-0" />
</Link> <span className="flex-1 text-left">编辑帖子</span>
</Link>
)}
<button <button
type="button" type="button"
role="menuitem" role="menuitem"

View File

@@ -37,6 +37,8 @@ import Modal from "@/components/Modal";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
const TITLE_MAX = 256; const TITLE_MAX = 256;
/** 帖子正文字符上限(与后端 handler/post.go 校验一致) */
const CONTENT_MAX = 20000;
const DRAFT_KEY = "j13-compose-draft"; const DRAFT_KEY = "j13-compose-draft";
const DRAFT_DEBOUNCE_MS = 600; const DRAFT_DEBOUNCE_MS = 600;
/** 与 globals.css 工作台分栏断点一致 */ /** 与 globals.css 工作台分栏断点一致 */
@@ -1640,7 +1642,8 @@ export default function PostComposer(props: PostComposerProps) {
<MarkdownEditor <MarkdownEditor
id="compose-content" id="compose-content"
value={content} value={content}
onChange={setContent} onChange={(v) => setContent(v.slice(0, CONTENT_MAX))}
maxChars={CONTENT_MAX}
allowHide allowHide
disabled={loading} disabled={loading}
rows={18} rows={18}

View File

@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import EditHistoryModal from "@/components/EditHistoryModal";
import { apiPostHistory } from "@/lib/api";
/** 帖子详情「更新于」旁的编辑历史入口(登录用户可见) */
export default function PostEditedMark({
postId,
currentTitle,
currentContent,
}: {
postId: number;
currentTitle: string;
currentContent: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
className="cmt-edited"
onClick={() => setOpen(true)}
>
编辑历史
</button>
<EditHistoryModal
open={open}
title="帖子编辑历史"
currentTitle={currentTitle}
currentContent={currentContent}
fetchPage={(p, s) => apiPostHistory(postId, p, s)}
onClose={() => setOpen(false)}
/>
</>
);
}

View File

@@ -381,11 +381,19 @@ export interface Post {
type_status?: string; type_status?: string;
pinned: number; pinned: number;
recommended: boolean; recommended: boolean;
/** 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) */
locked: boolean;
/** 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at) */
last_reply_at?: string | null;
/** 旧帖回复确认提示(详情接口按查看者计算;存在时提交回复前需用户确认) */
necro_reply?: { after_hours: number; penalty: number } | null;
status: string; status: string;
like_count: number; like_count: number;
view_count: number; view_count: number;
comment_count: number; comment_count: number;
liked: boolean; liked: boolean;
/** 详情接口返回:是否存在编辑历史快照(列表接口无此字段) */
edited?: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
board: Board; board: Board;
@@ -608,7 +616,7 @@ export interface NotificationItem {
id: number; id: number;
user_id: number; user_id: number;
actor_id: number; actor_id: number;
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge"; type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply";
post_id: number; post_id: number;
comment_id: number; comment_id: number;
room_id?: number; room_id?: number;
@@ -918,6 +926,14 @@ export interface PublicSettings {
points_reply_daily_cap: number; points_reply_daily_cap: number;
/** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */ /** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */
points_recommend_reward: number; points_recommend_reward: number;
/** 帖子可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */
post_edit_lock_hours: number;
/** 评论可编辑时长(小时);0=不锁定;缺省 0;上限 8760 */
comment_edit_lock_hours: number;
/** 旧帖回复判定时长(小时);0=关闭;缺省 0 */
necro_reply_after_hours: number;
/** 旧帖回复扣除积分;0=仅提醒不扣分;缺省 0;上限 100 */
necro_reply_penalty: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */ /** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */
levels: LevelDef[]; levels: LevelDef[];
/** 等级特效动画总开关(流光动画);缺省开 */ /** 等级特效动画总开关(流光动画);缺省开 */
@@ -980,6 +996,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
points_reply_reward: 0, points_reply_reward: 0,
points_reply_daily_cap: 0, points_reply_daily_cap: 0,
points_recommend_reward: 0, points_recommend_reward: 0,
post_edit_lock_hours: 0,
comment_edit_lock_hours: 0,
necro_reply_after_hours: 0,
necro_reply_penalty: 0,
levels: [...DEFAULT_LEVEL_DEFS], levels: [...DEFAULT_LEVEL_DEFS],
levels_fx: true, levels_fx: true,
}; };
@@ -1068,11 +1088,40 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "", bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover", bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
...normalizePointsSettings(data), ...normalizePointsSettings(data),
...normalizeLockSettings(data),
levels: normalizeLevels(data?.levels), levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false, levels_fx: data?.levels_fx !== false,
}; };
} }
/** 内容锁定 4 键:小时 0–8760 / 积分 0–100 有效整数,越界/异常回落默认值 */
function normalizeLockSettings(
data: Partial<PublicSettings> | null | undefined,
): Pick<
PublicSettings,
| "post_edit_lock_hours"
| "comment_edit_lock_hours"
| "necro_reply_after_hours"
| "necro_reply_penalty"
> {
const hours = (key: keyof PublicSettings): number | undefined => {
const v = data?.[key];
return typeof v === "number" && v >= 0 && v <= 8760 ? Math.round(v) : undefined;
};
const points = (key: keyof PublicSettings): number | undefined => {
const v = data?.[key];
return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined;
};
return {
post_edit_lock_hours: hours("post_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.post_edit_lock_hours,
comment_edit_lock_hours:
hours("comment_edit_lock_hours") ?? DEFAULT_PUBLIC_SETTINGS.comment_edit_lock_hours,
necro_reply_after_hours:
hours("necro_reply_after_hours") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_after_hours,
necro_reply_penalty: points("necro_reply_penalty") ?? DEFAULT_PUBLIC_SETTINGS.necro_reply_penalty,
};
}
/** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */ /** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */
function normalizePointsSettings( function normalizePointsSettings(
data: Partial<PublicSettings> | null | undefined, data: Partial<PublicSettings> | null | undefined,
@@ -1977,6 +2026,15 @@ export async function apiToggleRecommend(postId: string): Promise<{ recommended:
return res.json(); return res.json();
} }
/** 切换帖子手动锁定(管理员及以上);锁定后普通用户不可编辑/回复 */
export async function apiTogglePostLock(postId: string): Promise<{ locked: boolean }> {
const res = await fetchWithRefresh(`/api/posts/${postId}/lock`, {
method: "PUT",
headers: clientHeaders(),
});
return res.json();
}
export async function apiChangePassword(oldPassword: string, newPassword: string) { export async function apiChangePassword(oldPassword: string, newPassword: string) {
const res = await fetchWithRefresh("/api/change-password", { const res = await fetchWithRefresh("/api/change-password", {
method: "POST", method: "POST",
@@ -3092,6 +3150,10 @@ export type UpdateSiteSettingsBody = {
points_reply_reward?: number; points_reply_reward?: number;
points_reply_daily_cap?: number; points_reply_daily_cap?: number;
points_recommend_reward?: number; points_recommend_reward?: number;
post_edit_lock_hours?: number;
comment_edit_lock_hours?: number;
necro_reply_after_hours?: number;
necro_reply_penalty?: number;
/** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */ /** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */
levels?: LevelDef[]; levels?: LevelDef[];
levels_fx?: boolean; levels_fx?: boolean;
@@ -3264,6 +3326,8 @@ export interface AdminContentComment {
board_id: number; board_id: number;
content: string; content: string;
status: string; status: string;
/** 相对创建已编辑(服务端判定);无编辑时隐藏历史入口 */
edited: boolean;
deleted: boolean; deleted: boolean;
deleted_at?: string; deleted_at?: string;
created_at: string; created_at: string;
@@ -3352,15 +3416,44 @@ export interface CommentEditHistoryItem {
created_at: string; created_at: string;
} }
/** 管理:评论编辑历史(分页;默认每页 10 条) */ /** 评论编辑历史(登录可见;分页;默认每页 10 条) */
export async function apiAdminCommentHistory( export async function apiCommentHistory(
commentId: number, commentId: number,
page = 1, page = 1,
size = 10 size = 10
): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> { ): Promise<{ items: CommentEditHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) }); const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh( const res = await fetchWithRefresh(
`/api/admin/content/comments/${commentId}/history?${params}`, `/api/comments/${commentId}/history?${params}`,
{ headers: clientHeaders() }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
return data;
}
export interface PostEditHistoryItem {
id: number;
editor: {
id: number;
username: string;
nickname: string;
avatar: string;
};
old_title: string;
old_content: string;
created_at: string;
}
/** 帖子编辑历史(登录可见;分页;默认每页 10 条) */
export async function apiPostHistory(
postId: number,
page = 1,
size = 10
): Promise<{ items: PostEditHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(
`/api/posts/${postId}/history?${params}`,
{ headers: clientHeaders() } { headers: clientHeaders() }
); );
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));

View File

@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { smartDiff, toFoldedRows } from "./smartDiff.ts";
/** 把结果压成便于断言的短格式:每行 "op:text" */
function flat(text: string, other: string): string[] {
const r = smartDiff(text, other);
return r.blocks.flatMap((b) => b.lines.map((l) => `${l.op}:${l.text}`));
}
describe("smartDiff", () => {
it("场景1:整段中文说明 → 整段 R 代码,判定 block-replace 而非碎片对齐", () => {
// 两段各留一个空行作为「诱饵公共行」——朴素 LCS 会围绕它做对齐
const oldText = [
"本函数用于处理用户上传的数据。",
"",
"首先校验输入的合法性,",
"然后将数据逐行写入临时表,",
"最后清理缓存并返回结果。",
].join("\n");
const newText = [
"process_data <- function(df) {",
"",
" stopifnot(!is.null(df))",
" out <- transform(df, total = sum(x))",
' write.csv(out, "out.csv")',
" invisible(out)",
"}",
].join("\n");
const r = smartDiff(oldText, newText);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "block-replace");
// 大删除块在前、大新增块在后,不出现 -/+ 交替碎片
const ops = r.blocks[0].lines.map((l) => l.op);
assert.ok(ops.slice(0, 5).every((o) => o === "del"));
assert.ok(ops.slice(5).every((o) => o === "ins"));
assert.equal(r.stats.dels, 5);
assert.equal(r.stats.adds, 7);
// 整段替换不做行内细分
assert.ok(r.blocks[0].lines.every((l) => l.segments === undefined));
});
it("场景2:单行内改一个词,输出词级 segments 而非整行标红标绿", () => {
const oldText = "const total = countItems(list);";
const newText = "const total = countUsers(list);";
const r = smartDiff(oldText, newText);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "replace");
const del = r.blocks[0].lines[0];
const ins = r.blocks[0].lines[1];
assert.ok(del.segments && del.segments.length > 0, "del 行应有 segments");
assert.ok(ins.segments && ins.segments.length > 0, "ins 行应有 segments");
// 删除行上被标记的正是 countItems,新增行上是 countUsers
const delMarked = del.segments!.map((s) => del.text.slice(s.start, s.end)).join("");
const insMarked = ins.segments!.map((s) => ins.text.slice(s.start, s.end)).join("");
assert.equal(delMarked, "countItems");
assert.equal(insMarked, "countUsers");
// 片段不应包含未变化的部分
for (const s of del.segments!) {
assert.ok(!del.text.slice(s.start, s.end).includes("const"));
}
});
it("场景3:仅行尾空白变化,不产生假改动", () => {
const oldText = "第一行 \n第二行\t";
const newText = "第一行\n第二行";
const r = smartDiff(oldText, newText);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "equal");
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
// 展示保留原文(含行尾空白),但比较视为相同
assert.equal(r.blocks[0].lines[0].text, "第一行 ");
});
it("场景4:大量重复空行/花括号的代码,对齐不错位、变更块不碎", () => {
const oldText = [
"function a() {",
"",
" if (x) {",
"",
" return 1;",
"",
" }",
"",
"}",
].join("\n");
const newText = [
"function a() {",
"",
" if (y) {",
"",
" return 2;",
"",
" }",
"",
"}",
].join("\n");
const r = smartDiff(oldText, newText);
// 期望:equal → replace(if 行) → equal → replace(return 行) → equal
assert.deepEqual(
r.blocks.map((b) => b.type),
["equal", "replace", "equal", "replace", "equal"]
);
// 两个 replace 块均有行内细分,且只标记真正变化的字符
const [b1, b2] = [r.blocks[1], r.blocks[3]];
assert.equal(b1.lines[0].text, " if (x) {");
assert.equal(
b1.lines[0].segments!.map((s) => b1.lines[0].text.slice(s.start, s.end)).join(""),
"x"
);
assert.equal(
b1.lines[1].segments!.map((s) => b1.lines[1].text.slice(s.start, s.end)).join(""),
"y"
);
assert.equal(
b2.lines[0].segments!.map((s) => b2.lines[0].text.slice(s.start, s.end)).join(""),
"1"
);
// 空行不进入任何变更块
assert.ok(r.blocks.every((b) => b.type === "equal" || !b.lines.some((l) => l.text === "")));
});
it("场景5:完全相同的文本,只有一个 equal 块", () => {
const text = "第一行\n第二行\n第三行";
const r = smartDiff(text, text);
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "equal");
assert.equal(r.blocks[0].lines.length, 3);
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
});
it("CRLF / CR 行尾符差异不影响比较", () => {
const r = smartDiff("a\r\nb\rc", "a\nb\nc");
assert.equal(r.blocks.length, 1);
assert.equal(r.blocks[0].type, "equal");
assert.deepEqual(r.stats, { adds: 0, dels: 0 });
});
it("单侧为空:整体删除 / 整体新增", () => {
const ins = smartDiff("", "新的一行\n再来一行");
assert.deepEqual(ins.blocks.map((b) => b.type), ["insert"]);
assert.equal(ins.stats.adds, 2);
const del = smartDiff("旧行一\n旧行二", "");
assert.deepEqual(del.blocks.map((b) => b.type), ["delete"]);
assert.equal(del.stats.dels, 2);
assert.deepEqual(smartDiff("", "").blocks, []);
});
it("后处理不变式:相邻同向块必合并,replace 块内 del 全部在 ins 之前", () => {
const oldText = Array.from({ length: 30 }, (_, i) => `common ${i}`).join("\n");
const newText = oldText
.replace("common 5", "changed 5")
.replace("common 6", "changed 6")
.replace("common 20", "changed 20");
const r = smartDiff(oldText, newText);
// 相邻块不允许同为变更块(equal 必然隔开两个变更区域)
for (let i = 1; i < r.blocks.length; i++) {
const prev = r.blocks[i - 1];
const cur = r.blocks[i];
assert.ok(
!(prev.type !== "equal" && cur.type !== "equal"),
"相邻变更块未被合并"
);
}
for (const b of r.blocks) {
if (b.type !== "replace") continue;
const firstIns = b.lines.findIndex((l) => l.op === "ins");
const lastDel = b.lines.map((l) => l.op).lastIndexOf("del");
assert.ok(lastDel < firstIns, "replace 块内应先删后增");
}
});
it("上下文折叠:长 equal 块折叠为上下文 + 省略标记,头/尾单侧保留", () => {
const lines = Array.from({ length: 40 }, (_, i) => `L${i}`);
const oldText = lines.join("\n");
const newText = [...lines.slice(0, 20), "changed", ...lines.slice(21)].join("\n");
const rows = toFoldedRows(smartDiff(oldText, newText).blocks);
// 头部 equal 块(L0..L19,isHead)只保留尾部 3 行上下文 L17/L18/L19 + 折叠 17 行;
// 尾部 equal 块(L21..L39,isTail)只保留头部 3 行上下文 L21/L22/L23 + 折叠 16 行
const folds = rows.filter((r) => r.kind === "fold") as Array<{ kind: "fold"; count: number }>;
assert.deepEqual(
folds.map((f) => f.count),
[17, 16]
);
// 头部 equal 块(L0..L19,isHead):省略前 17 行 → 保留尾部上下文 L17/L18/L19;
// 尾部 equal 块(L21..L39,isTail):保留头部上下文 L21/L22/L23 → 省略后 16 行
assert.deepEqual(rows.slice(0, 4).map((r) => (r.kind === "fold" ? "fold" : r.text)), [
"fold",
"L17",
"L18",
"L19",
]);
});
it("10 万行大文件在 2 秒内完成", () => {
const n = 100_000;
const oldLines = Array.from({ length: n }, (_, i) => `line-${i}`);
const newLines = [...oldLines];
const changes = 20;
for (let k = 0; k < changes; k++) {
newLines[k * 4_000 + 7] = `line-${k * 4_000 + 7}-changed`;
}
const start = performance.now();
const r = smartDiff(oldLines.join("\n"), newLines.join("\n"));
const elapsed = performance.now() - start;
assert.equal(r.stats.dels, changes);
assert.equal(r.stats.adds, changes);
assert.ok(elapsed < 2_000, `应在 2 秒内完成,实际 ${elapsed.toFixed(0)}ms`);
});
});

527
frontend/lib/smartDiff.ts Normal file
View File

@@ -0,0 +1,527 @@
/**
* smartDiff —— 面向可读性的结构化行级 diff(自研,无第三方依赖)。
*
* 四层处理:
* 1. 预处理:CRLF/CR → LF(仅用于比较与展示),行尾空白在比较时忽略(展示保留原文)。
* 2. 算法选择:行级相似度(difflib ratio 思路:2M/(a+b),M 为多重集交集)低于阈值时
* 判定「整段替换」,输出一个 block-replace,跳过逐行对齐——避免把毫无对应关系的
* 两段文本切成几十对 -/+ 碎片;否则用 Patience diff(锚定双方唯一行,LIS 选链),
* 无锚点/超大规模/超深递归的子区间回退为 LCS 或整块替换。
* 3. 后处理:相邻同向变更合并为一个块(del 在前 ins 在后),消除 LCS 的交叉配对;
* 上下文折叠(默认 3 行,头/尾块只保留单侧上下文)。
* 4. 行内细分:replace 块内按下标配对的行对,先分词(CJK 单字、西文单词、空白、符号),
* 词级 LCS 相似度 > 阈值时输出变更片段的字符偏移(segments),否则整行视为变更。
*
* 输出为结构化块数组,供 diff 视图组件渲染;segments 直接挂在对应行上
* (start/end 为该行文本的 UTF-16 偏移),UI 只高亮变更部分。
*/
export type LineOp = "equal" | "del" | "ins";
export type BlockType = "equal" | "insert" | "delete" | "replace" | "block-replace";
/** 行内变更片段:start/end 为行文本的 UTF-16 偏移,type 为该行视角下的变更方向 */
export interface InlineSegment {
start: number;
end: number;
type: "del" | "ins";
}
export interface DiffLine {
op: LineOp;
/** 原始行文本(保留行尾空白,供展示) */
text: string;
/** 仅 replace 块中被判定为「修改」的行对附带;缺省表示整行变更 */
segments?: InlineSegment[];
}
export interface DiffBlock {
type: BlockType;
lines: DiffLine[];
}
export interface DiffStats {
adds: number;
dels: number;
}
export interface DiffResult {
blocks: DiffBlock[];
stats: DiffStats;
}
export interface SmartDiffOptions {
/** 行级相似度低于该值判定整段替换(默认 0.2;仅任一侧 ≥2 行时生效) */
replaceThreshold?: number;
/** 行对词级相似度高于该值才做行内细分(默认 0.6) */
inlineThreshold?: number;
}
// 行级 LCS 回退的规模上限(超出则该区间整块替换,避免 O(n·m) 爆炸)
const MAX_REGION_CELLS = 400_000;
// 行内词级 LCS 的 token 数上限
const MAX_INLINE_CELLS = 20_000;
// 行对合计字符数超过则跳过行内细分
const MAX_INLINE_CHARS = 4_000;
// Patience 递归深度上限(超过直接整块替换,防御构造性深递归)
const MAX_PATIENCE_DEPTH = 32;
/* ---------------- 第一层:预处理 ---------------- */
interface SplitText {
raw: string[];
/** 归一化后的行(比较用):行尾空白已去除 */
cmp: string[];
}
function splitLines(text: string): SplitText {
const norm = text.replace(/\r\n?/g, "\n");
const raw = norm === "" ? [] : norm.split("\n");
return { raw, cmp: raw.map((l) => l.replace(/\s+$/, "")) };
}
/* ---------------- 第二层:相似度与算法选择 ---------------- */
/** 行级相似度:difflib ratio 思路,2M/(a+b),M 为行多重集交集大小 */
function lineSimilarity(a: string[], b: string[]): number {
if (a.length === 0 && b.length === 0) return 1;
const freq = new Map<string, number>();
for (const l of a) freq.set(l, (freq.get(l) ?? 0) + 1);
let m = 0;
for (const l of b) {
const c = freq.get(l) ?? 0;
if (c > 0) {
m += 1;
freq.set(l, c - 1);
}
}
return (2 * m) / (a.length + b.length);
}
type RawOp = { op: LineOp; ai: number; bi: number };
/**
* Patience diff:在区间内找到「双方都恰好出现一次」的公共行作锚点,
* 对锚点按 i 升序求 j 的最长递增子序列(patience sorting,O(k log k))作骨架,
* 骨架之间的间隙递归;无锚点/超深的区间回退 fallbackRegion。
*/
function patienceDiff(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number,
out: RawOp[],
depth: number
): void {
if (loA >= hiA && loB >= hiB) return;
const countA = new Map<string, number>();
for (let i = loA; i < hiA; i++) countA.set(cmpA[i], (countA.get(cmpA[i]) ?? 0) + 1);
const countB = new Map<string, number>();
for (let j = loB; j < hiB; j++) countB.set(cmpB[j], (countB.get(cmpB[j]) ?? 0) + 1);
const posA = new Map<string, number>();
for (let i = loA; i < hiA; i++) {
if (countA.get(cmpA[i]) === 1) posA.set(cmpA[i], i);
}
const anchors: Array<{ i: number; j: number }> = [];
for (let j = loB; j < hiB; j++) {
if (countB.get(cmpB[j]) === 1) {
const i = posA.get(cmpB[j]);
if (i !== undefined) anchors.push({ i, j });
}
}
if (anchors.length === 0 || depth >= MAX_PATIENCE_DEPTH) {
fallbackRegion(cmpA, cmpB, loA, hiA, loB, hiB, out);
return;
}
anchors.sort((x, y) => x.i - y.i);
const tails: number[] = [];
const prev = new Array<number>(anchors.length).fill(-1);
for (let k = 0; k < anchors.length; k++) {
const j = anchors[k].j;
let lo = 0;
let hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (anchors[tails[mid]].j < j) lo = mid + 1;
else hi = mid;
}
if (lo > 0) prev[k] = tails[lo - 1];
if (lo === tails.length) tails.push(k);
else tails[lo] = k;
}
const chain: number[] = [];
for (let k = tails[tails.length - 1]; k !== -1; k = prev[k]) chain.push(k);
chain.reverse();
let ai = loA;
let bi = loB;
for (const k of chain) {
const { i, j } = anchors[k];
patienceDiff(cmpA, cmpB, ai, i, bi, j, out, depth + 1);
out.push({ op: "equal", ai: i, bi: j });
ai = i + 1;
bi = j + 1;
}
patienceDiff(cmpA, cmpB, ai, hiA, bi, hiB, out, depth + 1);
}
/** 无锚点区间:无公共行或规模超限时整块替换;否则 LCS 细对齐 */
function fallbackRegion(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number,
out: RawOp[]
): void {
const n = hiA - loA;
const m = hiB - loB;
if (n === 0 && m === 0) return;
if (n === 0) {
for (let j = loB; j < hiB; j++) out.push({ op: "ins", ai: loA, bi: j });
return;
}
if (m === 0) {
for (let i = loA; i < hiA; i++) out.push({ op: "del", ai: i, bi: loB });
return;
}
if (n * m > MAX_REGION_CELLS || !hasCommonLine(cmpA, cmpB, loA, hiA, loB, hiB)) {
for (let i = loA; i < hiA; i++) out.push({ op: "del", ai: i, bi: loB });
for (let j = loB; j < hiB; j++) out.push({ op: "ins", ai: hiA, bi: j });
return;
}
lcsRegion(cmpA, cmpB, loA, hiA, loB, hiB, out);
}
function hasCommonLine(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number
): boolean {
const set = new Set<string>();
for (let i = loA; i < hiA; i++) set.add(cmpA[i]);
for (let j = loB; j < hiB; j++) {
if (set.has(cmpB[j])) return true;
}
return false;
}
/** 行级 LCS(规模已由调用方限制),供无锚点但有公共行的区间使用 */
function lcsRegion(
cmpA: string[],
cmpB: string[],
loA: number,
hiA: number,
loB: number,
hiB: number,
out: RawOp[]
): void {
const n = hiA - loA;
const m = hiB - loB;
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
const aLine = cmpA[loA + i];
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
aLine === cmpB[loB + j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
let i = 0;
let j = 0;
while (i < n && j < m) {
if (cmpA[loA + i] === cmpB[loB + j]) {
out.push({ op: "equal", ai: loA + i, bi: loB + j });
i += 1;
j += 1;
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
out.push({ op: "del", ai: loA + i, bi: loB + j });
i += 1;
} else {
out.push({ op: "ins", ai: loA + i, bi: loB + j });
j += 1;
}
}
while (i < n) {
out.push({ op: "del", ai: loA + i, bi: loB + j });
i += 1;
}
while (j < m) {
out.push({ op: "ins", ai: loA + i, bi: loB + j });
j += 1;
}
}
/* ---------------- 第三层:后处理(块构建 + 上下文折叠) ---------------- */
/**
* 把扁平 op 序列整理成块:
* - 连续 equal → equal 块(展示用旧文本原文)
* - 连续非 equal 合并为一个变更区域:del 按原顺序在前、ins 按新顺序在后,
* 两侧都有 → replace(后续做行内细分),单侧 → delete / insert。
* 这一步消除了 LCS 可能出现的 -/+ 交叉碎片(del,ins,del,ins → 一个 replace 块)。
*/
function buildBlocks(ops: RawOp[], rawA: string[], rawB: string[]): DiffBlock[] {
const blocks: DiffBlock[] = [];
let k = 0;
while (k < ops.length) {
if (ops[k].op === "equal") {
const lines: DiffLine[] = [];
while (k < ops.length && ops[k].op === "equal") {
lines.push({ op: "equal", text: rawA[ops[k].ai] });
k += 1;
}
blocks.push({ type: "equal", lines });
continue;
}
const dels: DiffLine[] = [];
const inss: DiffLine[] = [];
while (k < ops.length && ops[k].op !== "equal") {
const o = ops[k];
if (o.op === "del") dels.push({ op: "del", text: rawA[o.ai] });
else inss.push({ op: "ins", text: rawB[o.bi] });
k += 1;
}
const type: BlockType =
dels.length > 0 && inss.length > 0 ? "replace" : dels.length > 0 ? "delete" : "insert";
blocks.push({ type, lines: [...dels, ...inss] });
}
return blocks;
}
export type FoldedRow =
| { kind: "line"; op: LineOp; text: string; segments?: InlineSegment[] }
| { kind: "fold"; count: number };
/**
* 上下文折叠:超过 context*2+2 行的 equal 块只保留变更两侧各 context 行,
* 头/尾 equal 块只保留单侧;返回渲染用的行序列(fold 为折叠标记)。
*/
export function toFoldedRows(blocks: DiffBlock[], context = 3): FoldedRow[] {
const rows: FoldedRow[] = [];
const pushLine = (l: DiffLine) =>
rows.push({ kind: "line", op: l.op, text: l.text, segments: l.segments });
blocks.forEach((b, bi) => {
if (!(b.type === "equal" && b.lines.length > context * 2 + 2)) {
b.lines.forEach(pushLine);
return;
}
const isHead = bi === 0;
const isTail = bi === blocks.length - 1;
if (isHead && isTail) {
// 整个 diff 都是相同内容:不折叠
b.lines.forEach(pushLine);
return;
}
const head = isHead ? 0 : context;
const tail = isTail ? 0 : context;
for (let i = 0; i < head; i++) pushLine(b.lines[i]);
rows.push({ kind: "fold", count: b.lines.length - head - tail });
for (let i = b.lines.length - tail; i < b.lines.length; i++) pushLine(b.lines[i]);
});
return rows;
}
/* ---------------- 第四层:行内细分(词级 diff) ---------------- */
// CJK 逐字成 token(中文无词边界),西文按单词,空白连续,其余单字符
const TOKEN_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]|[A-Za-z0-9_]+|\s+|[^\s]/gu;
interface Tokens {
tokens: string[];
starts: number[];
ends: number[];
}
function tokenize(line: string): Tokens {
const tokens: string[] = [];
const starts: number[] = [];
const ends: number[] = [];
for (const m of line.matchAll(TOKEN_RE)) {
const start = m.index;
if (start === undefined) continue;
tokens.push(m[0]);
starts.push(start);
ends.push(start + m[0].length);
}
return { tokens, starts, ends };
}
function lcsTokenLen(a: string[], b: string[]): number {
const n = a.length;
const m = b.length;
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
a[i] === b[j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
return dp[0];
}
/** X 视角下的变更片段:词级 LCS 中未被匹配的连续 token 合并为一个字符区间 */
function changedSegments(
t: Tokens,
ops: Array<{ op: "equal" | "chg"; idx: number }>,
type: "del" | "ins"
): InlineSegment[] {
const segs: InlineSegment[] = [];
let cur: InlineSegment | null = null;
for (const o of ops) {
if (o.op === "equal") {
cur = null;
continue;
}
const start = t.starts[o.idx];
const end = t.ends[o.idx];
if (cur && cur.end === start) {
cur.end = end;
} else {
cur = { start, end, type };
segs.push(cur);
}
}
return segs;
}
/** 词级 LCS 回溯,产出 X/Y 各自的 equal/chg 序列(idx 为各自 token 下标) */
function tokenOps(x: Tokens, y: Tokens): {
xOps: Array<{ op: "equal" | "chg"; idx: number }>;
yOps: Array<{ op: "equal" | "chg"; idx: number }>;
} {
const n = x.tokens.length;
const m = y.tokens.length;
const width = m + 1;
const dp = new Uint32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i * width + j] =
x.tokens[i] === y.tokens[j]
? dp[(i + 1) * width + (j + 1)] + 1
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)]);
}
}
const xOps: Array<{ op: "equal" | "chg"; idx: number }> = [];
const yOps: Array<{ op: "equal" | "chg"; idx: number }> = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (x.tokens[i] === y.tokens[j]) {
xOps.push({ op: "equal", idx: i });
yOps.push({ op: "equal", idx: j });
i += 1;
j += 1;
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
xOps.push({ op: "chg", idx: i });
i += 1;
} else {
yOps.push({ op: "chg", idx: j });
j += 1;
}
}
while (i < n) {
xOps.push({ op: "chg", idx: i });
i += 1;
}
while (j < m) {
yOps.push({ op: "chg", idx: j });
j += 1;
}
return { xOps, yOps };
}
/** 对 replace 块内按下标配对的行对做词级细分;相似度不足则整行变更、不产出 segments */
function annotateInline(blocks: DiffBlock[], inlineThreshold: number): void {
for (const b of blocks) {
if (b.type !== "replace") continue;
const dels = b.lines.filter((l) => l.op === "del");
const inss = b.lines.filter((l) => l.op === "ins");
const n = Math.min(dels.length, inss.length);
for (let i = 0; i < n; i++) {
const delLine = dels[i];
const insLine = inss[i];
if (delLine.text.length + insLine.text.length > MAX_INLINE_CHARS) continue;
const x = tokenize(delLine.text);
const y = tokenize(insLine.text);
if (x.tokens.length === 0 || y.tokens.length === 0) continue;
if (x.tokens.length * y.tokens.length > MAX_INLINE_CELLS) continue;
const ratio = (2 * lcsTokenLen(x.tokens, y.tokens)) / (x.tokens.length + y.tokens.length);
if (!(ratio > inlineThreshold)) continue;
const { xOps, yOps } = tokenOps(x, y);
const delSegs = changedSegments(x, xOps, "del");
const insSegs = changedSegments(y, yOps, "ins");
if (delSegs.length > 0) delLine.segments = delSegs;
if (insSegs.length > 0) insLine.segments = insSegs;
}
}
}
/* ---------------- 入口 ---------------- */
export function smartDiff(
oldText: string,
newText: string,
options: SmartDiffOptions = {}
): DiffResult {
const replaceThreshold = options.replaceThreshold ?? 0.2;
const inlineThreshold = options.inlineThreshold ?? 0.6;
const a = splitLines(oldText);
const b = splitLines(newText);
const ops: RawOp[] = [];
let wholeReplace = false;
if (a.cmp.length === 0 && b.cmp.length === 0) {
// 两侧皆空:无块
} else if (a.cmp.length === 0 || b.cmp.length === 0) {
// 单侧为空:整体删除/新增
if (a.cmp.length > 0) {
for (let i = 0; i < a.cmp.length; i++) ops.push({ op: "del", ai: i, bi: 0 });
} else {
for (let j = 0; j < b.cmp.length; j++) ops.push({ op: "ins", ai: 0, bi: j });
}
} else if (
a.cmp.length >= 2 &&
b.cmp.length >= 2 &&
lineSimilarity(a.cmp, b.cmp) < replaceThreshold
) {
// 整段替换:跳过逐行对齐,输出一个大删除块 + 一个大新增块
wholeReplace = true;
for (let i = 0; i < a.cmp.length; i++) ops.push({ op: "del", ai: i, bi: 0 });
for (let j = 0; j < b.cmp.length; j++) ops.push({ op: "ins", ai: 0, bi: j });
} else {
patienceDiff(a.cmp, b.cmp, 0, a.cmp.length, 0, b.cmp.length, ops, 0);
}
const blocks = buildBlocks(ops, a.raw, b.raw);
if (wholeReplace) {
// 相似度低于阈值判定为整段替换:唯一变更块标记为 block-replace,
// 不做行内细分(两侧内容无对应关系,细对齐只会产出噪音)
const blk = blocks.find((x) => x.type === "replace");
if (blk) blk.type = "block-replace";
}
annotateInline(blocks, inlineThreshold);
let dels = 0;
let adds = 0;
for (const blk of blocks) {
for (const l of blk.lines) {
if (l.op === "del") dels += 1;
else if (l.op === "ins") adds += 1;
}
}
return { blocks, stats: { adds, dels } };
}

View File

@@ -7,7 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start -p 3000", "start": "next start -p 3000",
"lint": "next lint", "lint": "next lint",
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/widgetCode.test.ts" "test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts"
}, },
"dependencies": { "dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6", "hast-util-to-jsx-runtime": "^2.3.6",