From e659de23fb5ff096eab2f0ca89197ffde4ebd7a5 Mon Sep 17 00:00:00 2001 From: Snowykami Date: Sat, 26 Jul 2025 09:48:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20feat:=20refactor=20sorting?= =?UTF-8?q?=20parameters=20in=20post=20listing=20API=20and=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed `orderedBy` to `orderBy` and `reverse` to `desc` in ListPostsParams interface and related functions. - Updated all usages of the sorting parameters in the post listing logic to reflect the new naming convention. feat: add user-related API functions - Implemented `getLoginUser` and `getUserById` functions in the user API to fetch user details. - Enhanced user model to include `language` property. feat: integrate next-intl for internationalization - Added `next-intl` plugin to Next.js configuration for improved localization support. - Removed previous i18n implementation and replaced it with a new structure using JSON files for translations. - Created locale files for English, Japanese, and Chinese with basic translations. - Implemented a request configuration to handle user locales and messages dynamically. fix: clean up unused imports and code - Removed unused i18n utility functions and language settings from device context. - Cleaned up commented-out code in blog card component and sidebar. chore: update dependencies - Added `deepmerge` for merging locale messages. - Updated package.json and pnpm-lock.yaml to reflect new dependencies. --- internal/controller/v1/comment.go | 90 +++++++++++ internal/controller/v1/like.go | 15 ++ internal/controller/v1/post.go | 16 +- internal/ctxutils/pagenation.go | 28 ++-- internal/dto/comment.go | 24 +++ internal/dto/post.go | 18 +-- internal/dto/user.go | 1 + internal/middleware/auth.go | 32 ++-- internal/model/comment.go | 1 + internal/model/post.go | 13 +- internal/model/user.go | 2 + internal/repo/comment.go | 32 ++++ internal/repo/init.go | 2 - internal/repo/like.go | 69 +++++---- internal/repo/post.go | 32 ++-- internal/repo/utils.go | 38 +++++ internal/router/apiv1/comment.go | 18 ++- internal/router/apiv1/like.go | 14 ++ internal/router/apiv1/post.go | 4 +- internal/router/apiv1/user.go | 2 +- internal/router/apiv1/v1.go | 1 + internal/router/router.go | 2 + internal/service/comment.go | 35 +++++ internal/service/post.go | 29 +++- internal/tasks/session_clear.go | 2 +- pkg/constant/constant.go | 16 +- web/next.config.ts | 4 +- web/package.json | 4 +- web/pnpm-lock.yaml | 183 +++++++++++++++-------- web/src/api/post.ts | 12 +- web/src/api/user.ts | 14 ++ web/src/app/layout.tsx | 4 +- web/src/components/blog-card.tsx | 26 +--- web/src/components/blog-home-sidebar.tsx | 5 +- web/src/components/blog-home.tsx | 20 +-- web/src/components/sidebar.tsx | 0 web/src/contexts/device-context.tsx | 24 +-- web/src/i18n/request.ts | 47 ++++++ web/src/locales/en.json | 5 + web/src/locales/ja.json | 5 + web/src/locales/zh-CN.json | 5 + web/src/models/user.ts | 1 + web/src/utils/i18n/index.ts | 27 ---- web/src/utils/i18n/locales/en-us.ts | 30 ---- web/src/utils/i18n/locales/index.ts | 9 -- web/src/utils/i18n/locales/zh-cn.ts | 30 ---- 46 files changed, 660 insertions(+), 331 deletions(-) create mode 100644 web/src/components/sidebar.tsx create mode 100644 web/src/i18n/request.ts create mode 100644 web/src/locales/en.json create mode 100644 web/src/locales/ja.json create mode 100644 web/src/locales/zh-CN.json delete mode 100644 web/src/utils/i18n/index.ts delete mode 100644 web/src/utils/i18n/locales/en-us.ts delete mode 100644 web/src/utils/i18n/locales/index.ts delete mode 100644 web/src/utils/i18n/locales/zh-cn.ts diff --git a/internal/controller/v1/comment.go b/internal/controller/v1/comment.go index b7b1f99..b898066 100644 --- a/internal/controller/v1/comment.go +++ b/internal/controller/v1/comment.go @@ -1 +1,91 @@ package v1 + +import ( + "context" + "github.com/cloudwego/hertz/pkg/app" + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/internal/service" + "github.com/snowykami/neo-blog/pkg/errs" + "github.com/snowykami/neo-blog/pkg/resps" + "strconv" +) + +type CommentController struct { + service *service.CommentService +} + +func NewCommentController() *CommentController { + return &CommentController{ + service: service.NewCommentService(), + } +} + +func (cc *CommentController) CreateComment(ctx context.Context, c *app.RequestContext) { + var req dto.CreateCommentReq + if err := c.BindAndValidate(&req); err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + err := cc.service.CreateComment(ctx, &req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, nil) +} + +func (cc *CommentController) UpdateComment(ctx context.Context, c *app.RequestContext) { + var req dto.UpdateCommentReq + if err := c.BindAndValidate(&req); err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + id := c.Param("id") + idInt, err := strconv.Atoi(id) + if err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + req.CommentID = uint(idInt) + + err = cc.service.UpdateComment(ctx, &req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, nil) +} + +func (cc *CommentController) DeleteComment(ctx context.Context, c *app.RequestContext) { + id := c.Param("id") + err := cc.service.DeleteComment(ctx, id) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, nil) +} + +func (cc *CommentController) GetComment(ctx context.Context, c *app.RequestContext) { + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + resp, err := cc.service.GetComment(ctx, id) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, resp) +} + +func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestContext) { + // pagenation := ctxutils.GetPaginationParams(c) +} + +func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) {} diff --git a/internal/controller/v1/like.go b/internal/controller/v1/like.go index b7b1f99..4822a6f 100644 --- a/internal/controller/v1/like.go +++ b/internal/controller/v1/like.go @@ -1 +1,16 @@ package v1 + +import ( + "context" + "github.com/cloudwego/hertz/pkg/app" +) + +type LikeController struct{} + +func NewLikeController() *LikeController { + return &LikeController{} +} + +func (lc *LikeController) ToggleLike(ctx context.Context, c *app.RequestContext) { + // Implementation for creating a like +} diff --git a/internal/controller/v1/post.go b/internal/controller/v1/post.go index 256b568..c7d6779 100644 --- a/internal/controller/v1/post.go +++ b/internal/controller/v1/post.go @@ -96,21 +96,21 @@ func (p *PostController) Update(ctx context.Context, c *app.RequestContext) { func (p *PostController) List(ctx context.Context, c *app.RequestContext) { pagination := ctxutils.GetPaginationParams(c) fmt.Println(pagination) - if pagination.OrderedBy == "" { - pagination.OrderedBy = constant.OrderedByUpdatedAt + if pagination.OrderBy == "" { + pagination.OrderBy = constant.OrderByUpdatedAt } - if pagination.OrderedBy != "" && !slices.Contains(constant.OrderedByEnumPost, pagination.OrderedBy) { + if pagination.OrderBy != "" && !slices.Contains(constant.OrderByEnumPost, pagination.OrderBy) { resps.BadRequest(c, "无效的排序字段") return } keywords := c.Query("keywords") keywordsArray := strings.Split(keywords, ",") req := &dto.ListPostReq{ - Keywords: keywordsArray, - Page: pagination.Page, - Size: pagination.Size, - OrderedBy: pagination.OrderedBy, - Reverse: pagination.Reverse, + Keywords: keywordsArray, + Page: pagination.Page, + Size: pagination.Size, + OrderBy: pagination.OrderBy, + Desc: pagination.Desc, } posts, err := p.service.ListPosts(ctx, req) if err != nil { diff --git a/internal/ctxutils/pagenation.go b/internal/ctxutils/pagenation.go index d8213b0..9fa73f2 100644 --- a/internal/ctxutils/pagenation.go +++ b/internal/ctxutils/pagenation.go @@ -6,28 +6,28 @@ import ( ) type PaginationParams struct { - Page uint64 - Size uint64 - OrderedBy string - Reverse bool // 默认是从大值到小值 + Page uint64 + Size uint64 + OrderBy string + Desc bool // 默认是从大值到小值 } func GetPaginationParams(c *app.RequestContext) *PaginationParams { page := c.Query("page") size := c.Query("size") - orderedBy := c.Query("ordered_by") - reverse := c.Query("reverse") + orderBy := c.Query("order_by") + desc := c.Query("desc") if page == "" { page = "1" } if size == "" { size = "10" } - var reverseBool bool - if reverse == "" || reverse == "false" || reverse == "0" { - reverseBool = false + var descBool bool + if desc == "" || desc == "false" || desc == "0" { + descBool = false } else { - reverseBool = true + descBool = true } pageNum, err := strconv.ParseUint(page, 10, 64) if err != nil || pageNum < 1 { @@ -38,9 +38,9 @@ func GetPaginationParams(c *app.RequestContext) *PaginationParams { sizeNum = 10 } return &PaginationParams{ - Page: pageNum, - Size: sizeNum, - OrderedBy: orderedBy, - Reverse: reverseBool, + Page: pageNum, + Size: sizeNum, + OrderBy: orderBy, + Desc: descBool, } } diff --git a/internal/dto/comment.go b/internal/dto/comment.go index 76d3a17..a5568f9 100644 --- a/internal/dto/comment.go +++ b/internal/dto/comment.go @@ -1 +1,25 @@ package dto + +type CommentDto struct { + ID uint `json:"id"` + TargetID uint `json:"target_id"` + TargetType string `json:"target_type"` // 目标类型,如 "post", "page" + Content string `json:"content"` + ReplyID uint `json:"reply_id"` // 回复的评论ID + Depth int `json:"depth"` // 评论的层级深度 + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + User UserDto `json:"user"` // 评论的 +} + +type CreateCommentReq struct { + TargetID uint `json:"target_id" binding:"required"` // 目标ID + TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "page" + Content string `json:"content" binding:"required"` // 评论内容 + ReplyID uint `json:"reply_id"` // 回复的评论ID +} + +type UpdateCommentReq struct { + CommentID uint `json:"comment_id" binding:"required"` // 评论ID + Content string `json:"content" binding:"required"` // 评论内容 +} diff --git a/internal/dto/post.go b/internal/dto/post.go index 6fba66b..5f7ab0d 100644 --- a/internal/dto/post.go +++ b/internal/dto/post.go @@ -29,16 +29,16 @@ type CreateOrUpdatePostReq struct { } type ListPostReq struct { - Keywords []string `json:"keywords"` // 关键词列表 - OrderedBy string `json:"ordered_by"` // 排序方式 - Page uint64 `json:"page"` // 页码 - Size uint64 `json:"size"` - Reverse bool `json:"reverse"` + Keywords []string `json:"keywords"` // 关键词列表 + OrderBy string `json:"order_by"` // 排序方式 + Page uint64 `json:"page"` // 页码 + Size uint64 `json:"size"` + Desc bool `json:"desc"` } type ListPostResp struct { - Posts []PostDto `json:"posts"` - Total uint64 `json:"total"` // 总数 - OrderedBy string `json:"ordered_by"` // 排序方式 - Reverse bool `json:"reverse"` + Posts []PostDto `json:"posts"` + Total uint64 `json:"total"` // 总数 + OrderBy string `json:"order_by"` // 排序方式 + Desc bool `json:"desc"` } diff --git a/internal/dto/user.go b/internal/dto/user.go index b334800..99a0d91 100644 --- a/internal/dto/user.go +++ b/internal/dto/user.go @@ -8,6 +8,7 @@ type UserDto struct { Email string `json:"email"` // 邮箱 Gender string `json:"gender"` Role string `json:"role"` + Language string `json:"language"` // 语言 } type UserOidcConfigDto struct { diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index e10bc2c..9ca05a9 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -8,30 +8,42 @@ import ( "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/resps" "github.com/snowykami/neo-blog/pkg/utils" + "strings" "time" ) func UseAuth(block bool) app.HandlerFunc { return func(ctx context.Context, c *app.RequestContext) { // For cookie - token := string(c.Cookie("token")) + tokenFromCookie := string(c.Cookie("tokenFromCookie")) + tokenFromHeader := strings.TrimPrefix(string(c.GetHeader("Authorization")), "Bearer ") refreshToken := string(c.Cookie("refresh_token")) - // 尝试用普通 token 认证 - tokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(token) - if err == nil && tokenClaims != nil { - ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID) - c.Next(ctx) - return + // 尝试用普通 tokenFromCookie 认证 + if tokenFromCookie != "" { + tokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(tokenFromCookie) + if err == nil && tokenClaims != nil { + ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID) + c.Next(ctx) + return + } } - - // token 失效 使用 refresh token 重新签发和鉴权 + // tokenFromCookie 认证失败,尝试用 Bearer tokenFromHeader 认证 + if tokenFromHeader != "" { + tokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(tokenFromHeader) + if err == nil && tokenClaims != nil { + ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID) + c.Next(ctx) + return + } + } + // tokenFromCookie 失效 使用 refresh tokenFromCookie 重新签发和鉴权 refreshTokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(refreshToken) if err == nil && refreshTokenClaims != nil { ok, err := isStatefulJwtValid(refreshTokenClaims) if err == nil && ok { ctx = context.WithValue(ctx, constant.ContextKeyUserID, refreshTokenClaims.UserID) - // 生成新 token + // 生成新 tokenFromCookie newTokenClaims := utils.Jwt.NewClaims( refreshTokenClaims.UserID, refreshTokenClaims.SessionKey, diff --git a/internal/model/comment.go b/internal/model/comment.go index fdaa9c8..192f81a 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -11,6 +11,7 @@ type Comment struct { ReplyID uint `gorm:"index"` // 回复的评论ID Content string `gorm:"type:text"` // 评论内容 Depth int `gorm:"default:0"` // 评论的层级深度 + IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见 LikeCount uint64 CommentCount uint64 } diff --git a/internal/model/post.go b/internal/model/post.go index bfa9c52..5d37c8d 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -17,7 +17,7 @@ type Post struct { CategoryID uint `gorm:"index"` // 帖子分类ID Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类 Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签 - IsPrivate bool `gorm:"default:false"` // 是否为私密帖子 + IsPrivate bool `gorm:"default:false"` LikeCount uint64 CommentCount uint64 ViewCount uint64 @@ -61,3 +61,14 @@ func (p *Post) ToDto() dto.PostDto { UpdatedAt: p.UpdatedAt, } } + +// ToDtoWithShortContent 返回一个简化的 DTO,内容可以根据需要截断 +func (p *Post) ToDtoWithShortContent(contentLength int) dto.PostDto { + dtoPost := p.ToDto() + if len(p.Content) > contentLength { + dtoPost.Content = p.Content[:contentLength] + "..." + } else { + dtoPost.Content = p.Content + } + return dtoPost +} diff --git a/internal/model/user.go b/internal/model/user.go index 652db49..ba2e0da 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -13,6 +13,7 @@ type User struct { Email string `gorm:"uniqueIndex"` Gender string Role string `gorm:"default:'user'"` + Language string `gorm:"default:'en'"` Password string // 密码,存储加密后的值 } @@ -33,5 +34,6 @@ func (user *User) ToDto() *dto.UserDto { Email: user.Email, Gender: user.Gender, Role: user.Role, + Language: user.Language, } } diff --git a/internal/repo/comment.go b/internal/repo/comment.go index e0281bf..d304b47 100644 --- a/internal/repo/comment.go +++ b/internal/repo/comment.go @@ -1 +1,33 @@ package repo + +import "github.com/snowykami/neo-blog/internal/model" + +type CommentRepo struct { +} + +var Comment = &CommentRepo{} + +func (cr *CommentRepo) CreateComment(comment *model.Comment) error { + // Implementation for creating a comment + return nil +} + +func (cr *CommentRepo) UpdateComment(comment *model.Comment) error { + // Implementation for updating a comment + return nil +} + +func (cr *CommentRepo) DeleteComment(commentID string) error { + // Implementation for deleting a comment + return nil +} + +func (cr *CommentRepo) GetComment(commentID string) (*model.Comment, error) { + // Implementation for getting a comment by ID + return nil, nil +} + +func (cr *CommentRepo) ListComments(currentUserID uint, page, size uint, orderBy string, desc bool) ([]model.Comment, error) { + // Implementation for listing comments for a post + return nil, nil +} diff --git a/internal/repo/init.go b/internal/repo/init.go index 4eda049..5e7a569 100644 --- a/internal/repo/init.go +++ b/internal/repo/init.go @@ -101,10 +101,8 @@ func initPostgres(config DBConfig, gormConfig *gorm.Config) (db *gorm.DB, err er if config.Host == "" || config.User == "" || config.Password == "" || config.DBName == "" { err = errors.New("PostgreSQL configuration is incomplete: host, user, password, and dbname are required") } - dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode) - db, err = gorm.Open(postgres.Open(dsn), gormConfig) return } diff --git a/internal/repo/like.go b/internal/repo/like.go index 581c0c1..4ced224 100644 --- a/internal/repo/like.go +++ b/internal/repo/like.go @@ -11,38 +11,53 @@ type likeRepo struct{} var Like = &likeRepo{} -// Like 用户点赞,幂等 -func (l *likeRepo) Like(userID, targetID uint, targetType string) error { +func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) error { err := l.checkTargetType(targetType) if err != nil { return err } - var existingLike model.Like - err = GetDB().Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).First(&existingLike).Error - if err == nil { + return GetDB().Transaction(func(tx *gorm.DB) error { + // 判断是否已点赞 + isLiked, err := l.IsLiked(userID, targetID, targetType) + if err != nil { + return err + } + if isLiked { + // 已点赞,执行取消点赞逻辑 + if err := tx.Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).Delete(&model.Like{}).Error; err != nil { + return err + } + } else { + // 未点赞,执行新增点赞逻辑 + like := &model.Like{ + TargetType: targetType, + TargetID: targetID, + UserID: userID, + } + if err := tx.Create(like).Error; err != nil { + return err + } + } + // 重新计算点赞数量 + var count int64 + if err := tx.Model(&model.Like{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&count).Error; err != nil { + return err + } + // 更新目标的点赞数量 + switch targetType { + case constant.TargetTypePost: + if err := tx.Model(&model.Post{}).Where("id = ?", targetID).Update("like_count", count).Error; err != nil { + return err + } + case constant.TargetTypeComment: + if err := tx.Model(&model.Comment{}).Where("id = ?", targetID).Update("like_count", count).Error; err != nil { + return err + } + default: + return errors.New("invalid target type") + } return nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - like := &model.Like{ - TargetType: targetType, - TargetID: targetID, - UserID: userID, - } - - return GetDB().Create(like).Error -} - -// UnLike 取消点赞 -func (l *likeRepo) UnLike(userID, targetID uint, targetType string) error { - err := l.checkTargetType(targetType) - if err != nil { - return err - } - return GetDB().Where("target_type = ? AND target_id = ? AND user_id = ?", - targetType, targetID, userID).Delete(&model.Like{}).Error + }) } // IsLiked 检查是否点赞 diff --git a/internal/repo/post.go b/internal/repo/post.go index f8da712..3e6b27f 100644 --- a/internal/repo/post.go +++ b/internal/repo/post.go @@ -1,7 +1,6 @@ package repo import ( - "fmt" "github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/errs" @@ -48,16 +47,9 @@ func (p *postRepo) UpdatePost(post *model.Post) error { return nil } -func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size uint64, orderedBy string, reverse bool) ([]model.Post, error) { - var posts []model.Post - if !slices.Contains(constant.OrderedByEnumPost, orderedBy) { - return nil, errs.New(http.StatusBadRequest, "invalid ordered_by parameter", nil) - } - order := orderedBy - if reverse { - order += " ASC" - } else { - order += " DESC" +func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size uint64, orderBy string, desc bool) ([]model.Post, error) { + if !slices.Contains(constant.OrderByEnumPost, orderBy) { + return nil, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil) } query := GetDB().Model(&model.Post{}).Preload("User") if currentUserID > 0 { @@ -65,7 +57,6 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u } else { query = query.Where("is_private = ?", false) } - fmt.Println(keywords) if len(keywords) > 0 { for _, keyword := range keywords { if keyword != "" { @@ -75,9 +66,20 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u } } } - query = query.Order(order).Offset(int((page - 1) * size)).Limit(int(size)) - if err := query.Find(&posts).Error; err != nil { + items, _, err := PaginateQuery[model.Post](query, page, size, orderBy, desc) + if err != nil { return nil, err } - return posts, nil + return items, nil +} + +func (p *postRepo) ToggleLikePost(postID uint, userID uint) error { + if postID == 0 || userID == 0 { + return errs.New(http.StatusBadRequest, "invalid post ID or user ID", nil) + } + err := Like.ToggleLike(userID, postID, constant.TargetTypePost) + if err != nil { + return err + } + return nil } diff --git a/internal/repo/utils.go b/internal/repo/utils.go index e0281bf..0ce9cd0 100644 --- a/internal/repo/utils.go +++ b/internal/repo/utils.go @@ -1 +1,39 @@ package repo + +import ( + "github.com/snowykami/neo-blog/pkg/constant" + "gorm.io/gorm" +) + +func PaginateQuery[T any](db *gorm.DB, page, limit uint64, orderBy string, desc bool, conditions ...any) (items []T, total int64, err error) { + countDB := db + if len(conditions) > 0 { + countDB = countDB.Where(conditions[0], conditions[1:]...) + } + err = countDB.Model(new(T)).Count(&total).Error + if err != nil { + return nil, 0, err + } + if limit <= 0 { + limit = constant.PageLimitDefault + } + queryDB := db + if len(conditions) > 0 { + queryDB = queryDB.Where(conditions[0], conditions[1:]...) + } + if page > 0 { + offset := (page - 1) * limit + queryDB = queryDB.Offset(int(offset)) + } + orderStr := orderBy + if orderStr == "" { + orderStr = "id" + } + if desc { + orderStr += " DESC" + } else { + orderStr += " ASC" + } + err = queryDB.Limit(int(limit)).Order(orderStr).Find(&items).Error + return +} diff --git a/internal/router/apiv1/comment.go b/internal/router/apiv1/comment.go index 28174b5..fa6bb46 100644 --- a/internal/router/apiv1/comment.go +++ b/internal/router/apiv1/comment.go @@ -1,7 +1,21 @@ package apiv1 -import "github.com/cloudwego/hertz/pkg/route" +import ( + "github.com/cloudwego/hertz/pkg/route" + v1 "github.com/snowykami/neo-blog/internal/controller/v1" + "github.com/snowykami/neo-blog/internal/middleware" +) func registerCommentRoutes(group *route.RouterGroup) { - // TODO: Implement comment routes + commentController := v1.NewCommentController() + commentGroup := group.Group("/comments").Use(middleware.UseAuth(true)) + commentGroupWithoutAuth := group.Group("/comments").Use(middleware.UseAuth(false)) + { + commentGroup.POST("/c", commentController.CreateComment) + commentGroup.PUT("/c/:id", commentController.UpdateComment) + commentGroup.DELETE("/c/:id", commentController.DeleteComment) + commentGroup.PUT("/c/:id/react", commentController.ReactComment) // 暂时先不写 + commentGroupWithoutAuth.GET("/c/:id", commentController.GetComment) + commentGroupWithoutAuth.GET("/c/list", commentController.GetCommentList) + } } diff --git a/internal/router/apiv1/like.go b/internal/router/apiv1/like.go index c0e14f5..8261aee 100644 --- a/internal/router/apiv1/like.go +++ b/internal/router/apiv1/like.go @@ -1 +1,15 @@ package apiv1 + +import ( + "github.com/cloudwego/hertz/pkg/route" + "github.com/snowykami/neo-blog/internal/controller/v1" + "github.com/snowykami/neo-blog/internal/middleware" +) + +func registerLikeRoutes(group *route.RouterGroup) { + likeController := v1.NewLikeController() + likeGroup := group.Group("/like").Use(middleware.UseAuth(true)) + { + likeGroup.PUT("/toggle", likeController.ToggleLike) + } +} diff --git a/internal/router/apiv1/post.go b/internal/router/apiv1/post.go index 3fdd915..b1010a3 100644 --- a/internal/router/apiv1/post.go +++ b/internal/router/apiv1/post.go @@ -16,7 +16,7 @@ func registerPostRoutes(group *route.RouterGroup) { postGroupWithoutAuth.GET("/p/:id", postController.Get) postGroupWithoutAuth.GET("/list", postController.List) postGroup.POST("/p", postController.Create) - postGroup.PUT("/p", postController.Update) - postGroup.DELETE("/p", postController.Delete) + postGroup.PUT("/p/:id", postController.Update) + postGroup.DELETE("/p/:id", postController.Delete) } } diff --git a/internal/router/apiv1/user.go b/internal/router/apiv1/user.go index e6ecd6d..3fdab19 100644 --- a/internal/router/apiv1/user.go +++ b/internal/router/apiv1/user.go @@ -18,7 +18,7 @@ func registerUserRoutes(group *route.RouterGroup) { userGroupWithoutAuth.GET("/oidc/list", userController.OidcList) userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin) userGroupWithoutAuth.GET("/u/:id", userController.GetUser) - userGroup.GET("/u", userController.GetUser) + userGroup.GET("/me", userController.GetUser) userGroup.POST("/logout", userController.Logout) userGroup.PUT("/u/:id", userController.UpdateUser) } diff --git a/internal/router/apiv1/v1.go b/internal/router/apiv1/v1.go index 5d8174a..2101e5e 100644 --- a/internal/router/apiv1/v1.go +++ b/internal/router/apiv1/v1.go @@ -9,6 +9,7 @@ func RegisterRoutes(h *server.Hertz) { registerAdminRoutes(apiV1Group) registerFileRoutes(apiV1Group) registerLabelRoutes(apiV1Group) + registerLikeRoutes(apiV1Group) registerPageRoutes(apiV1Group) registerPostRoutes(apiV1Group) registerUserRoutes(apiV1Group) diff --git a/internal/router/router.go b/internal/router/router.go index e4bb7b4..1e274af 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1,6 +1,7 @@ package router import ( + "github.com/cloudwego/hertz/pkg/app/middlewares/server/recovery" "github.com/cloudwego/hertz/pkg/app/server" "github.com/sirupsen/logrus" "github.com/snowykami/neo-blog/internal/router/apiv1" @@ -25,5 +26,6 @@ func init() { server.WithHostPorts(":"+utils.Env.Get("PORT", "8888")), server.WithMaxRequestBodySize(utils.Env.GetAsInt("MAX_REQUEST_BODY_SIZE", 1048576000)), // 1000MiB ) + h.Use(recovery.Recovery()) apiv1.RegisterRoutes(h) } diff --git a/internal/service/comment.go b/internal/service/comment.go index 6d43c33..4cfc2b1 100644 --- a/internal/service/comment.go +++ b/internal/service/comment.go @@ -1 +1,36 @@ package service + +import ( + "context" + "github.com/snowykami/neo-blog/internal/dto" +) + +type CommentService struct{} + +func NewCommentService() *CommentService { + return &CommentService{} +} + +func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) error { + return nil +} + +func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error { + // Implementation for updating a comment + return nil +} + +func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error { + // Implementation for deleting a comment + return nil +} + +func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) { + // Implementation for getting a single comment + return nil, nil +} + +//func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) { +// // Implementation for getting a list of comments +// return nil, nil +//} diff --git a/internal/service/post.go b/internal/service/post.go index 4feae31..2d6526b 100644 --- a/internal/service/post.go +++ b/internal/service/post.go @@ -127,12 +127,37 @@ func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.Create func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]dto.PostDto, error) { postDtos := make([]dto.PostDto, 0) currentUserID, _ := ctxutils.GetCurrentUserID(ctx) - posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderedBy, req.Reverse) + posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderBy, req.Desc) if err != nil { return nil, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err) } for _, post := range posts { - postDtos = append(postDtos, post.ToDto()) + postDtos = append(postDtos, post.ToDtoWithShortContent(100)) } return postDtos, nil } + +func (p *PostService) ToggleLikePost(ctx context.Context, id string) error { + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return errs.ErrUnauthorized + } + if id == "" { + return errs.ErrBadRequest + } + post, err := repo.Post.GetPostByID(id) + if err != nil { + return errs.New(errs.ErrNotFound.Code, "post not found", err) + } + if post.UserID == currentUser.ID { + return errs.ErrForbidden + } + idInt, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return errs.New(errs.ErrBadRequest.Code, "invalid post ID", err) + } + if err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID); err != nil { + return errs.ErrInternalServer + } + return nil +} diff --git a/internal/tasks/session_clear.go b/internal/tasks/session_clear.go index c6cfad8..2ada469 100644 --- a/internal/tasks/session_clear.go +++ b/internal/tasks/session_clear.go @@ -1,4 +1,4 @@ package tasks -// ClearSessionDaemon 定时任务:清理过期会话 +// ClearSessionDaemon 定时任务:清理过期会话 TODO: 定期清理过期会话key func ClearSessionDaemon() {} diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index cc443cd..c1e2a83 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -27,18 +27,18 @@ const ( DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl TargetTypePost = "post" TargetTypeComment = "comment" - OrderedByCreatedAt = "created_at" // 按创建时间排序 - OrderedByUpdatedAt = "updated_at" // 按更新时间排序 - OrderedByLikeCount = "like_count" // 按点赞数排序 - OrderedByCommentCount = "comment_count" // 按评论数排序 - OrderedByViewCount = "view_count" // 按浏览量排序 - OrderedByHeat = "heat" + OrderByCreatedAt = "created_at" // 按创建时间排序 + OrderByUpdatedAt = "updated_at" // 按更新时间排序 + OrderByLikeCount = "like_count" // 按点赞数排序 + OrderByCommentCount = "comment_count" // 按评论数排序 + OrderByViewCount = "view_count" // 按浏览量排序 + OrderByHeat = "heat" HeatFactorViewWeight = 1 // 热度因子:浏览量权重 HeatFactorLikeWeight = 5 // 热度因子:点赞权重 HeatFactorCommentWeight = 10 // 热度因子:评论权重 - + PageLimitDefault = 20 // 默认分页大小 ) var ( - OrderedByEnumPost = []string{OrderedByCreatedAt, OrderedByUpdatedAt, OrderedByLikeCount, OrderedByCommentCount, OrderedByViewCount, OrderedByHeat} // 帖子可用的排序方式 + OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 ) diff --git a/web/next.config.ts b/web/next.config.ts index b5aa1c4..4688682 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,6 +1,7 @@ import { BACKEND_URL } from "@/api/client"; import type { NextConfig } from "next"; +import createNextIntlPlugin from 'next-intl/plugin'; const nextConfig: NextConfig = { output: "standalone", @@ -31,4 +32,5 @@ const nextConfig: NextConfig = { ] } }; -export default nextConfig; +const withNextIntl = createNextIntlPlugin(); +export default withNextIntl(nextConfig); diff --git a/web/package.json b/web/package.json index 3e8d6c9..061a7ea 100644 --- a/web/package.json +++ b/web/package.json @@ -16,14 +16,14 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "deepmerge": "^4.3.1", "field-conv": "^1.0.9", "framer-motion": "^12.23.9", - "i18next": "^25.3.2", "lucide-react": "^0.525.0", "next": "15.4.1", + "next-intl": "^4.3.4", "react": "19.1.0", "react-dom": "19.1.0", - "react-i18next": "^15.6.1", "react-icons": "^5.5.0", "tailwind-merge": "^3.3.1" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5df86e0..a8c98a6 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -29,30 +29,30 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 field-conv: specifier: ^1.0.9 version: 1.0.9 framer-motion: specifier: ^12.23.9 version: 12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - i18next: - specifier: ^25.3.2 - version: 25.3.2(typescript@5.8.3) lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.1.0) next: specifier: 15.4.1 version: 15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-intl: + specifier: ^4.3.4 + version: 4.3.4(next@15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3) react: specifier: 19.1.0 version: 19.1.0 react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) - react-i18next: - specifier: ^15.6.1 - version: 15.6.1(i18next@25.3.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3) react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.1.0) @@ -101,10 +101,6 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@babel/runtime@7.27.6': - resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} - engines: {node: '>=6.9.0'} - '@emnapi/core@1.4.4': resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} @@ -152,6 +148,24 @@ packages: resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@formatjs/ecma402-abstract@2.3.4': + resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.2': + resolution: {integrity: sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==} + + '@formatjs/icu-skeleton-parser@1.8.14': + resolution: {integrity: sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==} + + '@formatjs/intl-localematcher@0.5.10': + resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==} + + '@formatjs/intl-localematcher@0.6.1': + resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -631,6 +645,9 @@ packages: '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1097,9 +1114,16 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1449,17 +1473,6 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - - i18next@25.3.2: - resolution: {integrity: sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==} - peerDependencies: - typescript: ^5 - peerDependenciesMeta: - typescript: - optional: true - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1480,6 +1493,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@10.7.16: + resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1786,6 +1802,20 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl@4.3.4: + resolution: {integrity: sha512-VWLIDlGbnL/o4LnveJTJD1NOYN8lh3ZAGTWw2krhfgg53as3VsS4jzUVnArJdqvwtlpU/2BIDbWTZ7V4o1jFEw==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + next@15.4.1: resolution: {integrity: sha512-eNKB1q8C7o9zXF8+jgJs2CzSLIU3T6bQtX6DcTnCq1sIR1CJ0GlSyRs1BubQi3/JgCnr9Vr+rS5mOMI38FFyQw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -1915,22 +1945,6 @@ packages: peerDependencies: react: ^19.1.0 - react-i18next@15.6.1: - resolution: {integrity: sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==} - peerDependencies: - i18next: '>= 23.2.3' - react: '>= 16.8.0' - react-dom: '*' - react-native: '*' - typescript: ^5 - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - typescript: - optional: true - react-icons@5.5.0: resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} peerDependencies: @@ -2222,6 +2236,11 @@ packages: '@types/react': optional: true + use-intl@4.3.4: + resolution: {integrity: sha512-sHfiU0QeJ1rirNWRxvCyvlSh9+NczcOzRnPyMeo2rtHXhVnBsvMRjE+UG4eh3lRhCxrvcqei/I0lBxsc59on1w==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -2232,10 +2251,6 @@ packages: '@types/react': optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2278,8 +2293,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.29 - '@babel/runtime@7.27.6': {} - '@emnapi/core@1.4.4': dependencies: '@emnapi/wasi-threads': 1.0.3 @@ -2340,6 +2353,36 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@formatjs/ecma402-abstract@2.3.4': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.1 + decimal.js: 10.6.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.2': + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + '@formatjs/icu-skeleton-parser': 1.8.14 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.14': + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.10': + dependencies: + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.1': + dependencies: + tslib: 2.8.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2719,6 +2762,8 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} + '@schummar/icu-type-parser@1.21.5': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3199,8 +3244,12 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + deepmerge@4.3.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -3699,16 +3748,6 @@ snapshots: dependencies: function-bind: 1.1.2 - html-parse-stringify@3.0.1: - dependencies: - void-elements: 3.1.0 - - i18next@25.3.2(typescript@5.8.3): - dependencies: - '@babel/runtime': 7.27.6 - optionalDependencies: - typescript: 5.8.3 - ignore@5.3.2: {} ignore@7.0.5: {} @@ -3726,6 +3765,13 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + intl-messageformat@10.7.16: + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.2 + tslib: 2.8.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -4003,6 +4049,18 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + next-intl@4.3.4(next@15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3): + dependencies: + '@formatjs/intl-localematcher': 0.5.10 + negotiator: 1.0.0 + next: 15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + use-intl: 4.3.4(react@19.1.0) + optionalDependencies: + typescript: 5.8.3 + next@15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.4.1 @@ -4140,16 +4198,6 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-i18next@15.6.1(i18next@25.3.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3): - dependencies: - '@babel/runtime': 7.27.6 - html-parse-stringify: 3.0.1 - i18next: 25.3.2(typescript@5.8.3) - react: 19.1.0 - optionalDependencies: - react-dom: 19.1.0(react@19.1.0) - typescript: 5.8.3 - react-icons@5.5.0(react@19.1.0): dependencies: react: 19.1.0 @@ -4541,6 +4589,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + use-intl@4.3.4(react@19.1.0): + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@schummar/icu-type-parser': 1.21.5 + intl-messageformat: 10.7.16 + react: 19.1.0 + use-sidecar@1.1.3(@types/react@19.1.8)(react@19.1.0): dependencies: detect-node-es: 1.1.0 @@ -4549,8 +4604,6 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 - void-elements@3.1.0: {} - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/web/src/api/post.ts b/web/src/api/post.ts index bf12070..e020729 100644 --- a/web/src/api/post.ts +++ b/web/src/api/post.ts @@ -5,8 +5,8 @@ import axiosClient from "./client"; interface ListPostsParams { page?: number; size?: number; - orderedBy?: string; - reverse?: boolean; + orderBy?: string; + desc?: boolean; keywords?: string; } @@ -24,16 +24,16 @@ export async function getPostById(id: string): Promise { export async function listPosts({ page = 1, size = 10, - orderedBy = 'updated_at', - reverse = false, + orderBy = 'updated_at', + desc = false, keywords = '' }: ListPostsParams = {}): Promise> { const res = await axiosClient.get>("/post/list", { params: { page, size, - orderedBy, - reverse, + orderBy, + desc, keywords } }); diff --git a/web/src/api/user.ts b/web/src/api/user.ts index bb451a5..b94edb7 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -43,4 +43,18 @@ export async function ListOidcConfigs(): Promise> { "/user/oidc/list" ); return res.data; +} + +export async function getLoginUser(token: string = ""): Promise> { + const res = await axiosClient.get>("/user/me", { + headers: { + Authorization: `Bearer ${token}` + } + }); + return res.data; +} + +export async function getUserById(id: number): Promise> { + const res = await axiosClient.get>(`/user/u/${id}`); + return res.data; } \ No newline at end of file diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index a78d2e7..4279c6a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { DeviceProvider } from "@/contexts/device-context"; +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale } from 'next-intl/server'; const geistSans = Geist({ variable: "--font-geist-sans", @@ -29,7 +31,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > - {children} + {children} diff --git a/web/src/components/blog-card.tsx b/web/src/components/blog-card.tsx index ebf189a..1354755 100644 --- a/web/src/components/blog-card.tsx +++ b/web/src/components/blog-card.tsx @@ -3,7 +3,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Badge } from "@/components/ui/badge"; import Link from "next/link"; import Image from "next/image"; -import { Calendar, Eye, Heart, MessageCircle, Lock } from "lucide-react"; +import { Calendar, Eye, Heart, MessageCircle, Lock } from "lucide-react"; import { cn } from "@/lib/utils"; import config from "@/config"; @@ -23,27 +23,7 @@ export function BlogCard({ post, className }: BlogCardProps) { }); }; - // 计算阅读时间(估算) - // const getReadingTime = (content: string) => { - // const wordsPerMinute = 200; - // const wordCount = content.length; - // const minutes = Math.ceil(wordCount / wordsPerMinute); - // return `${minutes} 分钟阅读`; - // }; - - // // 根据内容类型获取图标 - // const getContentTypeIcon = (type: Post['type']) => { - // switch (type) { - // case 'markdown': - // return '📝'; - // case 'html': - // return '🌐'; - // case 'text': - // return '📄'; - // default: - // return '📝'; - // } - // }; + // TODO: 阅读时间估计 return ( {post.title} - + {/* Card Content - 主要内容 */} diff --git a/web/src/components/blog-home-sidebar.tsx b/web/src/components/blog-home-sidebar.tsx index 1614ea0..1829053 100644 --- a/web/src/components/blog-home-sidebar.tsx +++ b/web/src/components/blog-home-sidebar.tsx @@ -5,8 +5,8 @@ import { Badge } from "@/components/ui/badge"; import type { Label } from "@/models/label"; import type { Post } from "@/models/post"; import type configType from '@/config'; -import { t } from "i18next"; import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; // 侧边栏父组件,接收卡片组件列表 export default function Sidebar({ cards }: { cards: React.ReactNode[] }) { @@ -115,10 +115,11 @@ export function SidebarIframe(props?: { src?: string; scriptSrc?: string; title? title = "External Content", height = "400px", } = props || {}; + const t = useTranslations('HomePage'); return ( - {t(title)} + {t("title")}