mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-25 18:46:23 +00:00
feat: Refactor comment section to correctly handle API response structure
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 9s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 9s
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
This commit is contained in:
@ -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) {
|
||||
|
@ -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})
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 // 密码,存储加密后的值
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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",
|
||||
|
230
web/pnpm-lock.yaml
generated
230
web/pnpm-lock.yaml
generated
@ -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):
|
||||
|
@ -20,8 +20,8 @@ export async function createComment(
|
||||
replyId: number | null
|
||||
isPrivate: boolean
|
||||
}
|
||||
): Promise<BaseResponse<{id: number}>> {
|
||||
const res = await axiosClient.post<BaseResponse<{id: number}>>('/comment/c', {
|
||||
): Promise<BaseResponse<{ id: number }>> {
|
||||
const res = await axiosClient.post<BaseResponse<{ id: number }>>('/comment/c', {
|
||||
targetType,
|
||||
targetId,
|
||||
content,
|
||||
@ -68,7 +68,7 @@ export async function listComments({
|
||||
commentId: number
|
||||
} & PaginationParams
|
||||
) {
|
||||
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
||||
const res = await axiosClient.get<BaseResponse<{ "comments": Comment[] }>>(`/comment/list`, {
|
||||
params: {
|
||||
targetType,
|
||||
targetId,
|
||||
|
@ -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<Post | null> {
|
||||
@ -22,17 +22,25 @@ export async function getPostById(id: string, token: string=""): Promise<Post |
|
||||
export async function listPosts({
|
||||
page = 1,
|
||||
size = 10,
|
||||
orderBy = 'updated_at',
|
||||
orderBy = OrderBy.CreatedAt,
|
||||
desc = false,
|
||||
keywords = '',
|
||||
}: ListPostsParams = {}): Promise<BaseResponse<Post[]>> {
|
||||
const res = await axiosClient.get<BaseResponse<Post[]>>('/post/list', {
|
||||
labels = '',
|
||||
labelRule = 'union',
|
||||
}: {
|
||||
keywords?: string, // 关键词,逗号分割
|
||||
labels?: string, // 标签,逗号分割
|
||||
labelRule?: 'union' | 'intersection' // 标签规则,默认并集
|
||||
} & PaginationParams): Promise<BaseResponse<{"posts": Post[], "total" : number}>> {
|
||||
const res = await axiosClient.get<BaseResponse<{"posts": Post[], "total": number}>>('/post/list', {
|
||||
params: {
|
||||
page,
|
||||
size,
|
||||
orderBy,
|
||||
desc,
|
||||
keywords,
|
||||
labels,
|
||||
labelRule
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
|
@ -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 (
|
||||
<>
|
||||
<motion.nav
|
||||
@ -23,10 +22,10 @@ export default function RootLayout({
|
||||
<Navbar />
|
||||
</header>
|
||||
</motion.nav>
|
||||
<BackgroundProvider>
|
||||
<div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
|
||||
</BackgroundProvider>
|
||||
<Footer />
|
||||
<BackgroundProvider>
|
||||
<div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
|
||||
</BackgroundProvider>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
@ -131,4 +131,24 @@ html, body {
|
||||
|
||||
.sonner-toast {
|
||||
background-color: aqua;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px; /* 垂直滚动条宽度 */
|
||||
height: 4px; /* 水平滚动条高度 */
|
||||
background: transparent; /* 滚动条轨道背景 */
|
||||
position: absolute; /* 实际不会影响内容布局 */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.15); /* 滚动条滑块颜色 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
@ -34,7 +34,7 @@ export default async function RootLayout({
|
||||
>
|
||||
<Toaster richColors position="top-center" offset={80} />
|
||||
<DeviceProvider>
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</DeviceProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -7,15 +7,14 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import config from '@/config'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getPostHref } from '@/utils/common/post'
|
||||
import { motion } from 'framer-motion'
|
||||
import { motion } from 'motion/react'
|
||||
import { deceleration } from '@/motion/curve'
|
||||
|
||||
interface BlogCardProps {
|
||||
|
||||
export function BlogCard({ post, className }: {
|
||||
post: Post
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlogCard({ post, className }: BlogCardProps) {
|
||||
}) {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
@ -57,16 +56,16 @@ export function BlogCard({ post, className }: BlogCardProps) {
|
||||
// 默认渐变背景 - 基于热度生成颜色
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-full bg-gradient-to-br',
|
||||
post.heat > 80
|
||||
? 'from-red-400 via-pink-500 to-orange-500'
|
||||
: post.heat > 60
|
||||
? 'from-orange-400 via-yellow-500 to-red-500'
|
||||
: post.heat > 40
|
||||
? 'from-blue-400 via-purple-500 to-pink-500'
|
||||
: post.heat > 20
|
||||
? 'from-green-400 via-blue-500 to-purple-500'
|
||||
: 'from-gray-400 via-slate-500 to-gray-600',
|
||||
'w-full h-full bg-gradient-to-br',
|
||||
post.heat > 80
|
||||
? 'from-red-400 via-pink-500 to-orange-500'
|
||||
: post.heat > 60
|
||||
? 'from-orange-400 via-yellow-500 to-red-500'
|
||||
: post.heat > 40
|
||||
? 'from-blue-400 via-purple-500 to-pink-500'
|
||||
: post.heat > 20
|
||||
? 'from-green-400 via-blue-500 to-purple-500'
|
||||
: 'from-gray-400 via-slate-500 to-gray-600',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@ -210,7 +209,7 @@ export function BlogCardGrid({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
{Array.from({ length: config.postsPerPage }).map((_, index) => (
|
||||
<BlogCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,85 +1,84 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { BlogCardGrid } from "@/components/blog-home/blog-home-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrendingUp, Clock, } from "lucide-react";
|
||||
import Sidebar, { SidebarAbout, SidebarHotPosts, SidebarMisskeyIframe, SidebarTags } from "../blog/blog-sidebar-card";
|
||||
import config from '@/config';
|
||||
import type { Label } from "@/models/label";
|
||||
import type { Post } from "@/models/post";
|
||||
import { listPosts } from "@/api/post";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStoredState } from '@/hooks/use-storage-state';
|
||||
import { listLabels } from "@/api/label";
|
||||
import { POST_SORT_TYPE } from "@/localstore";
|
||||
import { motion } from "framer-motion";
|
||||
import { useDevice } from "@/hooks/use-device";
|
||||
import { checkIsMobile } from "@/utils/client/device";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { OrderBy } from "@/models/common";
|
||||
import { PaginationController } from "@/components/common/pagination";
|
||||
import { QueryKey } from "@/constant";
|
||||
import { useStoredState } from "@/hooks/use-storage-state";
|
||||
|
||||
// 定义排序类型
|
||||
type SortType = 'latest' | 'popular';
|
||||
enum SortBy {
|
||||
Latest = 'latest',
|
||||
Hottest = 'hottest',
|
||||
}
|
||||
|
||||
const DEFAULT_SORTBY: SortBy = SortBy.Latest;
|
||||
|
||||
export default function BlogHome() {
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
// 从路由查询参数中获取页码和标签们
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("BlogHome");
|
||||
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [totalPosts, setTotalPosts] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
|
||||
const [sortBy, setSortBy, isSortByLoaded] = useStoredState<SortBy>(QueryKey.SortBy, DEFAULT_SORTBY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortTypeLoaded) return;
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let orderBy: string;
|
||||
let desc: boolean;
|
||||
switch (sortType) {
|
||||
case 'latest':
|
||||
orderBy = 'updated_at';
|
||||
desc = true;
|
||||
break;
|
||||
case 'popular':
|
||||
orderBy = 'heat';
|
||||
desc = true;
|
||||
break;
|
||||
default:
|
||||
orderBy = 'updated_at';
|
||||
desc = true;
|
||||
}
|
||||
// 处理关键词,空格分割转逗号
|
||||
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
|
||||
const data = await listPosts({
|
||||
page: 1,
|
||||
size: 10,
|
||||
orderBy: orderBy,
|
||||
desc: desc,
|
||||
keywords
|
||||
});
|
||||
setPosts(data.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch posts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!isSortByLoaded) return; // wait for stored state loaded
|
||||
setLoading(true);
|
||||
listPosts(
|
||||
{
|
||||
page: currentPage,
|
||||
size: config.postsPerPage,
|
||||
orderBy: sortBy === SortBy.Latest ? OrderBy.CreatedAt : OrderBy.Heat,
|
||||
desc: true,
|
||||
keywords: keywords.join(",") || undefined,
|
||||
labels: labels.join(",") || undefined,
|
||||
}
|
||||
};
|
||||
fetchPosts();
|
||||
}, [sortType, sortTypeLoaded]);
|
||||
|
||||
// 获取标签
|
||||
useEffect(() => {
|
||||
listLabels().then(data => {
|
||||
setLabels(data.data || []);
|
||||
}).catch(error => {
|
||||
console.error("Failed to fetch labels:", error);
|
||||
).then(res => {
|
||||
setPosts(res.data.posts);
|
||||
setTotalPosts(res.data.total);
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
}, [keywords, labels, currentPage, sortBy, isSortByLoaded]);
|
||||
|
||||
// 处理排序切换
|
||||
const handleSortChange = (type: SortType) => {
|
||||
if (sortType !== type) {
|
||||
setSortType(type);
|
||||
const handleSortChange = (type: SortBy) => {
|
||||
if (sortBy !== type) {
|
||||
setSortBy(type);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
// 修改查询参数和状态
|
||||
setCurrentPage(page);
|
||||
// 不滚动到顶部,用户可能在阅读侧边栏
|
||||
// window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// 修改查询参数
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('page', page.toString());
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 主内容区域 */}
|
||||
@ -90,80 +89,75 @@ export default function BlogHome() {
|
||||
{/* 主要内容区域 */}
|
||||
<motion.div
|
||||
className="lg:col-span-3 self-start"
|
||||
initial={{ y: checkIsMobile() ? 30 : 60, opacity: 0 }}
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
||||
{/* 文章列表标题 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{sortType === 'latest' ? '最新文章' : '热门文章'}
|
||||
{sortBy === 'latest' ? t("latest_posts") : t("hottest_posts")}
|
||||
{posts.length > 0 && (
|
||||
<span className="text-sm font-normal text-slate-500 ml-2">
|
||||
({posts.length} 篇)
|
||||
<span className="text-xl font-normal text-slate-500 ml-2">
|
||||
({posts.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* 排序按钮组 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isSortByLoaded && <div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={sortType === 'latest' ? 'default' : 'outline'}
|
||||
variant={sortBy === SortBy.Latest ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('latest')}
|
||||
onClick={() => handleSortChange(SortBy.Latest)}
|
||||
disabled={loading}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
最新
|
||||
{t("latest")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortType === 'popular' ? 'default' : 'outline'}
|
||||
variant={sortBy === 'hottest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleSortChange('popular')}
|
||||
onClick={() => handleSortChange(SortBy.Hottest)}
|
||||
disabled={loading}
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
热门
|
||||
{t("hottest")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* 博客卡片网格 */}
|
||||
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
|
||||
|
||||
{/* 加载更多按钮 */}
|
||||
{!loading && posts.length > 0 && (
|
||||
<div className="text-center mt-12">
|
||||
<Button size="lg" className="px-8">
|
||||
加载更多文章
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页控制器 */}
|
||||
<div className="mt-8">
|
||||
<PaginationController
|
||||
className="pt-4 flex justify-center"
|
||||
initialPage={currentPage}
|
||||
totalPages={Math.ceil(totalPosts / config.postsPerPage)}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
{/* 加载状态指示器 */}
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center gap-2 text-slate-600">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
正在加载{sortType === 'latest' ? '最新' : '热门'}文章...
|
||||
<span>{t("loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<motion.div
|
||||
initial={checkIsMobile() ? { y: 30, opacity: 0 } : { x: 80, opacity: 0 }}
|
||||
initial={{ x: 80, opacity: 0 }}
|
||||
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
|
||||
>
|
||||
<Sidebar
|
||||
cards={[
|
||||
<SidebarAbout key="about" config={config} />,
|
||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
|
||||
<SidebarTags key="tags" labels={labels} />,
|
||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortBy} /> : null,
|
||||
<SidebarTags key="tags" labels={[]} />,
|
||||
<SidebarMisskeyIframe key="misskey" />,
|
||||
].filter(Boolean)}
|
||||
/>
|
||||
|
@ -34,7 +34,7 @@ export function SidebarAbout({ config }: { config: typeof configType }) {
|
||||
<CardContent>
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-20 h-20 mx-auto bg-gradient-to-br from-blue-400 to-purple-500 rounded-full flex items-center justify-center text-white text-2xl font-bold overflow-hidden">
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" />
|
||||
<GravatarAvatar email={config.owner.gravatarEmail} className="w-full h-full object-cover" size={200} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">{config.owner.name}</h3>
|
||||
<p className="text-sm text-slate-600">{config.owner.motto}</p>
|
||||
|
@ -60,7 +60,7 @@ export function CommentInput(
|
||||
<div className="fade-in-up">
|
||||
<div className="flex py-4 fade-in">
|
||||
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
||||
{user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
|
||||
{user && <GravatarAvatar className="w-full h-full" url={user.avatarUrl} email={user.email} size={100}/>}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
|
@ -101,7 +101,7 @@ export function CommentItem(
|
||||
commentId: comment.id
|
||||
}
|
||||
).then(response => {
|
||||
setReplies(response.data);
|
||||
setReplies(response.data.comments);
|
||||
setRepliesLoaded(true);
|
||||
});
|
||||
}
|
||||
@ -159,7 +159,7 @@ export function CommentItem(
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||
<GravatarAvatar email={comment.user.email} size={120}/>
|
||||
<GravatarAvatar className="w-full h-full" url={comment.user.avatarUrl} email={comment.user.email} size={100}/>
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<div className="flex gap-2 md:gap-4 items-center">
|
||||
|
@ -17,8 +17,6 @@ import config from "@/config";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
|
||||
|
||||
export function CommentSection(
|
||||
{
|
||||
targetType,
|
||||
@ -59,7 +57,7 @@ export function CommentSection(
|
||||
size: config.commentsPerPage,
|
||||
commentId: 0
|
||||
}).then(response => {
|
||||
setComments(response.data);
|
||||
setComments(response.data.comments);
|
||||
});
|
||||
}, [])
|
||||
|
||||
@ -108,10 +106,10 @@ export function CommentSection(
|
||||
size: config.commentsPerPage,
|
||||
commentId: 0
|
||||
}).then(response => {
|
||||
if (response.data.length < config.commentsPerPage) {
|
||||
if (response.data.comments.length < config.commentsPerPage) {
|
||||
setNeedLoadMore(false);
|
||||
}
|
||||
setComments(prevComments => [...prevComments, ...response.data]);
|
||||
setComments(prevComments => [...prevComments, ...response.data.comments]);
|
||||
setPage(nextPage);
|
||||
});
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import Image from "next/image";
|
||||
import crypto from "crypto";
|
||||
|
||||
// 生成 Gravatar URL 的函数
|
||||
function getGravatarUrl(email: string, size: number = 40, defaultType: string = "identicon"): string {
|
||||
function getGravatarUrl(email: string, size: number = 200, defaultType: string = "identicon"): string {
|
||||
const hash = crypto.createHash('md5').update(email.toLowerCase().trim()).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultType}`;
|
||||
}
|
||||
@ -21,35 +21,26 @@ interface GravatarAvatarProps {
|
||||
|
||||
const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
|
||||
email,
|
||||
size = 40,
|
||||
size = 200,
|
||||
className = "",
|
||||
alt = "avatar",
|
||||
url,
|
||||
defaultType = "identicon"
|
||||
}) => {
|
||||
// 如果有自定义URL,使用自定义URL
|
||||
if (url && url.trim() !== "") {
|
||||
return (
|
||||
// 把尺寸控制交给父组件的 wrapper(父组件通过 tailwind 的 w-.. h-.. 控制)
|
||||
const gravatarUrl = url && url.trim() !== "" ? url : getGravatarUrl(email, size , defaultType);
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
<Image
|
||||
src={url}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||
src={gravatarUrl}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes="(max-width: 640px) 64px, 200px"
|
||||
className="rounded-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
|
||||
return (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||
alt={alt}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
144
web/src/components/common/pagination.tsx
Normal file
144
web/src/components/common/pagination.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
|
||||
export function PaginationController({
|
||||
initialPage = 1,
|
||||
totalPages = 10,
|
||||
buttons = 7, // recommended odd number >=5
|
||||
onPageChange,
|
||||
...props
|
||||
}: {
|
||||
initialPage?: number
|
||||
totalPages: number
|
||||
buttons?: number
|
||||
onPageChange?: (page: number) => void
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
// normalize buttons
|
||||
const btns = Math.max(5, buttons ?? 7);
|
||||
const buttonsToShow = totalPages < btns ? totalPages : btns;
|
||||
// rely on shadcn buttonVariants and PaginationLink's isActive prop for styling
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(() => Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages)));
|
||||
const [direction, setDirection] = useState(0) // 1 = forward (right->left), -1 = backward
|
||||
|
||||
// sync when initialPage or totalPages props change
|
||||
useEffect(() => {
|
||||
const p = Math.min(Math.max(1, initialPage ?? 1), Math.max(1, totalPages));
|
||||
setCurrentPage(p);
|
||||
}, [initialPage, totalPages]);
|
||||
|
||||
const handleSetPage = useCallback((p: number) => {
|
||||
const next = Math.min(Math.max(1, Math.floor(p)), Math.max(1, totalPages));
|
||||
setDirection(next > currentPage ? 1 : next < currentPage ? -1 : 0);
|
||||
setCurrentPage(next);
|
||||
if (typeof onPageChange === 'function') onPageChange(next);
|
||||
}, [onPageChange, totalPages, currentPage]);
|
||||
|
||||
// helper to render page link
|
||||
const renderPage = (pageNum: number) => (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={pageNum === currentPage}
|
||||
aria-current={pageNum === currentPage ? 'page' : undefined}
|
||||
onClick={() => handleSetPage(pageNum)}
|
||||
type="button"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
|
||||
// if totalPages small, render all
|
||||
if (totalPages <= buttonsToShow) {
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent className="select-none">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
aria-disabled={currentPage === 1}
|
||||
onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: totalPages }).map((_, i) => renderPage(i + 1))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
aria-disabled={currentPage === totalPages}
|
||||
onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
|
||||
// for larger totalPages, show: 1, 2 or ellipsis, center range, ellipsis or N-1, N
|
||||
const centerCount = buttonsToShow - 4; // slots for center pages
|
||||
let start = currentPage - Math.floor(centerCount / 2);
|
||||
let end = start + centerCount - 1;
|
||||
if (start < 3) {
|
||||
start = 3;
|
||||
end = start + centerCount - 1;
|
||||
}
|
||||
if (end > totalPages - 2) {
|
||||
end = totalPages - 2;
|
||||
start = end - (centerCount - 1);
|
||||
}
|
||||
|
||||
const centerPages = [] as number[];
|
||||
for (let i = start; i <= end; i++) centerPages.push(i);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<Pagination >
|
||||
<PaginationContent className="select-none">
|
||||
<PaginationItem>
|
||||
<PaginationPrevious aria-disabled={currentPage === 1} onClick={() => currentPage > 1 && handleSetPage(currentPage - 1)} />
|
||||
</PaginationItem>
|
||||
|
||||
{renderPage(1)}
|
||||
|
||||
{/* second slot: either page 2 or ellipsis if center starts later */}
|
||||
{start > 3 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : renderPage(2)}
|
||||
|
||||
{/* center pages */}
|
||||
{centerPages.map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === currentPage}
|
||||
aria-current={p === currentPage ? 'page' : undefined}
|
||||
onClick={() => handleSetPage(p)}
|
||||
type="button"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
{end < totalPages - 2 ? (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : renderPage(totalPages - 1)}
|
||||
|
||||
{renderPage(totalPages)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext aria-disabled={currentPage === totalPages} onClick={() => currentPage < totalPages && handleSetPage(currentPage + 1)} />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
88
web/src/components/common/theme-toggle.tsx
Normal file
88
web/src/components/common/theme-toggle.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
|
||||
import { useDevice } from "@/contexts/device-context";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
// PC端:三状态轮换按钮
|
||||
export function ThemeModeCycleButton(props: React.ButtonHTMLAttributes<HTMLButtonElement> & { mode: ThemeMode; setMode: (m: ThemeMode) => void }) {
|
||||
const { mode, setMode, className, style, onClick, ...rest } = props;
|
||||
const nextMode = (mode: ThemeMode): ThemeMode => {
|
||||
if (mode === "light") return "dark";
|
||||
if (mode === "dark") return "system";
|
||||
return "light";
|
||||
};
|
||||
const icon = mode === "light" ? <Sun className="w-4 h-4" /> : mode === "dark" ? <Moon className="w-4 h-4" /> : <Monitor className="w-4 h-4" />;
|
||||
const label = mode.charAt(0).toUpperCase() + mode.slice(1);
|
||||
|
||||
const baseCls = "flex items-center gap-2 px-2 py-2 rounded-full bg-muted hover:bg-accent border border-input text-sm font-medium transition-all";
|
||||
const mergedClassName = cn(baseCls, className);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={mergedClassName}
|
||||
style={style}
|
||||
onClick={(e) => {
|
||||
setMode(nextMode(mode));
|
||||
onClick?.(e);
|
||||
}}
|
||||
title={`切换主题(当前:${label})`}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端:横向按钮组
|
||||
export function ThemeModeSegmented(props: React.HTMLAttributes<HTMLDivElement> & { mode: ThemeMode; setMode: (m: ThemeMode) => void }) {
|
||||
const { mode, setMode, className, style, ...rest } = props;
|
||||
const modes: { value: ThemeMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ value: "light", icon: <Sun className="w-4 h-4" />, label: "Light" },
|
||||
{ value: "system", icon: <Monitor className="w-4 h-4" />, label: "System" },
|
||||
{ value: "dark", icon: <Moon className="w-4 h-4" />, label: "Dark" },
|
||||
];
|
||||
const activeIndex = modes.findIndex((m) => m.value === mode);
|
||||
const baseCls = "relative inline-flex bg-muted rounded-full p-1 gap-1 overflow-hidden";
|
||||
|
||||
return (
|
||||
<div className={cn("theme-mode-segmented-wrapper", className)} style={style} {...rest}>
|
||||
<div className={baseCls}>
|
||||
{/* 滑动高亮块 */}
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="absolute w-12 h-8 rounded-full bg-white/70 shadow-sm z-1 top-1"
|
||||
style={{
|
||||
left: `calc(0.25rem + ${activeIndex} * (3rem + 0.25rem))`,
|
||||
}}
|
||||
/>
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-12 h-8 rounded-full text-sm font-medium transition-all z-10",
|
||||
mode === m.value ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setMode(m.value)}
|
||||
type="button"
|
||||
>
|
||||
{m.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 总组件:根据设备类型渲染
|
||||
export function ThemeModeToggle(props: React.HTMLAttributes<HTMLElement> = {}) {
|
||||
const { isMobile, mode, setMode } = useDevice();
|
||||
const Comp: React.ElementType = isMobile ? ThemeModeSegmented : ThemeModeCycleButton;
|
||||
const { className, style } = props;
|
||||
// 仅转发 className / style,避免复杂的 prop 类型不匹配
|
||||
return <Comp mode={mode} setMode={setMode} className={className} style={style} />;
|
||||
}
|
@ -3,7 +3,7 @@ import React from "react";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 mt-12">
|
||||
<footer className="w-full py-6 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||
© {new Date().getFullYear()} {config.metadata.name} · Powered by {config.owner.name} · {config.footer.text}
|
||||
</footer>
|
||||
);
|
||||
|
@ -12,13 +12,13 @@ import {
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu"
|
||||
import GravatarAvatar from "@/components/common/gravatar"
|
||||
import { useDevice } from "@/contexts/device-context"
|
||||
import config from "@/config"
|
||||
import { useState } from "react"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { Menu } from "lucide-react"
|
||||
import { Switch } from "../ui/switch"
|
||||
import { ThemeModeToggle } from "../common/theme-toggle"
|
||||
|
||||
const navbarMenuComponents = [
|
||||
{
|
||||
@ -55,7 +55,7 @@ export function Navbar() {
|
||||
<NavMenuCenter />
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
|
||||
<ThemeModeToggle className="hidden md:block" />
|
||||
<SidebarMenuClientOnly />
|
||||
</div>
|
||||
</nav>
|
||||
@ -169,8 +169,11 @@ function SidebarMenu() {
|
||||
) : null
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex items-center justify-center p-4 border-t border-border">
|
||||
<ThemeModeToggle/>
|
||||
</div>
|
||||
|
||||
</SheetContent>
|
||||
</Sheet></div>
|
||||
|
||||
)
|
||||
}
|
185
web/src/components/ui/select.tsx
Normal file
185
web/src/components/ui/select.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
import { User } from "@/models/user";
|
||||
import { UserHeader } from "./user-header";
|
||||
|
||||
export function UserPage({user}: {user: User}) {
|
||||
return <div>User: {user.username}</div>;
|
||||
}
|
||||
return <div>
|
||||
<UserHeader user={user} />
|
||||
</div>;
|
||||
}
|
||||
|
40
web/src/components/user/user-header.tsx
Normal file
40
web/src/components/user/user-header.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
import { User } from "@/models/user";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
import { Mail, User as UserIcon, Shield } from 'lucide-react';
|
||||
|
||||
export function UserHeader({ user }: { user: User }) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row items-center md:items-center h-auto md:h-60">
|
||||
{/* 左侧 30%(头像容器) */}
|
||||
<div className="md:basis-[20%] flex justify-center items-center p-4">
|
||||
{/* wrapper 控制显示大小,父组件给具体 w/h */}
|
||||
<div className="w-40 h-40 md:w-48 md:h-48 relative">
|
||||
<GravatarAvatar className="rounded-full w-full h-full" url={user.avatarUrl} email={user.email} size={200} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧 70%(信息区) */}
|
||||
<div className="md:basis-[70%] p-4 flex flex-col justify-center space-y-2">
|
||||
<h2 className="text-2xl font-bold mt-0">{user.nickname}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">@{user.username}</p>
|
||||
|
||||
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<UserIcon className="w-4 h-4 mr-2" />
|
||||
<span>{user.gender || '未填写'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
<span>{user.email || '未填写'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
<span>{user.role || '访客'}</span>
|
||||
</div>
|
||||
{/* 其他简介、按钮等放这里 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
"use client"
|
||||
import { User } from "@/models/user";
|
||||
import GravatarAvatar from "@/components/common/gravatar";
|
||||
|
||||
export function UserProfile({ user }: { user: User }) {
|
||||
return (
|
||||
<div className="flex">
|
||||
<GravatarAvatar email={user.email} size={120}/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -14,7 +14,7 @@ const config = {
|
||||
},
|
||||
bodyWidth: "80vw",
|
||||
bodyWidthMobile: "100vw",
|
||||
postsPerPage: 12,
|
||||
postsPerPage: 9,
|
||||
commentsPerPage: 8,
|
||||
animationDurationSecond: 0.618,
|
||||
footer: {
|
||||
|
6
web/src/constant.ts
Normal file
6
web/src/constant.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum QueryKey {
|
||||
SortBy = "sort_by",
|
||||
Page = "page",
|
||||
Label = "label",
|
||||
Keywords = "keywords",
|
||||
};
|
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Mode = "light" | "dark";
|
||||
type Mode = "light" | "dark" | "system";
|
||||
|
||||
interface DeviceContextProps {
|
||||
isMobile: boolean;
|
||||
@ -19,7 +19,7 @@ interface DeviceContextProps {
|
||||
|
||||
const DeviceContext = createContext<DeviceContextProps>({
|
||||
isMobile: false,
|
||||
mode: "light",
|
||||
mode: "system",
|
||||
setMode: () => {},
|
||||
toggleMode: () => {},
|
||||
viewport: {
|
||||
@ -32,7 +32,7 @@ const DeviceContext = createContext<DeviceContextProps>({
|
||||
|
||||
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mode, setModeState] = useState<Mode>("light");
|
||||
const [mode, setModeState] = useState<Mode>("system");
|
||||
const [viewport, setViewport] = useState({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
@ -45,6 +45,18 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
// 应用主题到 document
|
||||
const applyTheme = useCallback(
|
||||
(theme: Mode) => {
|
||||
let effectiveTheme = theme;
|
||||
if (theme === "system") {
|
||||
effectiveTheme = getSystemTheme();
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", effectiveTheme === "dark");
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
@ -70,47 +82,48 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedTheme = localStorage.getItem("theme") as Mode | null;
|
||||
const systemTheme = getSystemTheme();
|
||||
const theme = savedTheme || systemTheme;
|
||||
const theme = savedTheme || "system";
|
||||
setModeState(theme);
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
applyTheme(theme);
|
||||
|
||||
// 监听系统主题变动
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem("theme")) {
|
||||
const newTheme = e.matches ? "dark" : "light";
|
||||
setModeState(newTheme);
|
||||
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
||||
if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "system") {
|
||||
applyTheme("system");
|
||||
}
|
||||
};
|
||||
media.addEventListener("change", handleChange);
|
||||
return () => media.removeEventListener("change", handleChange);
|
||||
}
|
||||
}, []);
|
||||
}, [applyTheme]);
|
||||
|
||||
const setMode = useCallback((newMode: Mode) => {
|
||||
setModeState(newMode);
|
||||
document.documentElement.classList.toggle("dark", newMode === "dark");
|
||||
if (newMode === getSystemTheme()) {
|
||||
applyTheme(newMode);
|
||||
if (newMode === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
localStorage.setItem("theme", newMode);
|
||||
}
|
||||
}, []);
|
||||
}, [applyTheme]);
|
||||
|
||||
// 支持三种状态的切换:light -> dark -> system -> light ...
|
||||
const toggleMode = useCallback(() => {
|
||||
setModeState((prev) => {
|
||||
const newMode = prev === "dark" ? "light" : "dark";
|
||||
document.documentElement.classList.toggle("dark", newMode === "dark");
|
||||
if (newMode === getSystemTheme()) {
|
||||
let newMode: Mode;
|
||||
if (prev === "light") newMode = "dark";
|
||||
else if (prev === "dark") newMode = "system";
|
||||
else newMode = "light";
|
||||
applyTheme(newMode);
|
||||
if (newMode === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
localStorage.setItem("theme", newMode);
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, []);
|
||||
}, [applyTheme]);
|
||||
|
||||
return (
|
||||
<DeviceContext.Provider
|
||||
|
@ -1,18 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDevice() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 简单判断移动端
|
||||
const check = () => {
|
||||
const ua = navigator.userAgent;
|
||||
setIsMobile(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua));
|
||||
};
|
||||
check();
|
||||
window.addEventListener("resize", check);
|
||||
return () => window.removeEventListener("resize", check);
|
||||
}, []);
|
||||
|
||||
return { isMobile };
|
||||
}
|
@ -1,35 +1,35 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export function useStoredState<T>(key: string, defaultValue: T) {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
setValue(JSON.parse(stored));
|
||||
} catch {
|
||||
setValue(stored as T);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
setValue(JSON.parse(stored));
|
||||
} catch {
|
||||
setValue(stored as T);
|
||||
}
|
||||
}, [key]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
// 使用 useCallback 确保 setter 函数引用稳定
|
||||
const setStoredValue = useCallback((newValue: T) => {
|
||||
setValue(newValue);
|
||||
try {
|
||||
localStorage.setItem(key, typeof newValue === 'string' ? newValue : JSON.stringify(newValue));
|
||||
} catch (error) {
|
||||
console.error('Error writing to localStorage:', error);
|
||||
}
|
||||
}, [key]);
|
||||
// 使用 useCallback 确保 setter 函数引用稳定
|
||||
const setStoredValue = useCallback((newValue: T) => {
|
||||
setValue(newValue);
|
||||
try {
|
||||
localStorage.setItem(key, typeof newValue === 'string' ? newValue : JSON.stringify(newValue));
|
||||
} catch (error) {
|
||||
console.error('Error writing to localStorage:', error);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
return [value, setStoredValue, isLoaded] as const;
|
||||
return [value, setStoredValue, isLoaded] as const;
|
||||
}
|
@ -2,6 +2,15 @@
|
||||
"HomePage": {
|
||||
"title": "Hello world!"
|
||||
},
|
||||
"BlogHome": {
|
||||
"hottest": "热门",
|
||||
"hottest_posts": "热门文章",
|
||||
"latest": "最新",
|
||||
"latest_posts": "最新文章",
|
||||
"loading": "加载中...",
|
||||
"load_more": "加载更多",
|
||||
"no_more": "没有更多了!"
|
||||
},
|
||||
"Captcha": {
|
||||
"doing": "正在检查你是不是个人...",
|
||||
"error": "验证失败",
|
||||
|
Reference in New Issue
Block a user