From 709aa82337cdb898b8c0557a3f77d199e08c5d44 Mon Sep 17 00:00:00 2001 From: Snowykami Date: Sat, 20 Sep 2025 12:45:10 +0800 Subject: [PATCH] feat: add new color themes and styles for rose, violet, and yellow - Introduced new CSS files for rose, violet, and yellow themes with custom color variables. - Implemented dark mode styles for each theme. - Created a color data structure to manage theme colors in the console settings. feat: implement image cropper component - Added an image cropper component for user profile picture editing. - Integrated the image cropper into the user profile page. feat: enhance console sidebar with user permissions - Defined sidebar items with permission checks for admin and editor roles. - Updated user center navigation to reflect user permissions. feat: add user profile and security settings - Developed user profile page with avatar upload and editing functionality. - Implemented user security settings for password and email verification. feat: create reusable dialog and OTP input components - Built a dialog component for modal interactions. - Developed an OTP input component for email verification. fix: improve file handling utilities - Added utility functions for file URI generation. - Implemented permission checks for user roles in the common utilities. --- go.mod | 1 + go.sum | 2 + internal/controller/v1/file.go | 128 ++++++++ internal/controller/v1/user.go | 2 +- internal/ctxutils/user.go | 37 +-- internal/model/file.go | 11 + internal/model/user.go | 4 +- internal/repo/file.go | 20 ++ internal/repo/init.go | 1 + internal/router/apiv1/file.go | 15 +- internal/service/comment.go | 300 +++++++++--------- internal/service/user.go | 2 +- pkg/constant/constant.go | 112 ++++--- pkg/errs/errors.go | 3 +- pkg/filedriver/base_driver.go | 34 +- pkg/filedriver/local_driver.go | 3 +- pkg/filedriver/webdav_driver.go | 8 +- pkg/utils/file.go | 30 ++ web/package.json | 2 + web/pnpm-lock.yaml | 26 ++ web/src/api/client.ts | 19 +- web/src/api/file.ts | 25 ++ web/src/api/user.ts | 7 +- web/src/app/console/layout.tsx | 24 +- web/src/app/console/setting/page.tsx | 5 + web/src/app/console/user-preference/page.tsx | 3 + web/src/app/console/user-profile/page.tsx | 5 + web/src/app/console/user-security/page.tsx | 5 + web/src/app/globals.css | 70 +--- web/src/app/styles/blue.css | 68 ++++ web/src/app/styles/green.css | 68 ++++ web/src/app/styles/orange.css | 68 ++++ web/src/app/styles/red.css | 68 ++++ web/src/app/styles/rose.css | 68 ++++ web/src/app/styles/violet.css | 68 ++++ web/src/app/styles/yellow.css | 68 ++++ .../components/blog-home/blog-home-card.tsx | 72 +++-- web/src/components/blog/blog-sidebar-card.tsx | 2 +- web/src/components/comment/comment-input.tsx | 4 +- web/src/components/comment/comment-item.tsx | 14 +- web/src/components/common/image-cropper.tsx | 28 ++ web/src/components/console/app-sidebar.tsx | 36 +-- web/src/components/console/data.ts | 71 +++++ web/src/components/console/nav-documents.tsx | 92 ------ web/src/components/console/nav-main.tsx | 13 +- web/src/components/console/nav-ucenter.tsx | 67 ++++ web/src/components/console/nav-user.tsx | 18 +- web/src/components/console/setting/colors.ts | 23 ++ web/src/components/console/setting/index.tsx | 18 ++ web/src/components/console/site-header.tsx | 4 +- .../components/console/user-profile/index.tsx | 135 ++++++++ .../console/user-security/index.tsx | 84 +++++ .../layout/avatar-with-dropdown-menu.tsx | 2 +- web/src/components/login/login-form.tsx | 13 +- web/src/components/ui/dialog.tsx | 143 +++++++++ web/src/components/ui/input-otp.tsx | 77 +++++ web/src/components/user/user-header.tsx | 4 +- web/src/contexts/auth-context.tsx | 14 +- web/src/models/user.ts | 5 + web/src/utils/client/file.ts | 3 + web/src/utils/common/permission.ts | 9 + web/src/utils/server/file.ts | 0 62 files changed, 1844 insertions(+), 487 deletions(-) create mode 100644 web/src/api/file.ts create mode 100644 web/src/app/console/setting/page.tsx create mode 100644 web/src/app/console/user-preference/page.tsx create mode 100644 web/src/app/console/user-profile/page.tsx create mode 100644 web/src/app/console/user-security/page.tsx create mode 100644 web/src/app/styles/blue.css create mode 100644 web/src/app/styles/green.css create mode 100644 web/src/app/styles/orange.css create mode 100644 web/src/app/styles/red.css create mode 100644 web/src/app/styles/rose.css create mode 100644 web/src/app/styles/violet.css create mode 100644 web/src/app/styles/yellow.css create mode 100644 web/src/components/common/image-cropper.tsx create mode 100644 web/src/components/console/data.ts delete mode 100644 web/src/components/console/nav-documents.tsx create mode 100644 web/src/components/console/nav-ucenter.tsx create mode 100644 web/src/components/console/setting/colors.ts create mode 100644 web/src/components/console/setting/index.tsx create mode 100644 web/src/components/console/user-profile/index.tsx create mode 100644 web/src/components/console/user-security/index.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/components/ui/input-otp.tsx create mode 100644 web/src/utils/client/file.ts create mode 100644 web/src/utils/common/permission.ts create mode 100644 web/src/utils/server/file.ts diff --git a/go.mod b/go.mod index 72f4f4e..3bd8572 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.3 github.com/joho/godotenv v1.5.1 github.com/sirupsen/logrus v1.9.3 + github.com/studio-b12/gowebdav v0.11.0 golang.org/x/crypto v0.31.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gorm.io/driver/postgres v1.6.0 diff --git a/go.sum b/go.sum index b902d98..355b0f5 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU= +github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= diff --git a/internal/controller/v1/file.go b/internal/controller/v1/file.go index b7b1f99..e0743d4 100644 --- a/internal/controller/v1/file.go +++ b/internal/controller/v1/file.go @@ -1 +1,129 @@ package v1 + +import ( + "context" + "io" + "path/filepath" + "strconv" + + "github.com/cloudwego/hertz/pkg/app" + "github.com/sirupsen/logrus" + "github.com/snowykami/neo-blog/internal/ctxutils" + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/internal/repo" + "github.com/snowykami/neo-blog/pkg/filedriver" + "github.com/snowykami/neo-blog/pkg/resps" + "github.com/snowykami/neo-blog/pkg/utils" +) + +type FileController struct{} + +func NewFileController() *FileController { + return &FileController{} +} + +func (f *FileController) UploadFileStream(ctx context.Context, c *app.RequestContext) { + // 获取文件信息 + file, err := c.FormFile("file") + if err != nil { + logrus.Error("无法读取文件: ", err) + resps.BadRequest(c, err.Error()) + return + } + + group := string(c.FormValue("group")) + name := string(c.FormValue("name")) + + // 初始化文件驱动 + driver, err := filedriver.GetFileDriver(filedriver.GetWebdavDriverConfig()) + if err != nil { + logrus.Error("获取文件驱动失败: ", err) + resps.InternalServerError(c, "获取文件驱动失败") + return + } + + // 校验文件哈希 + if hashForm := string(c.FormValue("hash")); hashForm != "" { + dir, fileName := utils.FilePath(hashForm) + storagePath := filepath.Join(dir, fileName) + if _, err := driver.Stat(c, storagePath); err == nil { + resps.Ok(c, "文件已存在", map[string]any{"hash": hashForm}) + return + } + } + + // 打开文件 + src, err := file.Open() + if err != nil { + logrus.Error("无法打开文件: ", err) + resps.BadRequest(c, err.Error()) + return + } + defer src.Close() + + // 计算文件哈希值 + hash, err := utils.FileHashFromStream(src) + if err != nil { + logrus.Error("计算文件哈希失败: ", err) + resps.BadRequest(c, err.Error()) + return + } + + // 根据哈希值生成存储路径 + dir, fileName := utils.FilePath(hash) + storagePath := filepath.Join(dir, fileName) + // 保存文件 + if _, err := src.Seek(0, io.SeekStart); err != nil { + logrus.Error("无法重置文件流位置: ", err) + resps.BadRequest(c, err.Error()) + return + } + if err := driver.Save(c, storagePath, src); err != nil { + logrus.Error("保存文件失败: ", err) + resps.InternalServerError(c, err.Error()) + return + } + // 数据库索引建立 + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + resps.InternalServerError(c, "获取当前用户失败") + return + } + fileModel := &model.File{ + Hash: hash, + UserID: currentUser.ID, + Group: group, + Name: name, + } + + if err := repo.File.Create(fileModel); err != nil { + logrus.Error("数据库索引建立失败: ", err) + resps.InternalServerError(c, "数据库索引建立失败") + return + } + resps.Ok(c, "文件上传成功", map[string]any{"hash": hash, "id": fileModel.ID}) +} + +func (f *FileController) GetFile(ctx context.Context, c *app.RequestContext) { + fileIdString := c.Param("id") + fileId, err := strconv.ParseUint(fileIdString, 10, 64) + if err != nil { + logrus.Error("无效的文件ID: ", err) + resps.BadRequest(c, "无效的文件ID") + return + } + fileModel, err := repo.File.GetByID(uint(fileId)) + if err != nil { + logrus.Error("获取文件信息失败: ", err) + resps.InternalServerError(c, "获取文件信息失败") + return + } + driver, err := filedriver.GetFileDriver(filedriver.GetWebdavDriverConfig()) + if err != nil { + logrus.Error("获取文件驱动失败: ", err) + resps.InternalServerError(c, "获取文件驱动失败") + return + } + filePath := filepath.Join(utils.FilePath(fileModel.Hash)) + driver.Get(c, filePath) +} diff --git a/internal/controller/v1/user.go b/internal/controller/v1/user.go index 835ee4b..621bfa2 100644 --- a/internal/controller/v1/user.go +++ b/internal/controller/v1/user.go @@ -172,7 +172,7 @@ func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) resp, err := u.service.UpdateUser(&updateUserReq) if err != nil { serviceErr := errs.AsServiceError(err) - resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + resps.Custom(c, serviceErr.Code, err.Error(), nil) return } resps.Ok(c, resps.Success, resp) diff --git a/internal/ctxutils/user.go b/internal/ctxutils/user.go index 4236f58..ee627ab 100644 --- a/internal/ctxutils/user.go +++ b/internal/ctxutils/user.go @@ -1,32 +1,33 @@ package ctxutils import ( - "context" - "github.com/snowykami/neo-blog/internal/model" - "github.com/snowykami/neo-blog/internal/repo" - "github.com/snowykami/neo-blog/pkg/constant" + "context" + + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/internal/repo" + "github.com/snowykami/neo-blog/pkg/constant" ) // GetCurrentUser 从上下文中获取当前用户 func GetCurrentUser(ctx context.Context) (*model.User, bool) { - val := ctx.Value(constant.ContextKeyUserID) - if val == nil { - return nil, false - } - user, err := repo.User.GetUserByID(val.(uint)) - if err != nil { - return nil, false - } + val := ctx.Value(constant.ContextKeyUserID) + if val == nil { + return nil, false + } + user, err := repo.User.GetUserByID(val.(uint)) + if err != nil { + return nil, false + } - return user, true + return user, true } // GetCurrentUserID 从上下文中获取当前用户ID func GetCurrentUserID(ctx context.Context) (uint, bool) { - user, ok := GetCurrentUser(ctx) - if !ok || user == nil { - return 0, false - } + user, ok := GetCurrentUser(ctx) + if !ok || user == nil { + return 0, false + } - return user.ID, true + return user.ID, true } diff --git a/internal/model/file.go b/internal/model/file.go index 8b53790..69eac72 100644 --- a/internal/model/file.go +++ b/internal/model/file.go @@ -1 +1,12 @@ package model + +import "gorm.io/gorm" + +type File struct { + gorm.Model + ID uint `gorm:"primaryKey"` // 文件ID File ID + Hash string `gorm:"not null"` // 文件哈希值 File hash + UserID uint `gorm:"not null"` // 上传者ID Uploader ID + Group string // 分组名称 + Name string // 文件名,为空显示未hash +} diff --git a/internal/model/user.go b/internal/model/user.go index 3b7d896..defd269 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -8,10 +8,10 @@ import ( type User struct { gorm.Model Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一 - Nickname string + Nickname string `gorm:"default:''"` // 昵称 AvatarUrl string Email string `gorm:"uniqueIndex"` - Gender string + Gender string `gorm:"default:''"` Role string `gorm:"default:'user'"` // user editor admin Language string `gorm:"default:'en'"` Password string // 密码,存储加密后的值 diff --git a/internal/repo/file.go b/internal/repo/file.go index e0281bf..45192b6 100644 --- a/internal/repo/file.go +++ b/internal/repo/file.go @@ -1 +1,21 @@ package repo + +import "github.com/snowykami/neo-blog/internal/model" + +type FileRepo struct{} + +var File = &FileRepo{} + +func (f *FileRepo) Create(file *model.File) (err error) { + return GetDB().Create(file).Error +} + +func (f *FileRepo) GetByHash(hash string) (file model.File, err error) { + err = GetDB().Where("hash = ?", hash).First(&file).Error + return +} + +func (f *FileRepo) GetByID(id uint) (file model.File, err error) { + err = GetDB().Where("id = ?", id).First(&file).Error + return +} diff --git a/internal/repo/init.go b/internal/repo/init.go index 4385b02..dbcfc1a 100644 --- a/internal/repo/init.go +++ b/internal/repo/init.go @@ -127,6 +127,7 @@ func migrate() error { &model.Comment{}, &model.Label{}, &model.Like{}, + &model.File{}, &model.OidcConfig{}, &model.Post{}, &model.Session{}, diff --git a/internal/router/apiv1/file.go b/internal/router/apiv1/file.go index 67f093b..a21c14d 100644 --- a/internal/router/apiv1/file.go +++ b/internal/router/apiv1/file.go @@ -1,7 +1,18 @@ package apiv1 -import "github.com/cloudwego/hertz/pkg/route" +import ( + "github.com/cloudwego/hertz/pkg/route" + v1 "github.com/snowykami/neo-blog/internal/controller/v1" + "github.com/snowykami/neo-blog/internal/middleware" +) func registerFileRoutes(group *route.RouterGroup) { - // TODO: Impl file routes + fileController := v1.NewFileController() + fileGroup := group.Group("/file").Use(middleware.UseAuth(true)) + fileGroupWithoutAuth := group.Group("/file") + { + fileGroup.POST("/f", fileController.UploadFileStream) // 上传文件 Upload file + fileGroup.DELETE("/f/:id") // TODO: 删除文件 Delete file + fileGroupWithoutAuth.GET("/f/:id", fileController.GetFile) // 下载文件 Download file + } } diff --git a/internal/service/comment.go b/internal/service/comment.go index a68c064..ea8ec48 100644 --- a/internal/service/comment.go +++ b/internal/service/comment.go @@ -1,196 +1,196 @@ package service import ( - "context" - "strconv" + "context" + "strconv" - "github.com/sirupsen/logrus" - "github.com/snowykami/neo-blog/pkg/constant" - "github.com/snowykami/neo-blog/pkg/utils" + "github.com/sirupsen/logrus" + "github.com/snowykami/neo-blog/pkg/constant" + "github.com/snowykami/neo-blog/pkg/utils" - "github.com/snowykami/neo-blog/internal/ctxutils" - "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/pkg/errs" + "github.com/snowykami/neo-blog/internal/ctxutils" + "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/pkg/errs" ) type CommentService struct{} func NewCommentService() *CommentService { - return &CommentService{} + return &CommentService{} } func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) (uint, error) { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return 0, errs.ErrUnauthorized - } + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return 0, errs.ErrUnauthorized + } - if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok { - if err != nil { - return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err) - } - return 0, errs.ErrBadRequest - } + if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok { + if err != nil { + return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err) + } + return 0, errs.ErrBadRequest + } - comment := &model.Comment{ - Content: req.Content, - ReplyID: req.ReplyID, - TargetID: req.TargetID, - TargetType: req.TargetType, - UserID: currentUser.ID, - IsPrivate: req.IsPrivate, - RemoteAddr: req.RemoteAddr, - UserAgent: req.UserAgent, - ShowClientInfo: req.ShowClientInfo, - } + comment := &model.Comment{ + Content: req.Content, + ReplyID: req.ReplyID, + TargetID: req.TargetID, + TargetType: req.TargetType, + UserID: currentUser.ID, + IsPrivate: req.IsPrivate, + RemoteAddr: req.RemoteAddr, + UserAgent: req.UserAgent, + ShowClientInfo: req.ShowClientInfo, + } - commentID, err := repo.Comment.CreateComment(comment) + commentID, err := repo.Comment.CreateComment(comment) - if err != nil { - return 0, err - } + if err != nil { + return 0, err + } - return commentID, nil + return commentID, nil } func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return errs.ErrUnauthorized - } - logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID) + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return errs.ErrUnauthorized + } + logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID) - comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID))) - if err != nil { - return err - } + comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID))) + if err != nil { + return err + } - if currentUser.ID != comment.UserID { - return errs.ErrForbidden - } + if currentUser.ID != comment.UserID { + return errs.ErrForbidden + } - comment.Content = req.Content - comment.IsPrivate = req.IsPrivate - comment.ShowClientInfo = req.ShowClientInfo - err = repo.Comment.UpdateComment(comment) - if err != nil { - return err - } - return nil + comment.Content = req.Content + comment.IsPrivate = req.IsPrivate + comment.ShowClientInfo = req.ShowClientInfo + err = repo.Comment.UpdateComment(comment) + if err != nil { + return err + } + return nil } func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error { - currentUser, ok := ctxutils.GetCurrentUser(ctx) - if !ok { - return errs.ErrUnauthorized - } - if commentID == "" { - return errs.ErrBadRequest - } + currentUser, ok := ctxutils.GetCurrentUser(ctx) + if !ok { + return errs.ErrUnauthorized + } + if commentID == "" { + return errs.ErrBadRequest + } - comment, err := repo.Comment.GetComment(commentID) - if err != nil { - return errs.New(errs.ErrNotFound.Code, "comment not found", err) - } + comment, err := repo.Comment.GetComment(commentID) + if err != nil { + return errs.New(errs.ErrNotFound.Code, "comment not found", err) + } - isTargetOwner := false - if comment.TargetType == constant.TargetTypePost { - post, err := repo.Post.GetPostByID(strconv.Itoa(int(comment.TargetID))) - if err == nil && post.UserID == currentUser.ID { - isTargetOwner = true - } - } + isTargetOwner := false + if comment.TargetType == constant.TargetTypePost { + post, err := repo.Post.GetPostByID(strconv.Itoa(int(comment.TargetID))) + if err == nil && post.UserID == currentUser.ID { + isTargetOwner = true + } + } - if comment.UserID != currentUser.ID && isTargetOwner { - return errs.ErrForbidden - } + if comment.UserID != currentUser.ID && isTargetOwner { + return errs.ErrForbidden + } - if err := repo.Comment.DeleteComment(commentID); err != nil { - return err - } - return nil + if err := repo.Comment.DeleteComment(commentID); err != nil { + return err + } + return nil } func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) { - comment, err := repo.Comment.GetComment(commentID) + comment, err := repo.Comment.GetComment(commentID) - if err != nil { - return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err) - } + if err != nil { + return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err) + } - currentUserID := uint(0) - if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok { - currentUserID = currentUser.ID - } - if comment.IsPrivate && currentUserID != comment.UserID { - return nil, errs.ErrForbidden - } - commentDto := cs.toGetCommentDto(comment, currentUserID) - return &commentDto, err + currentUserID := uint(0) + if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok { + currentUserID = currentUser.ID + } + if comment.IsPrivate && currentUserID != comment.UserID { + return nil, errs.ErrForbidden + } + commentDto := cs.toGetCommentDto(comment, currentUserID) + return &commentDto, err } func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) { - currentUserID := uint(0) - if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok { - currentUserID = currentUser.ID - } - comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth) - if err != nil { - return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err) - } - commentDtos := make([]dto.CommentDto, 0) - for _, comment := range comments { - //replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID) - commentDto := cs.toGetCommentDto(&comment, currentUserID) - commentDtos = append(commentDtos, commentDto) - } - return commentDtos, nil + currentUserID := uint(0) + if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok { + currentUserID = currentUser.ID + } + comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth) + if err != nil { + return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err) + } + commentDtos := make([]dto.CommentDto, 0) + for _, comment := range comments { + //replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID) + commentDto := cs.toGetCommentDto(&comment, currentUserID) + commentDtos = append(commentDtos, commentDto) + } + return commentDtos, nil } func (cs *CommentService) toGetCommentDto(comment *model.Comment, currentUserID uint) dto.CommentDto { - isLiked := false - if currentUserID != 0 { - isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment) - } - ua := utils.ParseUA(comment.UserAgent) - if !comment.ShowClientInfo { - comment.Location = "" - ua.OS = "" - ua.OSVersion = "" - ua.Browser = "" - ua.BrowserVer = "" - } + isLiked := false + if currentUserID != 0 { + isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment) + } + ua := utils.ParseUA(comment.UserAgent) + if !comment.ShowClientInfo { + comment.Location = "" + ua.OS = "" + ua.OSVersion = "" + ua.Browser = "" + ua.BrowserVer = "" + } - return dto.CommentDto{ - ID: comment.ID, - Content: comment.Content, - TargetID: comment.TargetID, - TargetType: comment.TargetType, - ReplyID: comment.ReplyID, - CreatedAt: comment.CreatedAt.String(), - UpdatedAt: comment.UpdatedAt.String(), - Depth: comment.Depth, - User: comment.User.ToDto(), - ReplyCount: comment.CommentCount, - LikeCount: comment.LikeCount, - IsLiked: isLiked, - IsPrivate: comment.IsPrivate, - OS: ua.OS + " " + ua.OSVersion, - Browser: ua.Browser + " " + ua.BrowserVer, - Location: comment.Location, - ShowClientInfo: comment.ShowClientInfo, - } + return dto.CommentDto{ + ID: comment.ID, + Content: comment.Content, + TargetID: comment.TargetID, + TargetType: comment.TargetType, + ReplyID: comment.ReplyID, + CreatedAt: comment.CreatedAt.String(), + UpdatedAt: comment.UpdatedAt.String(), + Depth: comment.Depth, + User: comment.User.ToDto(), + ReplyCount: comment.CommentCount, + LikeCount: comment.LikeCount, + IsLiked: isLiked, + IsPrivate: comment.IsPrivate, + OS: ua.OS + " " + ua.OSVersion, + Browser: ua.Browser + " " + ua.BrowserVer, + Location: comment.Location, + ShowClientInfo: comment.ShowClientInfo, + } } func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) { - switch targetType { - case constant.TargetTypePost: - if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil { - return false, errs.New(errs.ErrNotFound.Code, "post not found", err) - } - default: - return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil) - } - return true, nil + switch targetType { + case constant.TargetTypePost: + if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil { + return false, errs.New(errs.ErrNotFound.Code, "post not found", err) + } + default: + return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil) + } + return true, nil } diff --git a/internal/service/user.go b/internal/service/user.go index 7ef6137..9940fb9 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -368,7 +368,7 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e return nil, errs.ErrNotFound } logrus.Errorln("Failed to update user:", err) - return nil, errs.ErrInternalServer + return nil, err } return &dto.UpdateUserResp{}, nil } diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index 55a3f25..af9cdec 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -1,57 +1,69 @@ package constant const ( - CaptchaTypeDisable = "disable" // 禁用验证码 - CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 - CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 - CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 - ContextKeyUserID = "user_id" // 上下文键:用户ID - ContextKeyRemoteAddr = "remote_addr" // 上下文键:远程地址 - ContextKeyUserAgent = "user_agent" // 上下文键:用户代理 - ModeDev = "dev" - ModeProd = "prod" - RoleUser = "user" // 普通用户 仅有阅读和评论权限 - RoleEditor = "editor" // 能够发布和管理自己内容的用户 - RoleAdmin = "admin" - EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL - EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 - EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 - EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url - EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key - EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式 - EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 - EnvKeyMode = "MODE" // 环境变量:运行模式 - EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 - EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 - EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 - EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 - EnvKeyTokenDurationDefault = 30 // Token有效时长 - EnvKeyRefreshTokenDurationDefault = 6000000 // refresh token有效时长 - EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 - EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 - KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 - KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 - ApiSuffix = "/api/v1" // API版本前缀 - OidcUri = "/user/oidc/login" // OIDC登录URI - OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey - OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub - DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl - TargetTypePost = "post" - TargetTypeComment = "comment" - OrderByCreatedAt = "created_at" // 按创建时间排序 - OrderByUpdatedAt = "updated_at" // 按更新时间排序 - OrderByLikeCount = "like_count" // 按点赞数排序 - OrderByCommentCount = "comment_count" // 按评论数排序 - OrderByViewCount = "view_count" // 按浏览量排序 - OrderByHeat = "heat" - MaxReplyDepthDefault = 3 // 默认最大回复深度 - HeatFactorViewWeight = 1 // 热度因子:浏览量权重 - HeatFactorLikeWeight = 5 // 热度因子:点赞权重 - HeatFactorCommentWeight = 10 // 热度因子:评论权重 - PageLimitDefault = 20 // 默认分页大小 + CaptchaTypeDisable = "disable" // 禁用验证码 + CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 + CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 + CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 + ContextKeyUserID = "user_id" // 上下文键:用户ID + ContextKeyRemoteAddr = "remote_addr" // 上下文键:远程地址 + ContextKeyUserAgent = "user_agent" // 上下文键:用户代理 + ModeDev = "dev" + ModeProd = "prod" + RoleUser = "user" // 普通用户 仅有阅读和评论权限 + RoleEditor = "editor" // 能够发布和管理自己内容的用户 + RoleAdmin = "admin" + DefaultFileBasePath = "./data/uploads" + EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL + EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 + EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 + EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url + EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key + EnvKeyFileDriverType = "FILE_DRIVER_TYPE" + EnvKeyFileBasepath = "FILE_BASEPATH" + EnvKeyFileWebdavUrl = "FILE_WEBDAV_URL" + EnvKeyFileWebdavPassword = "FILE_WEBDAV_PASSWORD" + EnvKeyFileWebdavPolicy = "FILE_WEBDAV_POLICY" + EnvKeyFileWebdavUser = "FILE_WEBDAV_USER" + EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式 + EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 + EnvKeyMode = "MODE" // 环境变量:运行模式 + EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 + EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 + EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 + EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 + EnvKeyTokenDurationDefault = 500 // Token有效时长 + EnvKeyRefreshTokenDurationDefault = 6000000 // refresh token有效时长 + EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 + EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 + FileDriverTypeLocal = "local" + FileDriverTypeWebdav = "webdav" + FileDriverTypeS3 = "s3" + KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 + KVKeyOidcState = "oidc_state:" // KV存储:OIDC状态 + ApiSuffix = "/api/v1" // API版本前缀 + OidcUri = "/user/oidc/login" // OIDC登录URI + OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey + OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub + DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl + TargetTypePost = "post" + TargetTypeComment = "comment" + WebdavPolicyProxy = "proxy" + WebdavPolicyRedirect = "redirect" + OrderByCreatedAt = "created_at" // 按创建时间排序 + OrderByUpdatedAt = "updated_at" // 按更新时间排序 + OrderByLikeCount = "like_count" // 按点赞数排序 + OrderByCommentCount = "comment_count" // 按评论数排序 + OrderByViewCount = "view_count" // 按浏览量排序 + OrderByHeat = "heat" + MaxReplyDepthDefault = 3 // 默认最大回复深度 + HeatFactorViewWeight = 1 // 热度因子:浏览量权重 + HeatFactorLikeWeight = 5 // 热度因子:点赞权重 + HeatFactorCommentWeight = 10 // 热度因子:评论权重 + PageLimitDefault = 20 // 默认分页大小 ) var ( - OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 - OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式 + OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 + OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式 ) diff --git a/pkg/errs/errors.go b/pkg/errs/errors.go index 0c1aa28..81ca3cf 100644 --- a/pkg/errs/errors.go +++ b/pkg/errs/errors.go @@ -2,8 +2,9 @@ package errs import ( "errors" - "github.com/cloudwego/hertz/pkg/app" "net/http" + + "github.com/cloudwego/hertz/pkg/app" ) // ServiceError 业务错误结构 diff --git a/pkg/filedriver/base_driver.go b/pkg/filedriver/base_driver.go index 8cb2869..f9938c0 100644 --- a/pkg/filedriver/base_driver.go +++ b/pkg/filedriver/base_driver.go @@ -2,10 +2,13 @@ package filedriver import ( "fmt" - "github.com/LiteyukiStudio/spage/pkg/constants" - "github.com/cloudwego/hertz/pkg/app" + "io" "os" + + "github.com/cloudwego/hertz/pkg/app" + "github.com/snowykami/neo-blog/pkg/constant" + "github.com/snowykami/neo-blog/pkg/utils" ) type FileDriver interface { @@ -18,19 +21,30 @@ type FileDriver interface { } type DriverConfig struct { - Type string `mapstructure:"file.driver.type"` - BasePath string `mapstructure:"file.driver.base_path"` - WebDavUrl string `mapstructure:"file.driver.webdav.url"` - WebDavUser string `mapstructure:"file.driver.webdav.user"` - WebDavPassword string `mapstructure:"file.driver.webdav.password"` - WebDavPolicy string `mapstructure:"file.driver.webdav.policy"` // proxy|redirect + Type string + BasePath string + WebDavUrl string + WebDavUser string + WebDavPassword string + WebDavPolicy string +} + +func GetWebdavDriverConfig() *DriverConfig { + return &DriverConfig{ + Type: utils.Env.Get(constant.EnvKeyFileDriverType, constant.FileDriverTypeLocal), + BasePath: utils.Env.Get(constant.EnvKeyFileBasepath, constant.DefaultFileBasePath), + WebDavUrl: utils.Env.Get(constant.EnvKeyFileWebdavUrl), + WebDavUser: utils.Env.Get(constant.EnvKeyFileWebdavUser), + WebDavPassword: utils.Env.Get(constant.EnvKeyFileWebdavPassword), + WebDavPolicy: utils.Env.Get(constant.EnvKeyFileWebdavPolicy), + } } func GetFileDriver(driverConfig *DriverConfig) (FileDriver, error) { switch driverConfig.Type { - case constants.FileDriverLocal: + case constant.FileDriverTypeLocal: return NewLocalDriver(driverConfig), nil - case constants.FileDriverWebdav: + case constant.FileDriverTypeWebdav: return NewWebDAVClientDriver(driverConfig), nil default: return nil, fmt.Errorf("unsupported file driver type: %s", driverConfig.Type) diff --git a/pkg/filedriver/local_driver.go b/pkg/filedriver/local_driver.go index c5486e0..69bcbdd 100644 --- a/pkg/filedriver/local_driver.go +++ b/pkg/filedriver/local_driver.go @@ -1,10 +1,11 @@ package filedriver import ( - "github.com/cloudwego/hertz/pkg/app" "io" "os" "path/filepath" + + "github.com/cloudwego/hertz/pkg/app" ) type LocalDriver struct { diff --git a/pkg/filedriver/webdav_driver.go b/pkg/filedriver/webdav_driver.go index 2ea90f5..7c955ff 100644 --- a/pkg/filedriver/webdav_driver.go +++ b/pkg/filedriver/webdav_driver.go @@ -3,9 +3,11 @@ package filedriver import ( "bytes" "fmt" - "github.com/LiteyukiStudio/spage/pkg/constants" - "github.com/LiteyukiStudio/spage/pkg/resps" + "github.com/cloudwego/hertz/pkg/app" + "github.com/snowykami/neo-blog/pkg/constant" + "github.com/snowykami/neo-blog/pkg/resps" + "io" "os" "path" @@ -48,7 +50,7 @@ func (d *WebDAVClientDriver) Open(ctx *app.RequestContext, p string) (io.ReadClo } func (d *WebDAVClientDriver) Get(ctx *app.RequestContext, p string) { - if d.config.WebDavPolicy == constants.WebDavPolicyRedirect { + if d.config.WebDavPolicy == constant.WebdavPolicyRedirect { ctx.Redirect(302, []byte(d.config.WebDavUrl+d.fullPath(p))) return } else { diff --git a/pkg/utils/file.go b/pkg/utils/file.go index d4b585b..faa484a 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -1 +1,31 @@ package utils + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "mime/multipart" +) + +// FilePath 根据哈希值生成文件路径,前4位为目录位hash[0:4]/hash +func FilePath(hash string) (dir, file string) { + dir = hash[0:4] + file = hash + return +} + +func FileHashFromStream(file multipart.File) (string, error) { + // 创建哈希计算器 + hash := sha256.New() + + // 将文件流内容拷贝到哈希计算器 + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + // 计算哈希值并转换为十六进制字符串 + hashInBytes := hash.Sum(nil) + hashString := hex.EncodeToString(hashInBytes) + + return hashString, nil +} diff --git a/web/package.json b/web/package.json index 6d856f6..ababb63 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "deepmerge": "^4.3.1", "field-conv": "^1.0.9", "highlight.js": "^11.11.1", + "input-otp": "^1.4.2", "lucide-react": "^0.525.0", "md5": "^2.3.0", "motion": "^12.23.12", @@ -48,6 +49,7 @@ "react-dom": "19.1.0", "react-google-recaptcha-v3": "^1.11.0", "react-icons": "^5.5.0", + "react-image-crop": "^11.0.10", "recharts": "2.15.4", "rehype-highlight": "^7.0.2", "sonner": "^2.0.6", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1282d97..faa33d3 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + input-otp: + specifier: ^1.4.2 + version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.1.0) @@ -125,6 +128,9 @@ importers: react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.1.0) + react-image-crop: + specifier: ^11.0.10 + version: 11.0.10(react@19.1.0) recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2137,6 +2143,12 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2787,6 +2799,11 @@ packages: peerDependencies: react: '*' + react-image-crop@11.0.10: + resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5408,6 +5425,11 @@ snapshots: inline-style-parser@0.2.4: {} + input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6239,6 +6261,10 @@ snapshots: dependencies: react: 19.1.0 + react-image-crop@11.0.10(react@19.1.0): + dependencies: + react: 19.1.0 + react-is@16.13.1: {} react-is@18.3.1: {} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index cf8a730..166098c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,9 +14,26 @@ const axiosClient = axios.create({ timeout: 10000, }) +function isBrowserFormData(v: any) { + return typeof FormData !== 'undefined' && v instanceof FormData +} +// node form-data (form-data package) heuristic +function isNodeFormData(v: any) { + return v && typeof v.getHeaders === 'function' && typeof v.pipe === 'function' +} + axiosClient.interceptors.request.use((config) => { - if (config.data && typeof config.data === 'object') { + // 如果是 FormData(浏览器)或 node form-data,跳过对象转换 + if (config.data && typeof config.data === 'object' && !isBrowserFormData(config.data) && !isNodeFormData(config.data)) { config.data = camelToSnakeObj(config.data) + } else if (isBrowserFormData(config.data)) { + // 只处理键 + const formData = config.data as FormData + const newFormData = new FormData() + for (const [key, value] of formData.entries()) { + newFormData.append(camelToSnakeObj(key), value) + } + config.data = newFormData } if (config.params && typeof config.params === 'object') { config.params = camelToSnakeObj(config.params) diff --git a/web/src/api/file.ts b/web/src/api/file.ts new file mode 100644 index 0000000..4d3a110 --- /dev/null +++ b/web/src/api/file.ts @@ -0,0 +1,25 @@ +import { BaseResponse } from '@/models/resp' +import axiosClient from './client' + +export async function uploadFile({ file, name, group }: { file: File, name?: string, group?: string }): Promise> { + if (typeof window === 'undefined') { + throw new Error('uploadFile can only be used in the browser') + } + if (!file) { + throw new Error('No file provided') + } + const formData = new FormData() + formData.append('file', file) + formData.append('name', name || file.name) + formData.append('group', group || '') + const res = await axiosClient.post>('/file/f', formData, { + withCredentials: true, + }) + return res.data +} \ No newline at end of file diff --git a/web/src/api/user.ts b/web/src/api/user.ts index c3d64d1..bd67a8c 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,8 +1,8 @@ import type { OidcConfig } from '@/models/oidc-config' import type { BaseResponse } from '@/models/resp' import type { RegisterRequest, User } from '@/models/user' -import axiosClient from './client' import { CaptchaProvider } from '@/models/captcha' +import axiosClient from './client' export async function userLogin( { @@ -83,4 +83,9 @@ export async function getCaptchaConfig(): Promise>('/user/captcha') return res.data +} + +export async function updateUser(data: Partial): Promise> { + const res = await axiosClient.put>(`/user/u/${data.id}`, data) + return res.data } \ No newline at end of file diff --git a/web/src/app/console/layout.tsx b/web/src/app/console/layout.tsx index aa053af..312059c 100644 --- a/web/src/app/console/layout.tsx +++ b/web/src/app/console/layout.tsx @@ -7,8 +7,10 @@ import { } from "@/components/ui/sidebar" import { useToLogin } from "@/hooks/use-route" -import { useEffect } from "react" +import { useEffect, useState } from "react" import { useAuth } from "@/contexts/auth-context" +import { sidebarData, SidebarItem } from "@/components/console/data" +import { usePathname } from "next/navigation" export default function ConsoleLayout({ children, @@ -16,7 +18,21 @@ export default function ConsoleLayout({ children: React.ReactNode; }>) { const { user } = useAuth(); + const [title, setTitle] = useState("Title"); const toLogin = useToLogin(); + const pathname = usePathname() ?? "/" + + const sideBarItems: SidebarItem[] = sidebarData.navMain.concat(sidebarData.navUserCenter); + + useEffect(() => { + const currentItem = sideBarItems.find(item => item.url === pathname); + if (currentItem) { + setTitle(currentItem.title); + document.title = `${currentItem.title} - 控制台`; + } else { + setTitle("Title"); + } + }, [pathname]) useEffect(() => { if (!user) { @@ -35,8 +51,10 @@ export default function ConsoleLayout({ > - - {children} + +
+ {children} +
) diff --git a/web/src/app/console/setting/page.tsx b/web/src/app/console/setting/page.tsx new file mode 100644 index 0000000..05e6b59 --- /dev/null +++ b/web/src/app/console/setting/page.tsx @@ -0,0 +1,5 @@ +import SettingPage from "@/components/console/setting"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/web/src/app/console/user-preference/page.tsx b/web/src/app/console/user-preference/page.tsx new file mode 100644 index 0000000..e07a5d3 --- /dev/null +++ b/web/src/app/console/user-preference/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
个性化设置
+} \ No newline at end of file diff --git a/web/src/app/console/user-profile/page.tsx b/web/src/app/console/user-profile/page.tsx new file mode 100644 index 0000000..ebe9040 --- /dev/null +++ b/web/src/app/console/user-profile/page.tsx @@ -0,0 +1,5 @@ +import { UserProfilePage } from "@/components/console/user-profile"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/web/src/app/console/user-security/page.tsx b/web/src/app/console/user-security/page.tsx new file mode 100644 index 0000000..1c1b61b --- /dev/null +++ b/web/src/app/console/user-security/page.tsx @@ -0,0 +1,5 @@ +import { UserSecurityPage } from "@/components/console/user-security"; + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 520b9b7..26ff857 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import "./styles/violet.css"; @custom-variant dark (&:is(.dark *)); @@ -43,75 +44,6 @@ --radius-xl: calc(var(--radius) + 4px); } -:root { - --radius: 0.65rem; - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.623 0.214 259.815); - --primary-foreground: oklch(0.97 0.014 254.604); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.623 0.214 259.815); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); - --sidebar-primary: oklch(0.623 0.214 259.815); - --sidebar-primary-foreground: oklch(0.97 0.014 254.604); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.623 0.214 259.815); -} - -.dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.546 0.245 262.881); - --primary-foreground: oklch(0.379 0.146 265.522); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.488 0.243 264.376); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.546 0.245 262.881); - --sidebar-primary-foreground: oklch(0.379 0.146 265.522); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.488 0.243 264.376); -} - :root { --animation-duration: 0.6s; } diff --git a/web/src/app/styles/blue.css b/web/src/app/styles/blue.css new file mode 100644 index 0000000..85e9237 --- /dev/null +++ b/web/src/app/styles/blue.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.623 0.214 259.815); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.97 0.014 254.604); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.623 0.214 259.815); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.379 0.146 265.522); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.488 0.243 264.376); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.379 0.146 265.522); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.488 0.243 264.376); +} diff --git a/web/src/app/styles/green.css b/web/src/app/styles/green.css new file mode 100644 index 0000000..50a48a8 --- /dev/null +++ b/web/src/app/styles/green.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.723 0.219 149.579); + --primary-foreground: oklch(0.982 0.018 155.826); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.723 0.219 149.579); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.723 0.219 149.579); + --sidebar-primary-foreground: oklch(0.982 0.018 155.826); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.723 0.219 149.579); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.696 0.17 162.48); + --primary-foreground: oklch(0.393 0.095 152.535); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.527 0.154 150.069); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.696 0.17 162.48); + --sidebar-primary-foreground: oklch(0.393 0.095 152.535); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.527 0.154 150.069); +} diff --git a/web/src/app/styles/orange.css b/web/src/app/styles/orange.css new file mode 100644 index 0000000..2953a38 --- /dev/null +++ b/web/src/app/styles/orange.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.705 0.213 47.604); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.213 47.604); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.705 0.213 47.604); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.213 47.604); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.646 0.222 41.116); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.646 0.222 41.116); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.646 0.222 41.116); + --sidebar-primary-foreground: oklch(0.98 0.016 73.684); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.646 0.222 41.116); +} diff --git a/web/src/app/styles/red.css b/web/src/app/styles/red.css new file mode 100644 index 0000000..2ca606b --- /dev/null +++ b/web/src/app/styles/red.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.637 0.237 25.331); + --primary-foreground: oklch(0.971 0.013 17.38); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.637 0.237 25.331); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.637 0.237 25.331); + --sidebar-primary-foreground: oklch(0.971 0.013 17.38); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.637 0.237 25.331); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.637 0.237 25.331); + --primary-foreground: oklch(0.971 0.013 17.38); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.637 0.237 25.331); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.637 0.237 25.331); + --sidebar-primary-foreground: oklch(0.971 0.013 17.38); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.637 0.237 25.331); +} diff --git a/web/src/app/styles/rose.css b/web/src/app/styles/rose.css new file mode 100644 index 0000000..04b3f87 --- /dev/null +++ b/web/src/app/styles/rose.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.645 0.246 16.439); + --primary-foreground: oklch(0.969 0.015 12.422); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.645 0.246 16.439); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.645 0.246 16.439); + --sidebar-primary-foreground: oklch(0.969 0.015 12.422); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.645 0.246 16.439); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.645 0.246 16.439); + --primary-foreground: oklch(0.969 0.015 12.422); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.645 0.246 16.439); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.645 0.246 16.439); + --sidebar-primary-foreground: oklch(0.969 0.015 12.422); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.645 0.246 16.439); +} diff --git a/web/src/app/styles/violet.css b/web/src/app/styles/violet.css new file mode 100644 index 0000000..471c981 --- /dev/null +++ b/web/src/app/styles/violet.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.606 0.25 292.717); + --primary-foreground: oklch(0.969 0.016 293.756); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.606 0.25 292.717); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.606 0.25 292.717); + --sidebar-primary-foreground: oklch(0.969 0.016 293.756); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.606 0.25 292.717); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.541 0.281 293.009); + --primary-foreground: oklch(0.969 0.016 293.756); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.541 0.281 293.009); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.541 0.281 293.009); + --sidebar-primary-foreground: oklch(0.969 0.016 293.756); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.541 0.281 293.009); +} diff --git a/web/src/app/styles/yellow.css b/web/src/app/styles/yellow.css new file mode 100644 index 0000000..dec73b8 --- /dev/null +++ b/web/src/app/styles/yellow.css @@ -0,0 +1,68 @@ +:root { + --radius: 0.65rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.795 0.184 86.047); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.421 0.095 57.708); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.795 0.184 86.047); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.795 0.184 86.047); + --primary-foreground: oklch(0.421 0.095 57.708); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.554 0.135 66.442); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.795 0.184 86.047); + --sidebar-primary-foreground: oklch(0.421 0.095 57.708); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.554 0.135 66.442); +} diff --git a/web/src/components/blog-home/blog-home-card.tsx b/web/src/components/blog-home/blog-home-card.tsx index d48b80c..9254f24 100644 --- a/web/src/components/blog-home/blog-home-card.tsx +++ b/web/src/components/blog-home/blog-home-card.tsx @@ -9,6 +9,7 @@ import { cn } from '@/lib/utils' import { getPostHref } from '@/utils/common/post' import { motion } from 'motion/react' import { deceleration } from '@/motion/curve' +import { Skeleton } from '@/components/ui/skeleton' export function BlogCard({ post, className }: { @@ -158,37 +159,58 @@ export function BlogCard({ post, className }: { // 骨架屏加载组件 - 使用 shadcn Card 结构 export function BlogCardSkeleton() { return ( - - {/* 封面图片骨架 */} -
+ + {/* 封面骨架 */} +
+ - {/* Header 骨架 */} - -
-
-
-
-
+ {/* 覆盖层(模拟暗色遮罩) */} +
+ + {/* 私有标识骨架 */} +
+
+ + {/* 统计信息骨架 */} +
+
+ + + +
+
+ + {/* 热度骨架 */} +
+ +
+
+ + {/* 标题骨架 */} + + + + - {/* Content 骨架 */} - -
-
-
-
-
-
-
-
-
+ {/* 内容骨架 */} + + +
+ + + +
+
- {/* Footer 骨架 */} - -
-
+ {/* 底部骨架 */} + +
+ +
+
) diff --git a/web/src/components/blog/blog-sidebar-card.tsx b/web/src/components/blog/blog-sidebar-card.tsx index 8cd49e9..1a4b25a 100644 --- a/web/src/components/blog/blog-sidebar-card.tsx +++ b/web/src/components/blog/blog-sidebar-card.tsx @@ -59,7 +59,7 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType: - {sortType === 'latest' ? '热门文章' : '最新文章'} + {sortType === 'latest' ? '最新文章' : '热门文章'} diff --git a/web/src/components/comment/comment-input.tsx b/web/src/components/comment/comment-input.tsx index 8c6dd78..0eb20d1 100644 --- a/web/src/components/comment/comment-input.tsx +++ b/web/src/components/comment/comment-input.tsx @@ -8,7 +8,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { getGravatarUrl } from "@/utils/common/gravatar"; +import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar"; import { getFirstCharFromUser } from "@/utils/common/username"; import { useAuth } from "@/contexts/auth-context"; @@ -68,7 +68,7 @@ export function CommentInput(
clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in"> {user && - + {getFirstCharFromUser(user)} } {!user && } diff --git a/web/src/components/comment/comment-item.tsx b/web/src/components/comment/comment-item.tsx index f2d069b..5bd971b 100644 --- a/web/src/components/comment/comment-item.tsx +++ b/web/src/components/comment/comment-item.tsx @@ -13,7 +13,7 @@ import { createComment, deleteComment, getComment, listComments, updateComment } import { OrderBy } from "@/models/common"; import { formatDateTime } from "@/utils/common/datetime"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { getGravatarUrl } from "@/utils/common/gravatar"; +import { getGravatarFromUser, getGravatarUrl } from "@/utils/common/gravatar"; import { getFirstCharFromUser } from "@/utils/common/username"; import { useAuth } from "@/contexts/auth-context"; @@ -35,7 +35,7 @@ export function CommentItem( onReplySubmitted: ({ commentContent, isPrivate }: { commentContent: string, isPrivate: boolean }) => void, } ) { - const {user} = useAuth(); + const { user } = useAuth(); const locale = useLocale(); const t = useTranslations("Comment"); const commonT = useTranslations("Common"); @@ -160,9 +160,9 @@ export function CommentItem(
clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12"> - - {getFirstCharFromUser(commentState.user)} - + + {getFirstCharFromUser(commentState.user)} +
@@ -234,7 +234,7 @@ export function CommentItem( >
{likeCount}
- + {/* 编辑和删除按钮 仅自己的评论可见 */} {user?.id === commentState.user.id && ( <> @@ -271,7 +271,7 @@ export function CommentItem( )} - +
{/* 这俩输入框一次只能显示一个 */} diff --git a/web/src/components/common/image-cropper.tsx b/web/src/components/common/image-cropper.tsx new file mode 100644 index 0000000..6278b3e --- /dev/null +++ b/web/src/components/common/image-cropper.tsx @@ -0,0 +1,28 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +export function ImageCropper({ image, onCropped, onCancel }: { image: File, onCropped: (blob: Blob) => void, onCancel: () => void }) { + return ( + +
+ + + + + + +
+
+ ) +} diff --git a/web/src/components/console/app-sidebar.tsx b/web/src/components/console/app-sidebar.tsx index c992ff3..9e3f694 100644 --- a/web/src/components/console/app-sidebar.tsx +++ b/web/src/components/console/app-sidebar.tsx @@ -17,37 +17,10 @@ import { } from "@/components/ui/sidebar" import config from "@/config" import Link from "next/link" -import { Folder, Gauge, MessageCircle, Newspaper, Users } from "lucide-react" +import { NavUserCenter } from "./nav-ucenter" +import { sidebarData } from "./data" + -const data = { - navMain: [ - { - title: "大石坝", - url: "/console", - icon: Gauge, - }, - { - title: "文章管理", - url: "/console/post", - icon: Newspaper, - }, - { - title: "评论管理", - url: "/console/comment", - icon: MessageCircle, - }, - { - title: "文件管理", - url: "/console/file", - icon: Folder, - }, - { - title: "用户管理", - url: "/console/user", - icon: Users, - }, - ] -} export function AppSidebar({ ...props }: React.ComponentProps) { return ( @@ -68,7 +41,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + + diff --git a/web/src/components/console/data.ts b/web/src/components/console/data.ts new file mode 100644 index 0000000..6ef678c --- /dev/null +++ b/web/src/components/console/data.ts @@ -0,0 +1,71 @@ +import type { User } from "@/models/user"; +import { isAdmin, isEditor } from "@/utils/common/permission"; +import { Folder, Gauge, MessageCircle, Newspaper, Palette, Settings, ShieldCheck, UserPen, Users } from "lucide-react"; + +export interface SidebarItem { + title: string; + url: string; + icon: React.ComponentType; + permission: ({ user }: { user: User }) => boolean; +} + +export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = { + navMain: [ + { + title: "大石坝", + url: "/console", + icon: Gauge, + permission: isAdmin + }, + { + title: "文章管理", + url: "/console/post", + icon: Newspaper, + permission: isEditor + }, + { + title: "评论管理", + url: "/console/comment", + icon: MessageCircle, + permission: isEditor + }, + { + title: "文件管理", + url: "/console/file", + icon: Folder, + permission: () => true + }, + { + title: "用户管理", + url: "/console/user", + icon: Users, + permission: isAdmin + }, + { + title: "全局设置", + url: "/console/setting", + icon: Settings, + permission: isAdmin + }, + ], + navUserCenter: [ + { + title: "个人资料", + url: "/console/user-profile", + icon: UserPen, + permission: () => true + }, + { + title: "安全设置", + url: "/console/user-security", + icon: ShieldCheck, + permission: () => true + }, + { + title: "个性化", + url: "/console/user-preference", + icon: Palette, + permission: () => true + } + ] +} \ No newline at end of file diff --git a/web/src/components/console/nav-documents.tsx b/web/src/components/console/nav-documents.tsx deleted file mode 100644 index b551e71..0000000 --- a/web/src/components/console/nav-documents.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client" - -import { - IconDots, - IconFolder, - IconShare3, - IconTrash, - type Icon, -} from "@tabler/icons-react" - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar" - -export function NavDocuments({ - items, -}: { - items: { - name: string - url: string - icon: Icon - }[] -}) { - const { isMobile } = useSidebar() - - return ( - - Documents - - {items.map((item) => ( - - - - - {item.name} - - - - - - - More - - - - - - Open - - - - Share - - - - - Delete - - - - - ))} - - - - More - - - - - ) -} diff --git a/web/src/components/console/nav-main.tsx b/web/src/components/console/nav-main.tsx index 9d60a6d..05ca650 100644 --- a/web/src/components/console/nav-main.tsx +++ b/web/src/components/console/nav-main.tsx @@ -3,6 +3,7 @@ import { SidebarGroup, SidebarGroupContent, + SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -11,6 +12,8 @@ import Link from "next/link" import type { LucideProps } from "lucide-react"; import { ComponentType, SVGProps } from "react" import { usePathname } from "next/navigation"; +import { User } from "@/models/user"; +import { useAuth } from "@/contexts/auth-context"; export function NavMain({ items, @@ -19,19 +22,23 @@ export function NavMain({ title: string url: string icon?: ComponentType & LucideProps>; + permission: ({ user }: { user: User }) => boolean }[] }) { + const { user } = useAuth(); const pathname = usePathname() ?? "/" - console.log("pathname", pathname) + + if (!user) return null; return ( + General {items.map((item) => ( - + item.permission({ user }) && - + {item.icon && } {item.title} diff --git a/web/src/components/console/nav-ucenter.tsx b/web/src/components/console/nav-ucenter.tsx new file mode 100644 index 0000000..b7769b1 --- /dev/null +++ b/web/src/components/console/nav-ucenter.tsx @@ -0,0 +1,67 @@ +"use client" + +import { + IconDots, + IconFolder, + IconShare3, + IconTrash, + type Icon, +} from "@tabler/icons-react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" +import { ComponentType, SVGProps } from "react" +import { LucideProps } from "lucide-react" +import { User } from "@/models/user" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { useAuth } from "@/contexts/auth-context" + +export function NavUserCenter({ + items, +}: { + items: { + title: string + url: string + icon?: ComponentType & LucideProps>; + permission: ({ user }: { user: User }) => boolean + }[] +}) { + const { isMobile } = useSidebar() + const { user } = useAuth(); + const pathname = usePathname() ?? "/" + + if (!user) return null; + + return ( + + Personal + + {items.map((item) => ( + item.permission({ user }) && + + + {item.icon && } + {item.title} + + + + ))} + + + ) +} diff --git a/web/src/components/console/nav-user.tsx b/web/src/components/console/nav-user.tsx index 065bfec..0548a97 100644 --- a/web/src/components/console/nav-user.tsx +++ b/web/src/components/console/nav-user.tsx @@ -28,14 +28,24 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar" -import { User } from "@/models/user" + import { getGravatarFromUser } from "@/utils/common/gravatar" import { getFallbackAvatarFromUsername } from "@/utils/common/username" import { useAuth } from "@/contexts/auth-context" +import { userLogout } from "@/api/user" +import { toast } from "sonner" -export function NavUser({}: {}) { +export function NavUser({ }: {}) { const { isMobile } = useSidebar() - const {user} = useAuth(); + const { user } = useAuth(); + + const handleLogout = () => { + userLogout().then(() => { + toast.success("Logged out successfully"); + window.location.reload(); + }) + } + if (!user) return null return ( @@ -95,7 +105,7 @@ export function NavUser({}: {}) { - + Log out diff --git a/web/src/components/console/setting/colors.ts b/web/src/components/console/setting/colors.ts new file mode 100644 index 0000000..00941f6 --- /dev/null +++ b/web/src/components/console/setting/colors.ts @@ -0,0 +1,23 @@ +export const colorData = { + "red": { + "primary": "oklch(0.637 0.237 25.331)", + }, + "rose": { + "primary": "oklch(0.645 0.246 16.439)", + }, + "orange": { + "primary": "oklch(0.705 0.213 47.604)", + }, + "green": { + "primary": "oklch(0.723 0.219 149.579)", + }, + "blue": { + "primary": "oklch(0.623 0.214 259.815)", + }, + "yellow": { + "primary": "oklch(0.795 0.184 86.047)", + }, + "violet": { + "primary": "oklch(0.606 0.25 292.717)", + }, +} \ No newline at end of file diff --git a/web/src/components/console/setting/index.tsx b/web/src/components/console/setting/index.tsx new file mode 100644 index 0000000..955ab2f --- /dev/null +++ b/web/src/components/console/setting/index.tsx @@ -0,0 +1,18 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function SettingPage() { + return
+

+ 全局设置 +

+
+ + +
+
; +} + +export function ColorPick() { + +} \ No newline at end of file diff --git a/web/src/components/console/site-header.tsx b/web/src/components/console/site-header.tsx index 59c4f02..e586039 100644 --- a/web/src/components/console/site-header.tsx +++ b/web/src/components/console/site-header.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { SidebarTrigger } from "@/components/ui/sidebar" -export function SiteHeader() { +export function SiteHeader({ title = "Title" }: { title?: string }) { return (
@@ -11,7 +11,7 @@ export function SiteHeader() { orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" /> -

Documents

+

{title}

+
+
+ ) +} + +export function PictureEditor({}){ + +} \ No newline at end of file diff --git a/web/src/components/console/user-security/index.tsx b/web/src/components/console/user-security/index.tsx new file mode 100644 index 0000000..b0941fd --- /dev/null +++ b/web/src/components/console/user-security/index.tsx @@ -0,0 +1,84 @@ +"use client" +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp" +import { useEffect, useState } from "react"; + +const VERIFY_CODE_COOL_DOWN = 60; // seconds + +export function UserSecurityPage() { + const [email, setEmail] = useState("") + const [verifyCode, setVerifyCode] = useState("") + const [oldPassword, setOldPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const handleSubmitPassword = () => { + + } + const handleSendVerifyCode = () => { + console.log("send verify code to ", email) + } + const handleSubmitEmail = () => { + console.log("submit email ", email, verifyCode) + } + return ( +
+
+

+ 密码设置 +

+ + setOldPassword(e.target.value)} /> + + setNewPassword(e.target.value)} /> + +
+ +
+

+ 邮箱设置 +

+ +
+ setEmail(e.target.value)} /> + +
+ +
+ setVerifyCode(value)} /> + +
+
+
+ ) +} + +function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) { + const [value, setValue] = useState("") + useEffect(() => { + onChange(value) + }, [value, onChange]) + return ( +
+ setValue(value)} + > + + + + + + + + + +
+ ) +} diff --git a/web/src/components/layout/avatar-with-dropdown-menu.tsx b/web/src/components/layout/avatar-with-dropdown-menu.tsx index 2d04a82..44d684a 100644 --- a/web/src/components/layout/avatar-with-dropdown-menu.tsx +++ b/web/src/components/layout/avatar-with-dropdown-menu.tsx @@ -36,7 +36,7 @@ export function AvatarWithDropdownMenu() { {user ? {getFallbackAvatarFromUsername(user.nickname || user.username)} - : } + : } diff --git a/web/src/components/login/login-form.tsx b/web/src/components/login/login-form.tsx index 0a0f823..935de3f 100644 --- a/web/src/components/login/login-form.tsx +++ b/web/src/components/login/login-form.tsx @@ -21,12 +21,14 @@ import { useTranslations } from "next-intl" import Captcha from "../common/captcha" import { CaptchaProvider } from "@/models/captcha" import { toast } from "sonner" +import { useAuth } from "@/contexts/auth-context" export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { const t = useTranslations('Login') + const {user, setUser} = useAuth(); const [oidcConfigs, setOidcConfigs] = useState([]) const [captchaProps, setCaptchaProps] = useState<{ provider: CaptchaProvider @@ -41,14 +43,20 @@ export function LoginForm({ const searchParams = useSearchParams() const redirectBack = searchParams.get("redirect_back") || "/" + useEffect(() => { + if (user) { + router.push(redirectBack); + } + }, [user, router, redirectBack]); + useEffect(() => { ListOidcConfigs() .then((res) => { - setOidcConfigs(res.data || []) // 确保是数组 + setOidcConfigs(res.data || []) }) .catch((error) => { toast.error(t("fetch_oidc_configs_failed") + (error?.message ? `: ${error.message}` : "")) - setOidcConfigs([]) // 错误时设置为空数组 + setOidcConfigs([]) }) }, [t]) @@ -69,6 +77,7 @@ export function LoginForm({ userLogin({ username, password, captcha: captchaToken || "" }) .then(res => { toast.success(t("login_success") + ` ${res.data.user.nickname || res.data.user.username}`); + setUser(res.data.user); router.push(redirectBack) }) .catch(error => { diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d9ccec9 --- /dev/null +++ b/web/src/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/web/src/components/ui/input-otp.tsx b/web/src/components/ui/input-otp.tsx new file mode 100644 index 0000000..614f70e --- /dev/null +++ b/web/src/components/ui/input-otp.tsx @@ -0,0 +1,77 @@ +"use client" + +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { MinusIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string +}) { + return ( + + ) +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number +}) { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ) +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/web/src/components/user/user-header.tsx b/web/src/components/user/user-header.tsx index 6f85865..8229e9e 100644 --- a/web/src/components/user/user-header.tsx +++ b/web/src/components/user/user-header.tsx @@ -2,7 +2,7 @@ import { User } from "@/models/user"; import { Mail, User as UserIcon, Shield } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; -import { getGravatarUrl } from "@/utils/common/gravatar"; +import { getGravatarFromUser } from "@/utils/common/gravatar"; import { getFirstCharFromUser } from "@/utils/common/username"; export function UserHeader({ user }: { user: User }) { @@ -13,7 +13,7 @@ export function UserHeader({ user }: { user: User }) { {/* wrapper 控制显示大小,父组件给具体 w/h */}
- + {getFirstCharFromUser(user)}
diff --git a/web/src/contexts/auth-context.tsx b/web/src/contexts/auth-context.tsx index 7d792ba..1536e87 100644 --- a/web/src/contexts/auth-context.tsx +++ b/web/src/contexts/auth-context.tsx @@ -1,8 +1,8 @@ "use client"; -import React, { createContext, useContext, useState, useMemo } from "react"; +import React, { createContext, useContext, useState, useMemo, useEffect } from "react"; import type { User } from "@/models/user"; -import { userLogout } from "@/api/user"; +import { getLoginUser, userLogout } from "@/api/user"; type AuthContextValue = { user: User | null; @@ -21,6 +21,16 @@ export function AuthProvider({ }) { const [user, setUser] = useState(initialUser); + useEffect(() => { + if (!user){ + getLoginUser().then(res => { + setUser(res.data); + }).catch(() => { + setUser(null); + }); + } + }, [user]); + const logout = async () => { setUser(null); await userLogout(); diff --git a/web/src/models/user.ts b/web/src/models/user.ts index 2b3f7ad..d1b975b 100644 --- a/web/src/models/user.ts +++ b/web/src/models/user.ts @@ -9,6 +9,11 @@ export interface User { language: string; } +export enum Role { + ADMIN = "admin", + USER = "user", + EDITOR = "editor", +} export interface RegisterRequest { username: string diff --git a/web/src/utils/client/file.ts b/web/src/utils/client/file.ts new file mode 100644 index 0000000..397c17d --- /dev/null +++ b/web/src/utils/client/file.ts @@ -0,0 +1,3 @@ +export function getFileUri(id: number){ + return `/api/v1/file/f/${id}` +} \ No newline at end of file diff --git a/web/src/utils/common/permission.ts b/web/src/utils/common/permission.ts new file mode 100644 index 0000000..0fffa9a --- /dev/null +++ b/web/src/utils/common/permission.ts @@ -0,0 +1,9 @@ +import { Role, User } from "@/models/user"; + +export function isAdmin({ user }: { user: User}) { + return user.role === Role.ADMIN; +} + +export function isEditor({ user }: { user: User}) { + return user.role === Role.EDITOR || user.role === Role.ADMIN; +} \ No newline at end of file diff --git a/web/src/utils/server/file.ts b/web/src/utils/server/file.ts new file mode 100644 index 0000000..e69de29