优化了评论分页加载的列表显示
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 15s

This commit is contained in:
2025-09-10 14:47:45 +08:00
parent eb66a51051
commit a7da023b1e
14 changed files with 404 additions and 294 deletions

View File

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/common/utils"
"github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/service" "github.com/snowykami/neo-blog/internal/service"
@ -30,13 +31,13 @@ func (cc *CommentController) CreateComment(ctx context.Context, c *app.RequestCo
resps.BadRequest(c, err.Error()) resps.BadRequest(c, err.Error())
return return
} }
err := cc.service.CreateComment(ctx, &req) commentID, err := cc.service.CreateComment(ctx, &req)
if err != nil { if err != nil {
serviceErr := errs.AsServiceError(err) serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return return
} }
resps.Ok(c, resps.Success, nil) resps.Ok(c, resps.Success, utils.H{"id": commentID})
} }
func (cc *CommentController) UpdateComment(ctx context.Context, c *app.RequestContext) { func (cc *CommentController) UpdateComment(ctx context.Context, c *app.RequestContext) {

View File

@ -2,6 +2,9 @@ package v1
import ( import (
"context" "context"
"slices"
"strings"
"github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/common/utils" "github.com/cloudwego/hertz/pkg/common/utils"
"github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/ctxutils"
@ -10,8 +13,6 @@ import (
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps" "github.com/snowykami/neo-blog/pkg/resps"
"slices"
"strings"
) )
type PostController struct { type PostController struct {

View File

@ -69,7 +69,7 @@ func (u *UserController) Logout(ctx context.Context, c *app.RequestContext) {
ctxutils.ClearTokenAndRefreshTokenCookie(c) ctxutils.ClearTokenAndRefreshTokenCookie(c)
resps.Ok(c, resps.Success, nil) resps.Ok(c, resps.Success, nil)
// 尝试吊销服务端状态:若用户登录的情况下 // 尝试吊销服务端状态:若用户登录的情况下
// TODO: 这里可以添加服务端状态的吊销逻辑 // TODO: 添加服务端状态的吊销逻辑
} }
func (u *UserController) OidcList(ctx context.Context, c *app.RequestContext) { func (u *UserController) OidcList(ctx context.Context, c *app.RequestContext) {
@ -176,3 +176,11 @@ func (u *UserController) VerifyEmail(ctx context.Context, c *app.RequestContext)
} }
resps.Ok(c, resps.Success, resp) resps.Ok(c, resps.Success, resp)
} }
func (u *UserController) ChangePassword(ctx context.Context, c *app.RequestContext) {
// TODO: 实现修改密码功能
}
func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) {
// TODO: 实现修改邮箱功能
}

View File

@ -1,6 +1,8 @@
package model package model
import "gorm.io/gorm" import (
"gorm.io/gorm"
)
type Comment struct { type Comment struct {
gorm.Model gorm.Model

View File

@ -1,16 +1,16 @@
package repo package repo
import ( import (
"errors" "errors"
"net/http" "net/http"
"slices" "slices"
"strconv" "strconv"
"github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/utils" "github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
type CommentRepo struct { type CommentRepo struct {
@ -21,195 +21,237 @@ var Comment = &CommentRepo{}
// 检查设置父评论是否会造成循环引用 // 检查设置父评论是否会造成循环引用
// 它通过向上遍历潜在父评论的所有祖先来实现 // 它通过向上遍历潜在父评论的所有祖先来实现
func (cr *CommentRepo) isCircularReference(tx *gorm.DB, commentID, parentID uint) (bool, error) { func (cr *CommentRepo) isCircularReference(tx *gorm.DB, commentID, parentID uint) (bool, error) {
// 如果没有父评论,则不可能有循环 // 如果没有父评论,则不可能有循环
if parentID == 0 { if parentID == 0 {
return false, nil return false, nil
} }
currentID := parentID currentID := parentID
for currentID != 0 { for currentID != 0 {
// 如果在向上追溯的过程中找到了自己的ID说明存在循环 // 如果在向上追溯的过程中找到了自己的ID说明存在循环
if currentID == commentID { if currentID == commentID {
return true, nil return true, nil
} }
var parent model.Comment var parent model.Comment
if err := tx.Where("id = ?", currentID).First(&parent).Error; err != nil { if err := tx.Where("id = ?", currentID).First(&parent).Error; err != nil {
// 如果祖先链中的某个评论不存在,说明链已经断开,不可能形成循环 // 如果祖先链中的某个评论不存在,说明链已经断开,不可能形成循环
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil return false, nil
} }
return false, err return false, err
} }
// 继续向上追溯 // 继续向上追溯
currentID = parent.ReplyID currentID = parent.ReplyID
} }
// 已经追溯到树的根节点,没有发现循环 // 已经追溯到树的根节点,没有发现循环
return false, nil return false, nil
} }
// 递归删除子评论的辅助函数 // 递归删除子评论的辅助函数
func (cr *CommentRepo) deleteChildren(tx *gorm.DB, parentID uint) error { func (cr *CommentRepo) deleteChildren(tx *gorm.DB, parentID uint) error {
var children []*model.Comment var children []*model.Comment
// 1. 找到所有直接子评论 // 1. 找到所有直接子评论
if err := tx.Where("reply_id = ?", parentID).Find(&children).Error; err != nil { if err := tx.Where("reply_id = ?", parentID).Find(&children).Error; err != nil {
return err return err
} }
// 2. 对每一个子评论,递归删除它的子评论 // 2. 对每一个子评论,递归删除它的子评论
for _, child := range children { for _, child := range children {
if err := cr.deleteChildren(tx, child.ID); err != nil { if err := cr.deleteChildren(tx, child.ID); err != nil {
return err return err
} }
} }
// 3. 删除当前层级的子评论 // 3. 删除当前层级的子评论
if err := tx.Where("reply_id = ?", parentID).Delete(&model.Comment{}).Error; err != nil { if err := tx.Where("reply_id = ?", parentID).Delete(&model.Comment{}).Error; err != nil {
return err return err
} }
return nil return nil
} }
func (cr *CommentRepo) CreateComment(comment *model.Comment) error { func (cr *CommentRepo) CreateComment(comment *model.Comment) (uint, error) {
err := GetDB().Transaction(func(tx *gorm.DB) error { var commentID uint
depth := 0 err := GetDB().Transaction(func(tx *gorm.DB) error {
if comment.ReplyID != 0 { depth := 0
isCircular, err := cr.isCircularReference(tx, comment.ID, comment.ReplyID) if comment.ReplyID != 0 {
if err != nil { isCircular, err := cr.isCircularReference(tx, comment.ID, comment.ReplyID)
return err // 检查过程中发生数据库错误 if err != nil {
} return err
if isCircular { }
return errs.New(http.StatusBadRequest, "circular reference detected in comment tree", nil) if isCircular {
} return errs.New(http.StatusBadRequest, "circular reference detected in comment tree", nil)
var parentComment model.Comment }
if err := tx.Where("id = ?", comment.ReplyID).First(&parentComment).Error; err != nil { var parentComment model.Comment
return err if err := tx.Where("id = ?", comment.ReplyID).First(&parentComment).Error; err != nil {
} return err
parentComment.CommentCount += 1 }
if err := tx.Model(&parentComment).UpdateColumn("CommentCount", parentComment.CommentCount).Error; err != nil { parentComment.CommentCount += 1
return err if err := tx.Model(&parentComment).UpdateColumn("CommentCount", parentComment.CommentCount).Error; err != nil {
} return err
depth = parentComment.Depth + 1 }
} depth = parentComment.Depth + 1
if depth > utils.Env.GetAsInt(constant.EnvKeyMaxReplyDepth, constant.MaxReplyDepthDefault) { }
return errs.New(http.StatusBadRequest, "exceeded maximum reply depth", nil) if depth > utils.Env.GetAsInt(constant.EnvKeyMaxReplyDepth, constant.MaxReplyDepthDefault) {
} return errs.New(http.StatusBadRequest, "exceeded maximum reply depth", nil)
comment.Depth = depth }
if err := tx.Create(comment).Error; err != nil { comment.Depth = depth
return err if err := tx.Create(comment).Error; err != nil {
} return err
return nil }
}) commentID = comment.ID // 记录主键
return err switch comment.TargetType {
case constant.TargetTypePost:
var count int64
if err := tx.Model(&model.Comment{}).
Where("target_id = ? AND target_type = ?", comment.TargetID, constant.TargetTypePost).
Count(&count).Error; err != nil {
return err
}
if err := tx.Model(&model.Post{}).Where("id = ?", comment.TargetID).
UpdateColumn("comment_count", count).Error; err != nil {
return err
}
default:
return errs.New(http.StatusBadRequest, "unsupported target type: "+comment.TargetType, nil)
}
return nil
})
return commentID, err
} }
func (cr *CommentRepo) UpdateComment(comment *model.Comment) error { func (cr *CommentRepo) UpdateComment(comment *model.Comment) error {
if comment.ID == 0 { if comment.ID == 0 {
return errs.New(http.StatusBadRequest, "invalid comment ID", nil) return errs.New(http.StatusBadRequest, "invalid comment ID", nil)
} }
if err := GetDB().Select("IsPrivate", "Content").Updates(comment).Error; err != nil { if err := GetDB().Select("IsPrivate", "Content").Updates(comment).Error; err != nil {
return err return err
} }
return nil return nil
} }
func (cr *CommentRepo) DeleteComment(commentID string) error { func (cr *CommentRepo) DeleteComment(commentID string) error {
if commentID == "" { if commentID == "" {
return errs.New(http.StatusBadRequest, "invalid comment ID", nil) return errs.New(http.StatusBadRequest, "invalid comment ID", nil)
} }
err := GetDB().Transaction(func(tx *gorm.DB) error { err := GetDB().Transaction(func(tx *gorm.DB) error {
var comment model.Comment var comment model.Comment
// 1. 查找主评论 // 1. 查找主评论
if err := tx.Where("id = ?", commentID).First(&comment).Error; err != nil { if err := tx.Where("id = ?", commentID).First(&comment).Error; err != nil {
return err return err
} }
// 2. 删除子评论 // 2. 删除子评论
if err := cr.deleteChildren(tx, comment.ID); err != nil { if err := cr.deleteChildren(tx, comment.ID); err != nil {
return err return err
} }
// 3. 删除主评论 // 3. 删除主评论
if err := tx.Delete(&comment).Error; err != nil { if err := tx.Delete(&comment).Error; err != nil {
return err return err
} }
// 4. 更新父评论的回复计数 // 4. 更新父评论的回复计数
if comment.ReplyID != 0 { if comment.ReplyID != 0 {
var parent model.Comment var parent model.Comment
if err := tx.Where("id = ?", comment.ReplyID).First(&parent).Error; err != nil { if err := tx.Where("id = ?", comment.ReplyID).First(&parent).Error; err != nil {
return err return err
} }
parent.CommentCount -= 1 parent.CommentCount -= 1
if err := tx.Save(&parent).Error; err != nil { if err := tx.Save(&parent).Error; err != nil {
return err return err
} }
} }
return nil // 5. 更新目标的评论数量
}) switch comment.TargetType {
case constant.TargetTypePost:
var count int64
if err := tx.Model(&model.Comment{}).
Where("target_id = ? AND target_type = ?", comment.TargetID, constant.TargetTypePost).
Count(&count).Error; err != nil {
return err
}
if err := tx.Model(&model.Post{}).Where("id = ?", comment.TargetID).
UpdateColumn("comment_count", count).Error; err != nil {
return err
}
default:
return errs.New(http.StatusBadRequest, "unsupported target type: "+comment.TargetType, nil)
}
if err != nil { return nil
return err })
}
return nil if err != nil {
return err
}
return nil
} }
func (cr *CommentRepo) GetComment(commentID string) (*model.Comment, error) { func (cr *CommentRepo) GetComment(commentID string) (*model.Comment, error) {
var comment model.Comment var comment model.Comment
if err := GetDB().Where("id = ?", commentID).Preload("User").First(&comment).Error; err != nil { if err := GetDB().Where("id = ?", commentID).Preload("User").First(&comment).Error; err != nil {
return nil, err return nil, err
} }
return &comment, nil return &comment, nil
} }
func (cr *CommentRepo) ListComments(currentUserID, targetID, commentID uint, targetType string, page, size uint64, orderBy string, desc bool, depth int) ([]model.Comment, error) { func (cr *CommentRepo) ListComments(currentUserID, targetID, commentID uint, targetType string, page, size uint64, orderBy string, desc bool, depth int) ([]model.Comment, error) {
if !slices.Contains(constant.OrderByEnumComment, orderBy) { if !slices.Contains(constant.OrderByEnumComment, orderBy) {
return nil, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil) return nil, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil)
} }
var masterID uint var masterID uint
if targetType == constant.TargetTypePost { if targetType == constant.TargetTypePost {
post, err := Post.GetPostByID(strconv.Itoa(int(targetID))) post, err := Post.GetPostByID(strconv.Itoa(int(targetID)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
masterID = post.UserID masterID = post.UserID
} }
query := GetDB().Model(&model.Comment{}).Preload("User") query := GetDB().Model(&model.Comment{}).Preload("User")
if commentID > 0 { if commentID > 0 {
query = query.Where("reply_id = ?", commentID) query = query.Where("reply_id = ?", commentID)
} }
if currentUserID > 0 { if currentUserID > 0 {
query = query.Where("(is_private = ? OR (is_private = ? AND (user_id = ? OR user_id = ?)))", false, true, currentUserID, masterID) query = query.Where("(is_private = ? OR (is_private = ? AND (user_id = ? OR user_id = ?)))", false, true, currentUserID, masterID)
} else { } else {
query = query.Where("is_private = ?", false) query = query.Where("is_private = ?", false)
} }
if depth >= 0 { if depth >= 0 {
query = query.Where("target_id = ? AND target_type = ? AND depth = ?", targetID, targetType, depth) query = query.Where("target_id = ? AND target_type = ? AND depth = ?", targetID, targetType, depth)
} else { } else {
query = query.Where("target_id = ? AND target_type = ?", targetID, targetType) query = query.Where("target_id = ? AND target_type = ?", targetID, targetType)
} }
items, _, err := PaginateQuery[model.Comment](query, page, size, orderBy, desc) items, _, err := PaginateQuery[model.Comment](query, page, size, orderBy, desc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return items, nil return items, nil
}
func (cr *CommentRepo) CountComments(targetType string, targetID uint) (int64, error) {
var count int64
err := GetDB().Model(&model.Comment{}).Where("target_id = ? AND target_type = ?", targetID, targetType).Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
} }

View File

@ -1,12 +1,12 @@
package repo package repo
import ( import (
"errors" "errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"gorm.io/gorm" "gorm.io/gorm"
) )
type likeRepo struct{} type likeRepo struct{}
@ -14,96 +14,96 @@ type likeRepo struct{}
var Like = &likeRepo{} var Like = &likeRepo{}
func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) (bool, error) { func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) (bool, error) {
err := l.checkTargetType(targetType) err := l.checkTargetType(targetType)
if err != nil { if err != nil {
return false, err return false, err
} }
var finalStatus bool var finalStatus bool
err = GetDB().Transaction(func(tx *gorm.DB) error { err = GetDB().Transaction(func(tx *gorm.DB) error {
isLiked, err := l.IsLiked(userID, targetID, targetType) isLiked, err := l.IsLiked(userID, targetID, targetType)
if err != nil { if err != nil {
logrus.Error(err) logrus.Error(err)
return err return err
} }
if isLiked { if isLiked {
if err := if err :=
tx.Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID). tx.Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).
Delete(&model.Like{TargetType: targetType, TargetID: targetID, UserID: userID}). Delete(&model.Like{TargetType: targetType, TargetID: targetID, UserID: userID}).
Error; err != nil { Error; err != nil {
logrus.Error(err) logrus.Error(err)
return err return err
} }
finalStatus = false finalStatus = false
} else { } else {
like := &model.Like{ like := &model.Like{
TargetType: targetType, TargetType: targetType,
TargetID: targetID, TargetID: targetID,
UserID: userID, UserID: userID,
} }
if err := tx.Create(like).Error; err != nil { if err := tx.Create(like).Error; err != nil {
return err return err
} }
finalStatus = true finalStatus = true
} }
// 重新计算点赞数量 // 重新计算点赞数量
var count int64 var count int64
if err := tx.Model(&model.Like{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&count).Error; err != nil { if err := tx.Model(&model.Like{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&count).Error; err != nil {
return err return err
} }
// 更新目标的点赞数量 // 更新目标的点赞数量
//switch targetType { //switch targetType {
//case constant.TargetTypePost: //case constant.TargetTypePost:
// if err := tx.Model(&model.Post{}).Where("id = ?", targetID).UpdateColumn("like_count", count).Error; err != nil { // if err := tx.Model(&model.Post{}).Where("id = ?", targetID).UpdateColumn("like_count", count).Error; err != nil {
// return err // return err
// } // }
//case constant.TargetTypeComment: //case constant.TargetTypeComment:
// if err := tx.Model(&model.Comment{}).Where("id = ?", targetID).UpdateColumn("like_count", count).Error; err != nil { // if err := tx.Model(&model.Comment{}).Where("id = ?", targetID).UpdateColumn("like_count", count).Error; err != nil {
// return err // return err
// } // }
//default: //default:
// return errors.New("invalid target type") // return errors.New("invalid target type")
//} //}
return nil return nil
}) })
return finalStatus, err return finalStatus, err
} }
// IsLiked 检查是否点赞 // IsLiked 检查是否点赞
func (l *likeRepo) IsLiked(userID, targetID uint, targetType string) (bool, error) { func (l *likeRepo) IsLiked(userID, targetID uint, targetType string) (bool, error) {
err := l.checkTargetType(targetType) err := l.checkTargetType(targetType)
if err != nil { if err != nil {
return false, err return false, err
} }
var like model.Like var like model.Like
err = GetDB().Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).First(&like).Error err = GetDB().Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).First(&like).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil return false, nil
} }
return false, err return false, err
} }
return true, nil return true, nil
} }
// Count 点赞计数 // Count 点赞计数
func (l *likeRepo) Count(targetID uint, targetType string) (int64, error) { func (l *likeRepo) Count(targetID uint, targetType string) (int64, error) {
err := l.checkTargetType(targetType) err := l.checkTargetType(targetType)
if err != nil { if err != nil {
return 0, err return 0, err
} }
var count int64 var count int64
err = GetDB().Model(&model.Like{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&count).Error err = GetDB().Model(&model.Like{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&count).Error
if err != nil { if err != nil {
return 0, err return 0, err
} }
return count, nil return count, nil
} }
func (l *likeRepo) checkTargetType(targetType string) error { func (l *likeRepo) checkTargetType(targetType string) error {
switch targetType { switch targetType {
case constant.TargetTypePost, constant.TargetTypeComment: case constant.TargetTypePost, constant.TargetTypeComment:
return nil return nil
default: default:
return errors.New("invalid target type") return errors.New("invalid target type")
} }
} }

View File

@ -1,25 +1,27 @@
package apiv1 package apiv1
import ( import (
"github.com/cloudwego/hertz/pkg/route" "github.com/cloudwego/hertz/pkg/route"
"github.com/snowykami/neo-blog/internal/controller/v1" "github.com/snowykami/neo-blog/internal/controller/v1"
"github.com/snowykami/neo-blog/internal/middleware" "github.com/snowykami/neo-blog/internal/middleware"
) )
func registerUserRoutes(group *route.RouterGroup) { func registerUserRoutes(group *route.RouterGroup) {
userController := v1.NewUserController() userController := v1.NewUserController()
userGroup := group.Group("/user").Use(middleware.UseAuth(true)) userGroup := group.Group("/user").Use(middleware.UseAuth(true))
userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false)) userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false))
userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha()) userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha())
{ {
userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login) userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login)
userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register) userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register)
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList) userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin) userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
userGroupWithoutAuth.GET("/u/:id", userController.GetUser) userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
userGroup.GET("/me", userController.GetUser) userGroup.GET("/me", userController.GetUser)
userGroupWithoutAuth.POST("/logout", userController.Logout) userGroupWithoutAuth.POST("/logout", userController.Logout)
userGroup.PUT("/u/:id", userController.UpdateUser) userGroup.PUT("/u/:id", userController.UpdateUser)
} userGroup.PUT("/password/edit", userController.ChangePassword)
userGroup.PUT("/email/edit", userController.ChangeEmail)
}
} }

View File

@ -19,17 +19,17 @@ func NewCommentService() *CommentService {
return &CommentService{} return &CommentService{}
} }
func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) error { func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) (uint, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx) currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok { if !ok {
return errs.ErrUnauthorized return 0, errs.ErrUnauthorized
} }
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok { if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
if err != nil { if err != nil {
return errs.New(errs.ErrBadRequest.Code, "target not found", err) return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err)
} }
return errs.ErrBadRequest return 0, errs.ErrBadRequest
} }
comment := &model.Comment{ comment := &model.Comment{
@ -41,13 +41,13 @@ func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateComm
IsPrivate: req.IsPrivate, IsPrivate: req.IsPrivate,
} }
err := repo.Comment.CreateComment(comment) commentID, err := repo.Comment.CreateComment(comment)
if err != nil { if err != nil {
return err return 0, err
} }
return nil return commentID, nil
} }
func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error { func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error {

View File

@ -20,8 +20,8 @@ export async function createComment(
replyId: number | null replyId: number | null
isPrivate: boolean isPrivate: boolean
} }
): Promise<BaseResponse<Comment>> { ): Promise<BaseResponse<{id: number}>> {
const res = await axiosClient.post<BaseResponse<Comment>>('/comment/c', { const res = await axiosClient.post<BaseResponse<{id: number}>>('/comment/c', {
targetType, targetType,
targetId, targetId,
content, content,
@ -52,7 +52,6 @@ export async function deleteComment({ id }: { id: number }): Promise<void> {
await axiosClient.delete(`/comment/c/${id}`) await axiosClient.delete(`/comment/c/${id}`)
} }
export async function listComments({ export async function listComments({
targetType, targetType,
targetId, targetId,
@ -83,3 +82,8 @@ export async function listComments({
}) })
return res.data return res.data
} }
export async function getComment({ id }: { id: number }): Promise<BaseResponse<Comment>> {
const res = await axiosClient.get<BaseResponse<Comment>>(`/comment/c/${id}`)
return res.data
}

View File

@ -4,8 +4,8 @@ import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, Square
import { RenderMarkdown } from "@/components/common/markdown"; import { RenderMarkdown } from "@/components/common/markdown";
import { isMobileByUA } from "@/utils/server/device"; import { isMobileByUA } from "@/utils/server/device";
import { calculateReadingTime } from "@/utils/common/post"; import { calculateReadingTime } from "@/utils/common/post";
import {CommentSection} from "@/components/comment"; import { CommentSection } from "@/components/comment";
import { TargetType } from '../../models/types'; import { TargetType } from '@/models/types';
function PostMeta({ post }: { post: Post }) { function PostMeta({ post }: { post: Post }) {
return ( return (
@ -139,7 +139,7 @@ async function BlogPost({ post }: { post: Post }) {
{/* <ScrollToTop /> */} {/* <ScrollToTop /> */}
<PostHeader post={post} /> <PostHeader post={post} />
<PostContent post={post} /> <PostContent post={post} />
<CommentSection targetType={TargetType.Post} targetId={post.id} /> <CommentSection targetType={TargetType.Post} targetId={post.id} totalCount={post.commentCount} />
</div> </div>
); );
} }

View File

@ -12,7 +12,6 @@ import { useDoubleConfirm } from "@/hooks/use-double-confirm";
import { CommentInput } from "./comment-input"; import { CommentInput } from "./comment-input";
import { createComment, deleteComment, listComments, updateComment } from "@/api/comment"; import { createComment, deleteComment, listComments, updateComment } from "@/api/comment";
import { OrderBy } from "@/models/common"; import { OrderBy } from "@/models/common";
import config from "@/config";
import { formatDateTime } from "@/utils/common/datetime"; import { formatDateTime } from "@/utils/common/datetime";
@ -23,7 +22,8 @@ export function CommentItem(
parentComment, parentComment,
onCommentDelete, onCommentDelete,
activeInput, activeInput,
setActiveInputId setActiveInputId,
onReplySubmitted // 评论区计数更新用
}: { }: {
user: User | null, user: User | null,
comment: Comment, comment: Comment,
@ -31,6 +31,7 @@ export function CommentItem(
onCommentDelete: ({ commentId }: { commentId: number }) => void, onCommentDelete: ({ commentId }: { commentId: number }) => void,
activeInput: { id: number; type: 'reply' | 'edit' } | null, activeInput: { id: number; type: 'reply' | 'edit' } | null,
setActiveInputId: (input: { id: number; type: 'reply' | 'edit' } | null) => void, setActiveInputId: (input: { id: number; type: 'reply' | 'edit' } | null) => void,
onReplySubmitted: ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => void,
} }
) { ) {
const locale = useLocale(); const locale = useLocale();
@ -96,7 +97,7 @@ export function CommentItem(
orderBy: OrderBy.CreatedAt, orderBy: OrderBy.CreatedAt,
desc: false, desc: false,
page: 1, page: 1,
size: config.commentsPerPage, size: 999999,
commentId: comment.id commentId: comment.id
} }
).then(response => { ).then(response => {
@ -136,6 +137,7 @@ export function CommentItem(
setShowReplies(true); setShowReplies(true);
setActiveInputId(null); setActiveInputId(null);
setReplyCount(replyCount + 1); setReplyCount(replyCount + 1);
onReplySubmitted({ commentContent, isPrivate });
}).catch(error => { }).catch(error => {
toast.error(t("comment_failed") + ": " + toast.error(t("comment_failed") + ": " +
error?.response?.data?.message || error?.message error?.response?.data?.message || error?.message
@ -289,6 +291,7 @@ export function CommentItem(
onCommentDelete={onReplyDelete} onCommentDelete={onReplyDelete}
activeInput={activeInput} activeInput={activeInput}
setActiveInputId={setActiveInputId} setActiveInputId={setActiveInputId}
onReplySubmitted={onReplySubmitted}
/> />
))} ))}
</div> </div>

View File

@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Comment } from "@/models/comment"; import { Comment } from "@/models/comment";
import { createComment, deleteComment, listComments } from "@/api/comment"; import { createComment, deleteComment, getComment, listComments } from "@/api/comment";
import { TargetType } from "@/models/types"; import { TargetType } from "@/models/types";
import { OrderBy } from "@/models/common"; import { OrderBy } from "@/models/common";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@ -22,18 +22,22 @@ import "./style.css";
export function CommentSection( export function CommentSection(
{ {
targetType, targetType,
targetId targetId,
totalCount = 0
}: { }: {
targetType: TargetType, targetType: TargetType,
targetId: number targetId: number,
totalCount?: number
} }
) { ) {
const t = useTranslations('Comment') const t = useTranslations('Comment')
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [refreshCommentsKey, setRefreshCommentsKey] = useState(0);
const [activeInput, setActiveInput] = useState<{ id: number; type: 'reply' | 'edit' } | null>(null); const [activeInput, setActiveInput] = useState<{ id: number; type: 'reply' | 'edit' } | null>(null);
const [page, setPage] = useState(1); // 当前页码
const [totalCommentCount, setTotalCommentCount] = useState(totalCount); // 评论总数
const [needLoadMore, setNeedLoadMore] = useState(true); // 是否需要加载更多当最后一次获取的评论数小于分页大小时设为false
// 获取当前登录用户 // 获取当前登录用户
useEffect(() => { useEffect(() => {
@ -51,13 +55,13 @@ export function CommentSection(
depth: 0, depth: 0,
orderBy: OrderBy.CreatedAt, orderBy: OrderBy.CreatedAt,
desc: true, desc: true,
page: 1, page: page,
size: config.commentsPerPage, size: config.commentsPerPage,
commentId: 0 commentId: 0
}).then(response => { }).then(response => {
setComments(response.data); setComments(response.data);
}); });
}, [refreshCommentsKey]) }, [])
const onCommentSubmitted = ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => { const onCommentSubmitted = ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => {
createComment({ createComment({
@ -66,25 +70,56 @@ export function CommentSection(
content: commentContent, content: commentContent,
replyId: null, replyId: null,
isPrivate, isPrivate,
}).then(() => { }).then(res => {
toast.success(t("comment_success")); toast.success(t("comment_success"));
setRefreshCommentsKey(k => k + 1); setTotalCommentCount(c => c + 1);
setComments(prevComments => prevComments.slice(0, -1));
getComment({ id: res.data.id }).then(response => {
console.log("New comment fetched:", response.data);
setComments(prevComments => [response.data, ...prevComments]);
});
setActiveInput(null);
}) })
} }
const onReplySubmitted = ({ }: { commentContent: string, isPrivate: boolean }) => {
setTotalCommentCount(c => c + 1);
}
const onCommentDelete = ({ commentId }: { commentId: number }) => { const onCommentDelete = ({ commentId }: { commentId: number }) => {
deleteComment({ id: commentId }).then(() => { deleteComment({ id: commentId }).then(() => {
toast.success(t("delete_success")); toast.success(t("delete_success"));
setRefreshCommentsKey(k => k + 1); setComments(prevComments => prevComments.filter(comment => comment.id !== commentId));
setTotalCommentCount(c => c - 1);
}).catch(error => { }).catch(error => {
toast.error(t("delete_failed") + ": " + error.message); toast.error(t("delete_failed") + ": " + error.message);
}); });
} }
const handleLoadMore = () => {
const nextPage = page + 1;
listComments({
targetType,
targetId,
depth: 0,
orderBy: OrderBy.CreatedAt,
desc: true,
page: nextPage,
size: config.commentsPerPage,
commentId: 0
}).then(response => {
if (response.data.length < config.commentsPerPage) {
setNeedLoadMore(false);
}
setComments(prevComments => [...prevComments, ...response.data]);
setPage(nextPage);
});
}
return ( return (
<div> <div>
<Separator className="my-16" /> <Separator className="my-16" />
<div className="font-bold text-2xl">{t("comment")}</div> <div className="font-bold text-2xl">{t("comment")} ({totalCommentCount})</div>
<CommentInput <CommentInput
user={currentUser} user={currentUser}
onCommentSubmitted={onCommentSubmitted} onCommentSubmitted={onCommentSubmitted}
@ -92,7 +127,7 @@ export function CommentSection(
<div className="mt-4"> <div className="mt-4">
<Suspense fallback={<CommentLoading />}> <Suspense fallback={<CommentLoading />}>
{comments.map((comment, idx) => ( {comments.map((comment, idx) => (
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}> <div key={comment.id} className="" style={{ animationDelay: `${idx * 60}ms` }}>
<Separator className="my-2" /> <Separator className="my-2" />
<CommentItem <CommentItem
user={currentUser} user={currentUser}
@ -101,10 +136,20 @@ export function CommentSection(
onCommentDelete={onCommentDelete} onCommentDelete={onCommentDelete}
activeInput={activeInput} activeInput={activeInput}
setActiveInputId={setActiveInput} setActiveInputId={setActiveInput}
onReplySubmitted={onReplySubmitted}
/> />
</div> </div>
))} ))}
</Suspense> </Suspense>
{needLoadMore ?
<p onClick={handleLoadMore} className="text-center text-sm text-gray-500 my-4 cursor-pointer hover:underline">
{t("load_more")}
</p>
:
<p className="text-center text-sm text-gray-500 my-4">
{t("no_more")}
</p>
}
</div> </div>
</div> </div>
) )

View File

@ -15,7 +15,7 @@ const config = {
bodyWidth: "80vw", bodyWidth: "80vw",
bodyWidthMobile: "100vw", bodyWidthMobile: "100vw",
postsPerPage: 12, postsPerPage: 12,
commentsPerPage: 20, commentsPerPage: 8,
footer: { footer: {
text: "Liteyuki ICP备 1145141919810", text: "Liteyuki ICP备 1145141919810",
links: [] links: []

View File

@ -21,7 +21,9 @@
"like": "点赞", "like": "点赞",
"like_failed": "点赞失败", "like_failed": "点赞失败",
"like_success": "点赞成功", "like_success": "点赞成功",
"load_more": "加载更多",
"login_required": "请先登录后再操作", "login_required": "请先登录后再操作",
"no_more": "没有更多了!",
"placeholder": "写下你的评论...", "placeholder": "写下你的评论...",
"private": "私密评论", "private": "私密评论",
"private_placeholder": "悄悄地说一句...", "private_placeholder": "悄悄地说一句...",