feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
@@ -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 站内通知
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
backend/service/content_lock_test.go
Normal file
74
backend/service/content_lock_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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…"
|
||||||
|
|||||||
@@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
433
frontend/components/EditHistoryModal.tsx
Normal file
433
frontend/components/EditHistoryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
37
frontend/components/PostEditedMark.tsx
Normal file
37
frontend/components/PostEditedMark.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(() => ({}));
|
||||||
|
|||||||
214
frontend/lib/smartDiff.test.ts
Normal file
214
frontend/lib/smartDiff.test.ts
Normal 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
527
frontend/lib/smartDiff.ts
Normal 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 } };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user