From d1d8aa529ffbaf3256e17617c69be285452e4c13 Mon Sep 17 00:00:00 2001 From: Snowykami Date: Fri, 12 Sep 2025 00:26:08 +0800 Subject: [PATCH] feat: Refactor comment section to correctly handle API response structure fix: Update Gravatar URL size and improve avatar rendering logic style: Adjust footer margin for better layout consistency refactor: Remove old navbar component and integrate new layout structure feat: Enhance user profile page with user header component chore: Remove unused user profile component fix: Update posts per page configuration for better pagination feat: Extend device context to support system theme mode refactor: Remove unused device hook fix: Improve storage state hook for better error handling i18n: Add new translations for blog home page feat: Implement pagination component for better navigation feat: Create theme toggle component for improved user experience feat: Introduce responsive navbar or side layout with theme toggle feat: Develop custom select component for better UI consistency feat: Create user header component to display user information chore: Add query key constants for better code maintainability --- internal/controller/v1/comment.go | 4 +- internal/controller/v1/post.go | 219 +++--- internal/dto/post.go | 12 +- internal/model/user.go | 2 +- internal/repo/label.go | 20 + internal/repo/post.go | 38 +- internal/service/post.go | 8 +- internal/service/user.go | 708 +++++++++--------- web/package.json | 2 +- web/pnpm-lock.yaml | 230 +++++- web/src/api/comment.ts | 6 +- web/src/api/post.ts | 16 +- web/src/app/(main)/layout.tsx | 13 +- web/src/app/globals.css | 20 + web/src/app/layout.tsx | 2 +- .../components/blog-home/blog-home-card.tsx | 31 +- web/src/components/blog-home/blog-home.tsx | 172 ++--- web/src/components/blog/blog-sidebar-card.tsx | 2 +- web/src/components/comment/comment-input.tsx | 2 +- web/src/components/comment/comment-item.tsx | 4 +- web/src/components/comment/index.tsx | 8 +- web/src/components/common/gravatar.tsx | 33 +- web/src/components/common/pagination.tsx | 144 ++++ web/src/components/common/theme-toggle.tsx | 88 +++ web/src/components/layout/footer.tsx | 2 +- .../layout/{navbar.tsx => navbar-or-side.tsx} | 9 +- web/src/components/ui/select.tsx | 185 +++++ web/src/components/user/index.tsx | 7 +- web/src/components/user/user-header.tsx | 40 + web/src/components/user/user-profile.tsx | 11 - web/src/config.ts | 2 +- web/src/constant.ts | 6 + web/src/contexts/device-context.tsx | 49 +- web/src/hooks/use-device.ts | 18 - web/src/hooks/use-storage-state.tsx | 52 +- web/src/locales/zh-CN.json | 9 + 36 files changed, 1443 insertions(+), 731 deletions(-) create mode 100644 web/src/components/common/pagination.tsx create mode 100644 web/src/components/common/theme-toggle.tsx rename web/src/components/layout/{navbar.tsx => navbar-or-side.tsx} (95%) create mode 100644 web/src/components/ui/select.tsx create mode 100644 web/src/components/user/user-header.tsx create mode 100644 web/src/constant.ts delete mode 100644 web/src/hooks/use-device.ts diff --git a/internal/controller/v1/comment.go b/internal/controller/v1/comment.go index ee616a0..ff9bd9f 100644 --- a/internal/controller/v1/comment.go +++ b/internal/controller/v1/comment.go @@ -128,13 +128,13 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC TargetType: c.Query("target_type"), CommentID: commentID, } - resp, err := cc.service.GetCommentList(ctx, &req) + commentDtos, err := cc.service.GetCommentList(ctx, &req) if err != nil { serviceErr := errs.AsServiceError(err) resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) return } - resps.Ok(c, resps.Success, resp) + resps.Ok(c, resps.Success, utils.H{"comments": commentDtos}) } func (cc *CommentController) ReactComment(ctx context.Context, c *app.RequestContext) { diff --git a/internal/controller/v1/post.go b/internal/controller/v1/post.go index 4641836..c26d534 100644 --- a/internal/controller/v1/post.go +++ b/internal/controller/v1/post.go @@ -1,121 +1,150 @@ package v1 import ( - "context" - "slices" - "strings" + "context" + "slices" + "strings" - "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/dto" - "github.com/snowykami/neo-blog/internal/service" - "github.com/snowykami/neo-blog/pkg/constant" - "github.com/snowykami/neo-blog/pkg/errs" - "github.com/snowykami/neo-blog/pkg/resps" + "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/dto" + "github.com/snowykami/neo-blog/internal/service" + "github.com/snowykami/neo-blog/pkg/constant" + "github.com/snowykami/neo-blog/pkg/errs" + "github.com/snowykami/neo-blog/pkg/resps" ) type PostController struct { - service *service.PostService + service *service.PostService } func NewPostController() *PostController { - return &PostController{ - service: service.NewPostService(), - } + return &PostController{ + service: service.NewPostService(), + } } func (p *PostController) Create(ctx context.Context, c *app.RequestContext) { - var req dto.CreateOrUpdatePostReq - if err := c.BindAndValidate(&req); err != nil { - resps.BadRequest(c, resps.ErrParamInvalid) - return - } - postID, err := p.service.CreatePost(ctx, &req) - if err != nil { - serviceErr := errs.AsServiceError(err) - resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) - return - } - resps.Ok(c, resps.Success, utils.H{"id": postID}) + var req dto.CreateOrUpdatePostReq + if err := c.BindAndValidate(&req); err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + postID, err := p.service.CreatePost(ctx, &req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, utils.H{"id": postID}) } func (p *PostController) Delete(ctx context.Context, c *app.RequestContext) { - id := c.Param("id") - if id == "" { - resps.BadRequest(c, resps.ErrParamInvalid) - return - } - if err := p.service.DeletePost(ctx, id); err != nil { - serviceErr := errs.AsServiceError(err) - resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) - return - } - resps.Ok(c, resps.Success, nil) + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + if err := p.service.DeletePost(ctx, id); err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, nil) } func (p *PostController) Get(ctx context.Context, c *app.RequestContext) { - id := c.Param("id") - if id == "" { - resps.BadRequest(c, resps.ErrParamInvalid) - return - } - post, err := p.service.GetPost(ctx, id) - if err != nil { - serviceErr := errs.AsServiceError(err) - resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) - return - } - if post == nil { - resps.NotFound(c, resps.ErrNotFound) - return - } - resps.Ok(c, resps.Success, post) + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + post, err := p.service.GetPost(ctx, id) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + if post == nil { + resps.NotFound(c, resps.ErrNotFound) + return + } + resps.Ok(c, resps.Success, post) } func (p *PostController) Update(ctx context.Context, c *app.RequestContext) { - var req dto.CreateOrUpdatePostReq - if err := c.BindAndValidate(&req); err != nil { - resps.BadRequest(c, resps.ErrParamInvalid) - return - } - id := c.Param("id") - if id == "" { - resps.BadRequest(c, resps.ErrParamInvalid) - return - } - postID, err := p.service.UpdatePost(ctx, id, &req) - if err != nil { - serviceErr := errs.AsServiceError(err) - resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) - return - } - resps.Ok(c, resps.Success, utils.H{"id": postID}) + var req dto.CreateOrUpdatePostReq + if err := c.BindAndValidate(&req); err != nil { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + id := c.Param("id") + if id == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + postID, err := p.service.UpdatePost(ctx, id, &req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, utils.H{"id": postID}) } func (p *PostController) List(ctx context.Context, c *app.RequestContext) { - pagination := ctxutils.GetPaginationParams(c) - if pagination.OrderBy == "" { - pagination.OrderBy = constant.OrderByUpdatedAt - } - 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, - OrderBy: pagination.OrderBy, - Desc: pagination.Desc, - } - posts, err := p.service.ListPosts(ctx, req) - if err != nil { - serviceErr := errs.AsServiceError(err) - resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) - return - } - resps.Ok(c, resps.Success, posts) + pagination := ctxutils.GetPaginationParams(c) + if pagination.OrderBy == "" { + pagination.OrderBy = constant.OrderByUpdatedAt + } + if pagination.OrderBy != "" && !slices.Contains(constant.OrderByEnumPost, pagination.OrderBy) { + resps.BadRequest(c, "无效的排序字段") + return + } + keywords := c.Query("keywords") + keywordsArray := strings.Split(keywords, ",") + labels := c.Query("labels") + labelStringArray := strings.Split(labels, ",") + + labelRule := c.Query("label_rule") + if labelRule != "intersection" { + labelRule = "union" + } + + labelDtos := make([]dto.LabelDto, 0, len(labelStringArray)) + for _, labelString := range labelStringArray { + // :分割key和value + if labelString == "" { + continue + } + parts := strings.SplitN(labelString, ":", 2) + if len(parts) == 2 { + labelDtos = append(labelDtos, dto.LabelDto{ + Key: parts[0], + Value: parts[1], + }) + } else { + labelDtos = append(labelDtos, dto.LabelDto{ + Key: parts[0], + Value: "", + }) + } + } + req := &dto.ListPostReq{ + Keywords: keywordsArray, + Labels: labelDtos, + LabelRule: labelRule, + Page: pagination.Page, + Size: pagination.Size, + OrderBy: pagination.OrderBy, + Desc: pagination.Desc, + } + posts, total, err := p.service.ListPosts(ctx, req) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, utils.H{"posts": posts, "total": total}) } diff --git a/internal/dto/post.go b/internal/dto/post.go index a816c69..88abd78 100644 --- a/internal/dto/post.go +++ b/internal/dto/post.go @@ -31,11 +31,13 @@ type CreateOrUpdatePostReq struct { } type ListPostReq struct { - Keywords []string `json:"keywords"` // 关键词列表 - OrderBy string `json:"order_by"` // 排序方式 - Page uint64 `json:"page"` // 页码 - Size uint64 `json:"size"` - Desc bool `json:"desc"` + Keywords []string `json:"keywords"` // 关键词列表 + OrderBy string `json:"order_by"` // 排序方式 + Page uint64 `json:"page"` // 页码 + Size uint64 `json:"size"` + Desc bool `json:"desc"` + Labels []LabelDto `json:"labels"` + LabelRule string `json:"label_rule"` // 标签过滤规则 union or intersection } type ListPostResp struct { diff --git a/internal/model/user.go b/internal/model/user.go index 66de9d1..ea808fc 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -12,7 +12,7 @@ type User struct { AvatarUrl string Email string `gorm:"uniqueIndex"` Gender string - Role string `gorm:"default:'user'"` + Role string `gorm:"default:'user'"` // user editor admin Language string `gorm:"default:'en'"` Password string // 密码,存储加密后的值 } diff --git a/internal/repo/label.go b/internal/repo/label.go index 5a6aaae..b48c6a3 100644 --- a/internal/repo/label.go +++ b/internal/repo/label.go @@ -18,6 +18,26 @@ func (l *labelRepo) GetLabelByKey(key string) (*model.Label, error) { return &label, nil } +func (l *labelRepo) GetLabelByValue(value string) (*model.Label, error) { + var label model.Label + if err := GetDB().Where("value = ?", value).First(&label).Error; err != nil { + return nil, err + } + return &label, nil +} + +func (l *labelRepo) GetLabelByKeyAndValue(key, value string) (*model.Label, error) { + var label model.Label + query := GetDB().Where("key = ?", key) + if value != "" { + query = query.Where("value = ?", value) + } + if err := GetDB().Where(query).First(&label).Error; err != nil { + return nil, err + } + return &label, nil +} + func (l *labelRepo) GetLabelByID(id string) (*model.Label, error) { var label model.Label if err := GetDB().Where("id = ?", id).First(&label).Error; err != nil { diff --git a/internal/repo/post.go b/internal/repo/post.go index 76327fe..6904cf1 100644 --- a/internal/repo/post.go +++ b/internal/repo/post.go @@ -1,12 +1,15 @@ package repo import ( + "errors" "net/http" "slices" + "github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/errs" + "gorm.io/gorm" ) type postRepo struct{} @@ -48,9 +51,9 @@ func (p *postRepo) UpdatePost(post *model.Post) error { return nil } -func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size uint64, orderBy string, desc bool) ([]model.Post, error) { +func (p *postRepo) ListPosts(currentUserID uint, keywords []string, labels []dto.LabelDto, labelRule string, page, size uint64, orderBy string, desc bool) ([]model.Post, int64, error) { if !slices.Contains(constant.OrderByEnumPost, orderBy) { - return nil, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil) + return nil, 0, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil) } query := GetDB().Model(&model.Post{}).Preload("User") if currentUserID > 0 { @@ -58,20 +61,43 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u } else { query = query.Where("is_private = ?", false) } + + if len(labels) > 0 { + var labelIds []uint + for _, labelDto := range labels { + label, _ := Label.GetLabelByKeyAndValue(labelDto.Key, labelDto.Value) + labelIds = append(labelIds, label.ID) + } + if labelRule == "intersection" { + query = query.Joins("JOIN post_labels ON post_labels.post_id = posts.id"). + Where("post_labels.label_id IN ?", labelIds). + Group("posts.id"). + Having("COUNT(DISTINCT post_labels.label_id) = ?", len(labelIds)) + } else { + query = query.Joins("JOIN post_labels ON post_labels.post_id = posts.id"). + Where("post_labels.label_id IN ?", labelIds) + } + } + if len(keywords) > 0 { for _, keyword := range keywords { if keyword != "" { - // 使用LIKE进行模糊匹配,搜索标题、内容和标签 - query = query.Where("title LIKE ? OR content LIKE ?", // TODO: 支持标签搜索 + query = query.Where("title LIKE ? OR content LIKE ?", "%"+keyword+"%", "%"+keyword+"%") } } } + + var total int64 + if err := query.Count(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, err + } + items, _, err := PaginateQuery[model.Post](query, page, size, orderBy, desc) if err != nil { - return nil, err + return nil, 0, err } - return items, nil + return items, total, nil } func (p *postRepo) ToggleLikePost(postID uint, userID uint) (bool, error) { diff --git a/internal/service/post.go b/internal/service/post.go index 2e272da..3708f75 100644 --- a/internal/service/post.go +++ b/internal/service/post.go @@ -114,17 +114,17 @@ func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.Create return post.ID, nil } -func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, error) { +func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, int64, error) { postDtos := make([]*dto.PostDto, 0) currentUserID, _ := ctxutils.GetCurrentUserID(ctx) - posts, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Page, req.Size, req.OrderBy, req.Desc) + posts, total, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Labels, req.LabelRule, req.Page, req.Size, req.OrderBy, req.Desc) if err != nil { - return nil, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err) + return nil, total, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err) } for _, post := range posts { postDtos = append(postDtos, post.ToDtoWithShortContent(100)) } - return postDtos, nil + return postDtos, total, nil } func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) { diff --git a/internal/service/user.go b/internal/service/user.go index c28b9ed..4b206ab 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -1,400 +1,400 @@ package service import ( - "errors" - "fmt" - "net/http" - "strings" - "time" + "errors" + "fmt" + "net/http" + "strings" + "time" - "github.com/sirupsen/logrus" - "github.com/snowykami/neo-blog/internal/dto" - "github.com/snowykami/neo-blog/internal/model" - "github.com/snowykami/neo-blog/internal/repo" - "github.com/snowykami/neo-blog/internal/static" - "github.com/snowykami/neo-blog/pkg/constant" - "github.com/snowykami/neo-blog/pkg/errs" - "github.com/snowykami/neo-blog/pkg/utils" - "gorm.io/gorm" + "github.com/sirupsen/logrus" + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/internal/repo" + "github.com/snowykami/neo-blog/internal/static" + "github.com/snowykami/neo-blog/pkg/constant" + "github.com/snowykami/neo-blog/pkg/errs" + "github.com/snowykami/neo-blog/pkg/utils" + "gorm.io/gorm" ) type UserService struct{} func NewUserService() *UserService { - return &UserService{} + return &UserService{} } func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) { - user, err := repo.User.GetUserByUsernameOrEmail(req.Username) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - logrus.Warnf("User not found: %s", req.Username) - return nil, errs.ErrNotFound - } - return nil, errs.ErrInternalServer - } - if user == nil { - return nil, errs.ErrNotFound - } - if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) { - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.UserLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } else { - return nil, errs.ErrInternalServer - } + user, err := repo.User.GetUserByUsernameOrEmail(req.Username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logrus.Warnf("User not found: %s", req.Username) + return nil, errs.ErrNotFound + } + return nil, errs.ErrInternalServer + } + if user == nil { + return nil, errs.ErrNotFound + } + if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) { + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.UserLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } else { + return nil, errs.ErrInternalServer + } } func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) { - // 验证邮箱验证码 - if !utils.Env.GetAsBool("ENABLE_REGISTER", true) { - return nil, errs.ErrForbidden - } - if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) { - ok, err := s.verifyEmail(req.Email, req.VerificationCode) - if err != nil { - logrus.Errorln("Failed to verify email:", err) - return nil, errs.ErrInternalServer - } - if !ok { - return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil) - } - } - // 检查用户名或邮箱是否已存在 - usernameExist, err := repo.User.CheckUsernameExists(req.Username) - if err != nil { - return nil, errs.ErrInternalServer - } - emailExist, err := repo.User.CheckEmailExists(req.Email) - if err != nil { - return nil, errs.ErrInternalServer - } - if usernameExist || emailExist { - return nil, errs.New(http.StatusConflict, "Username or email already exists", nil) - } - // 创建新用户 - hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) - if err != nil { - logrus.Errorln("Failed to hash password:", err) - return nil, errs.ErrInternalServer - } - newUser := &model.User{ - Username: req.Username, - Nickname: req.Nickname, - Email: req.Email, - Gender: "", - Role: "user", - Password: hashedPassword, - } - err = repo.User.CreateUser(newUser) - if err != nil { - return nil, errs.ErrInternalServer - } - // 创建默认管理员账户 - if newUser.ID == 1 { - newUser.Role = constant.RoleAdmin - err = repo.User.UpdateUser(newUser) - if err != nil { - logrus.Errorln("Failed to update user role to admin:", err) - return nil, errs.ErrInternalServer - } - } - // 生成访问令牌和刷新令牌 - token, refreshToken, err := s.generate2Token(newUser.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.UserRegisterResp{ - Token: token, - RefreshToken: refreshToken, - User: newUser.ToDto(), - } - return resp, nil + // 验证邮箱验证码 + if !utils.Env.GetAsBool("ENABLE_REGISTER", true) { + return nil, errs.ErrForbidden + } + if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) { + ok, err := s.verifyEmail(req.Email, req.VerificationCode) + if err != nil { + logrus.Errorln("Failed to verify email:", err) + return nil, errs.ErrInternalServer + } + if !ok { + return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil) + } + } + // 检查用户名或邮箱是否已存在 + usernameExist, err := repo.User.CheckUsernameExists(req.Username) + if err != nil { + return nil, errs.ErrInternalServer + } + emailExist, err := repo.User.CheckEmailExists(req.Email) + if err != nil { + return nil, errs.ErrInternalServer + } + if usernameExist || emailExist { + return nil, errs.New(http.StatusConflict, "Username or email already exists", nil) + } + // 创建新用户 + hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) + if err != nil { + logrus.Errorln("Failed to hash password:", err) + return nil, errs.ErrInternalServer + } + newUser := &model.User{ + Username: req.Username, + Nickname: req.Nickname, + Email: req.Email, + Gender: "", + Role: "user", + Password: hashedPassword, + } + err = repo.User.CreateUser(newUser) + if err != nil { + return nil, errs.ErrInternalServer + } + // 创建默认管理员账户 + if newUser.ID == 1 { + newUser.Role = constant.RoleAdmin + err = repo.User.UpdateUser(newUser) + if err != nil { + logrus.Errorln("Failed to update user role to admin:", err) + return nil, errs.ErrInternalServer + } + } + // 生成访问令牌和刷新令牌 + token, refreshToken, err := s.generate2Token(newUser.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.UserRegisterResp{ + Token: token, + RefreshToken: refreshToken, + User: newUser.ToDto(), + } + return resp, nil } func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) { - generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef") - kv := utils.KV.GetInstance() - kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10) + generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef") + kv := utils.KV.GetInstance() + kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10) - template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{}) - if err != nil { - return nil, errs.ErrInternalServer - } - if utils.IsDevMode { - logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode) - } - err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true) + template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{}) + if err != nil { + return nil, errs.ErrInternalServer + } + if utils.IsDevMode { + logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode) + } + err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true) - if err != nil { - return nil, errs.ErrInternalServer - } - return &dto.VerifyEmailResp{Success: true}, nil + if err != nil { + return nil, errs.ErrInternalServer + } + return &dto.VerifyEmailResp{Success: true}, nil } func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) { - enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true) - if err != nil { - return nil, errs.ErrInternalServer - } - var oidcConfigsDtos []dto.UserOidcConfigDto + enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true) + if err != nil { + return nil, errs.ErrInternalServer + } + var oidcConfigsDtos []dto.UserOidcConfigDto - for _, oidcConfig := range enabledOidcConfigs { - state := utils.Strings.GenerateRandomString(32) - kvStore := utils.KV.GetInstance() - kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute) - loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ - "client_id": oidcConfig.ClientID, - "redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 - strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), - constant.ApiSuffix, - constant.OidcUri, - oidcConfig.Name, - ), - "response_type": "code", - "scope": "openid email profile", - "state": state, - }) + for _, oidcConfig := range enabledOidcConfigs { + state := utils.Strings.GenerateRandomString(32) + kvStore := utils.KV.GetInstance() + kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute) + loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ + "client_id": oidcConfig.ClientID, + "redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 + strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), + constant.ApiSuffix, + constant.OidcUri, + oidcConfig.Name, + ), + "response_type": "code", + "scope": "openid email profile", + "state": state, + }) - if oidcConfig.Type == constant.OidcProviderTypeMisskey { - // Misskey OIDC 特殊处理 - loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ - "client_id": oidcConfig.ClientID, - "redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 - strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), - constant.ApiSuffix, - constant.OidcUri, - oidcConfig.Name, - ), - "response_type": "code", - "scope": "read:account", - "state": state, - }) - } + if oidcConfig.Type == constant.OidcProviderTypeMisskey { + // Misskey OIDC 特殊处理 + loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ + "client_id": oidcConfig.ClientID, + "redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 + strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), + constant.ApiSuffix, + constant.OidcUri, + oidcConfig.Name, + ), + "response_type": "code", + "scope": "read:account", + "state": state, + }) + } - oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{ - Name: oidcConfig.Name, - DisplayName: oidcConfig.DisplayName, - Icon: oidcConfig.Icon, - LoginUrl: loginUrl, - }) - } - return oidcConfigsDtos, nil + oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{ + Name: oidcConfig.Name, + DisplayName: oidcConfig.DisplayName, + Icon: oidcConfig.Icon, + LoginUrl: loginUrl, + }) + } + return oidcConfigsDtos, nil } func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) { - // 验证state - kvStore := utils.KV.GetInstance() - storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State) - if !ok || storedName != req.Name { - return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil) - } - // 获取OIDC配置 - oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name) - if err != nil { - return nil, errs.ErrInternalServer - } - if oidcConfig == nil { - return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil) - } - // 请求访问令牌 - tokenResp, err := utils.Oidc.RequestToken( - oidcConfig.TokenEndpoint, - oidcConfig.ClientID, - oidcConfig.ClientSecret, - req.Code, - strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name, - ) - if err != nil { - logrus.Errorln("Failed to request OIDC token:", err) - return nil, errs.ErrInternalServer - } - userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken) - if err != nil { - logrus.Errorln("Failed to request OIDC user info:", err) - return nil, errs.ErrInternalServer - } + // 验证state + kvStore := utils.KV.GetInstance() + storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State) + if !ok || storedName != req.Name { + return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil) + } + // 获取OIDC配置 + oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name) + if err != nil { + return nil, errs.ErrInternalServer + } + if oidcConfig == nil { + return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil) + } + // 请求访问令牌 + tokenResp, err := utils.Oidc.RequestToken( + oidcConfig.TokenEndpoint, + oidcConfig.ClientID, + oidcConfig.ClientSecret, + req.Code, + strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name, + ) + if err != nil { + logrus.Errorln("Failed to request OIDC token:", err) + return nil, errs.ErrInternalServer + } + userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken) + if err != nil { + logrus.Errorln("Failed to request OIDC user info:", err) + return nil, errs.ErrInternalServer + } - // 绑定过登录 - userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errs.ErrInternalServer - } - if userOpenID != nil { - user, err := repo.User.GetUserByID(userOpenID.UserID) - if err != nil { - return nil, errs.ErrInternalServer - } - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.OidcLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } else { - // 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户 - user, err := repo.User.GetUserByEmail(userInfo.Email) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - logrus.Errorln("Failed to get user by email:", err) - return nil, errs.ErrInternalServer - } - if user != nil { - userOpenID = &model.UserOpenID{ - UserID: user.ID, - Issuer: oidcConfig.Issuer, - Sub: userInfo.Sub, - } - err = repo.User.CreateOrUpdateUserOpenID(userOpenID) - if err != nil { - logrus.Errorln("Failed to create or update user OpenID:", err) - return nil, errs.ErrInternalServer - } - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.OidcLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } else { - user = &model.User{ - Username: userInfo.Name, - Nickname: userInfo.Name, - AvatarUrl: userInfo.Picture, - Email: userInfo.Email, - } - err = repo.User.CreateUser(user) - if err != nil { - logrus.Errorln("Failed to create user:", err) - return nil, errs.ErrInternalServer - } - userOpenID = &model.UserOpenID{ - UserID: user.ID, - Issuer: oidcConfig.Issuer, - Sub: userInfo.Sub, - } - err = repo.User.CreateOrUpdateUserOpenID(userOpenID) - if err != nil { - logrus.Errorln("Failed to create or update user OpenID:", err) - return nil, errs.ErrInternalServer - } - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.OidcLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } - } + // 绑定过登录 + userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrInternalServer + } + if userOpenID != nil { + user, err := repo.User.GetUserByID(userOpenID.UserID) + if err != nil { + return nil, errs.ErrInternalServer + } + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.OidcLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } else { + // 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户 + user, err := repo.User.GetUserByEmail(userInfo.Email) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + logrus.Errorln("Failed to get user by email:", err) + return nil, errs.ErrInternalServer + } + if user != nil { + userOpenID = &model.UserOpenID{ + UserID: user.ID, + Issuer: oidcConfig.Issuer, + Sub: userInfo.Sub, + } + err = repo.User.CreateOrUpdateUserOpenID(userOpenID) + if err != nil { + logrus.Errorln("Failed to create or update user OpenID:", err) + return nil, errs.ErrInternalServer + } + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.OidcLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } else { + user = &model.User{ + Username: userInfo.Name, + Nickname: userInfo.Name, + AvatarUrl: userInfo.Picture, + Email: userInfo.Email, + } + err = repo.User.CreateUser(user) + if err != nil { + logrus.Errorln("Failed to create user:", err) + return nil, errs.ErrInternalServer + } + userOpenID = &model.UserOpenID{ + UserID: user.ID, + Issuer: oidcConfig.Issuer, + Sub: userInfo.Sub, + } + err = repo.User.CreateOrUpdateUserOpenID(userOpenID) + if err != nil { + logrus.Errorln("Failed to create or update user OpenID:", err) + return nil, errs.ErrInternalServer + } + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.OidcLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } + } } func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) { - if req.UserID == 0 { - return nil, errs.New(http.StatusBadRequest, "user_id is required", nil) - } - user, err := repo.User.GetUserByID(req.UserID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errs.ErrNotFound - } - logrus.Errorln("Failed to get user by ID:", err) - return nil, errs.ErrInternalServer - } - if user == nil { - return nil, errs.ErrNotFound - } - return &dto.GetUserResp{ - User: user.ToDto(), - }, nil + if req.UserID == 0 { + return nil, errs.New(http.StatusBadRequest, "user_id is required", nil) + } + user, err := repo.User.GetUserByID(req.UserID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrNotFound + } + logrus.Errorln("Failed to get user by ID:", err) + return nil, errs.ErrInternalServer + } + if user == nil { + return nil, errs.ErrNotFound + } + return &dto.GetUserResp{ + User: user.ToDto(), + }, nil } func (s *UserService) GetUserByUsername(req *dto.GetUserByUsernameReq) (*dto.GetUserResp, error) { - if req.Username == "" { - return nil, errs.New(http.StatusBadRequest, "username is required", nil) - } - user, err := repo.User.GetUserByUsername(req.Username) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errs.ErrNotFound - } - logrus.Errorln("Failed to get user by username:", err) - return nil, errs.ErrInternalServer - } - if user == nil { - return nil, errs.ErrNotFound - } - return &dto.GetUserResp{ - User: user.ToDto(), - }, nil + if req.Username == "" { + return nil, errs.New(http.StatusBadRequest, "username is required", nil) + } + user, err := repo.User.GetUserByUsername(req.Username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrNotFound + } + logrus.Errorln("Failed to get user by username:", err) + return nil, errs.ErrInternalServer + } + if user == nil { + return nil, errs.ErrNotFound + } + return &dto.GetUserResp{ + User: user.ToDto(), + }, nil } func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) { - user := &model.User{ - Model: gorm.Model{ - ID: req.ID, - }, - Username: req.Username, - Nickname: req.Nickname, - Gender: req.Gender, - AvatarUrl: req.AvatarUrl, - } - err := repo.User.UpdateUser(user) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errs.ErrNotFound - } - logrus.Errorln("Failed to update user:", err) - return nil, errs.ErrInternalServer - } - return &dto.UpdateUserResp{}, nil + user := &model.User{ + Model: gorm.Model{ + ID: req.ID, + }, + Username: req.Username, + Nickname: req.Nickname, + Gender: req.Gender, + AvatarUrl: req.AvatarUrl, + } + err := repo.User.UpdateUser(user) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrNotFound + } + logrus.Errorln("Failed to update user:", err) + return nil, errs.ErrInternalServer + } + return &dto.UpdateUserResp{}, nil } func (s *UserService) generate2Token(userID uint) (string, string, error) { - token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second) - tokenString, err := token.ToString() - if err != nil { - return "", "", errs.ErrInternalServer - } - refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second) - refreshTokenString, err := refreshToken.ToString() - if err != nil { - return "", "", errs.ErrInternalServer - } - err = repo.Session.SaveSession(refreshToken.SessionKey) - if err != nil { - return "", "", errs.ErrInternalServer - } - return tokenString, refreshTokenString, nil + token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second) + tokenString, err := token.ToString() + if err != nil { + return "", "", errs.ErrInternalServer + } + refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second) + refreshTokenString, err := refreshToken.ToString() + if err != nil { + return "", "", errs.ErrInternalServer + } + err = repo.Session.SaveSession(refreshToken.SessionKey) + if err != nil { + return "", "", errs.ErrInternalServer + } + return tokenString, refreshTokenString, nil } func (s *UserService) verifyEmail(email, code string) (bool, error) { - kv := utils.KV.GetInstance() - verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email) - if !ok || verificationCode != code { - return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil) - } - return true, nil + kv := utils.KV.GetInstance() + verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email) + if !ok || verificationCode != code { + return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil) + } + return true, nil } diff --git a/web/package.json b/web/package.json index 6f99963..e5309e1 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", @@ -23,7 +24,6 @@ "clsx": "^2.1.1", "deepmerge": "^4.3.1", "field-conv": "^1.0.9", - "framer-motion": "^12.23.9", "highlight.js": "^11.11.1", "lucide-react": "^0.525.0", "motion": "^12.23.12", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 77dfef8..7de495a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-navigation-menu': specifier: ^1.2.13 version: 1.2.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -50,9 +53,6 @@ importers: 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) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -193,6 +193,21 @@ packages: resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@formatjs/ecma402-abstract@2.3.4': resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==} @@ -467,12 +482,28 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -552,6 +583,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.2': resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} peerDependencies: @@ -561,6 +605,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: @@ -609,6 +662,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -661,6 +727,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.7': resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} peerDependencies: @@ -750,6 +829,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.1': resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} peerDependencies: @@ -772,6 +860,9 @@ packages: '@types/react-dom': optional: true + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1619,20 +1710,6 @@ packages: react-dom: optional: true - framer-motion@12.23.9: - resolution: {integrity: sha512-TqEHXj8LWfQSKqfdr5Y4mYltYLw96deu6/K9kGDd+ysqRJPNwF9nb5mZcrLmybHbU7gcJ+HQar41U3UTGanbbQ==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2187,9 +2264,6 @@ packages: motion-dom@12.23.12: resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} - motion-dom@12.23.9: - resolution: {integrity: sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==} - motion-utils@12.23.6: resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} @@ -2921,6 +2995,23 @@ snapshots: '@eslint/core': 0.15.1 levn: 0.4.1 + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/utils@0.2.10': {} + '@formatjs/ecma402-abstract@2.3.4': dependencies: '@formatjs/fast-memoize': 2.2.7 @@ -3169,10 +3260,21 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@radix-ui/number@1.1.1': {} + '@radix-ui/primitive@1.1.2': {} '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3254,12 +3356,31 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.8)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) @@ -3309,6 +3430,24 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -3348,6 +3487,35 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -3419,6 +3587,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.8)(react@19.1.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.8 + '@radix-ui/react-use-size@1.1.1(@types/react@19.1.8)(react@19.1.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) @@ -3435,6 +3610,8 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/rect@1.1.1': {} + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.12.0': {} @@ -4442,15 +4619,6 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - framer-motion@12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - motion-dom: 12.23.9 - motion-utils: 12.23.6 - tslib: 2.8.1 - optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -5220,10 +5388,6 @@ snapshots: dependencies: motion-utils: 12.23.6 - motion-dom@12.23.9: - dependencies: - motion-utils: 12.23.6 - motion-utils@12.23.6: {} motion@12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0): diff --git a/web/src/api/comment.ts b/web/src/api/comment.ts index 75d8719..0d5fdaf 100644 --- a/web/src/api/comment.ts +++ b/web/src/api/comment.ts @@ -20,8 +20,8 @@ export async function createComment( replyId: number | null isPrivate: boolean } -): Promise> { - const res = await axiosClient.post>('/comment/c', { +): Promise> { + const res = await axiosClient.post>('/comment/c', { targetType, targetId, content, @@ -68,7 +68,7 @@ export async function listComments({ commentId: number } & PaginationParams ) { - const res = await axiosClient.get>(`/comment/list`, { + const res = await axiosClient.get>(`/comment/list`, { params: { targetType, targetId, diff --git a/web/src/api/post.ts b/web/src/api/post.ts index e406cf6..9d43fe0 100644 --- a/web/src/api/post.ts +++ b/web/src/api/post.ts @@ -1,7 +1,7 @@ import type { Post } from '@/models/post' import type { BaseResponse } from '@/models/resp' import axiosClient from './client' -import type { ListPostsParams } from '@/models/post' +import { OrderBy, PaginationParams } from '@/models/common' export async function getPostById(id: string, token: string=""): Promise { @@ -22,17 +22,25 @@ export async function getPostById(id: string, token: string=""): Promise> { - const res = await axiosClient.get>('/post/list', { + labels = '', + labelRule = 'union', +}: { + keywords?: string, // 关键词,逗号分割 + labels?: string, // 标签,逗号分割 + labelRule?: 'union' | 'intersection' // 标签规则,默认并集 +} & PaginationParams): Promise> { + const res = await axiosClient.get>('/post/list', { params: { page, size, orderBy, desc, keywords, + labels, + labelRule }, }) return res.data diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index 72f995c..b712b4d 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -1,8 +1,8 @@ 'use client' -import { motion } from 'framer-motion' +import { motion } from 'motion/react' import { usePathname } from 'next/navigation' -import { Navbar } from '@/components/layout/navbar' +import { Navbar } from '@/components/layout/navbar-or-side' import { BackgroundProvider } from '@/contexts/background-context' import Footer from '@/components/layout/footer' import config from '@/config' @@ -12,7 +12,6 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode }>) { - const pathname = usePathname() return ( <> - -
{children}
-
-