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.
This commit is contained in:
2025-09-20 12:45:10 +08:00
parent f8e4a84d53
commit 709aa82337
62 changed files with 1844 additions and 487 deletions

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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 // 密码,存储加密后的值

View File

@ -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
}

View File

@ -127,6 +127,7 @@ func migrate() error {
&model.Comment{},
&model.Label{},
&model.Like{},
&model.File{},
&model.OidcConfig{},
&model.Post{},
&model.Session{},

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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} // 评论可用的排序方式
)

View File

@ -2,8 +2,9 @@ package errs
import (
"errors"
"github.com/cloudwego/hertz/pkg/app"
"net/http"
"github.com/cloudwego/hertz/pkg/app"
)
// ServiceError 业务错误结构

View File

@ -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)

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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",

26
web/pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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)

25
web/src/api/file.ts Normal file
View File

@ -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<BaseResponse<{
hash: string,
id: number,
}>> {
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<BaseResponse<{
hash: string,
id: number,
}>>('/file/f', formData, {
withCredentials: true,
})
return res.data
}

View File

@ -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(
{
@ -84,3 +84,8 @@ export async function getCaptchaConfig(): Promise<BaseResponse<{
}>>('/user/captcha')
return res.data
}
export async function updateUser(data: Partial<User>): Promise<BaseResponse<User>> {
const res = await axiosClient.put<BaseResponse<User>>(`/user/u/${data.id}`, data)
return res.data
}

View File

@ -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({
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
{children}
<SiteHeader title={title} />
<div className="p-5 md:p-8">
{children}
</div>
</SidebarInset>
</SidebarProvider>
)

View File

@ -0,0 +1,5 @@
import SettingPage from "@/components/console/setting";
export default function Page() {
return <SettingPage />;
}

View File

@ -0,0 +1,3 @@
export default function Page() {
return <div></div>
}

View File

@ -0,0 +1,5 @@
import { UserProfilePage } from "@/components/console/user-profile";
export default function Page() {
return <UserProfilePage />;
}

View File

@ -0,0 +1,5 @@
import { UserSecurityPage } from "@/components/console/user-security";
export default function Page() {
return <UserSecurityPage />;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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 (
<Card className="overflow-hidden h-full flex flex-col">
{/* 封面图片骨架 */}
<div className="aspect-[16/9] bg-muted animate-pulse" />
<Card className="group overflow-hidden hover:shadow-xl transition-all duration-300 h-full flex flex-col cursor-default pt-0 pb-4">
{/* 封面骨架 */}
<div className="relative aspect-[16/9] overflow-hidden">
<Skeleton className="absolute inset-0" />
{/* Header 骨架 */}
<CardHeader className="pb-3">
<div className="h-6 bg-muted rounded animate-pulse mb-2" />
<div className="space-y-2">
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="h-4 bg-muted rounded w-3/4 animate-pulse" />
<div className="h-4 bg-muted rounded w-1/2 animate-pulse" />
{/* 覆盖层(模拟暗色遮罩) */}
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
{/* 私有标识骨架 */}
<div className="absolute top-2 left-2">
<Skeleton className="h-6 w-14 rounded-md" />
</div>
{/* 统计信息骨架 */}
<div className="absolute bottom-2 left-2">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-6 rounded" />
<Skeleton className="h-3 w-6 rounded" />
<Skeleton className="h-3 w-6 rounded" />
</div>
</div>
{/* 热度骨架 */}
<div className="absolute bottom-2 right-2">
<Skeleton className="h-6 w-12 rounded-md" />
</div>
</div>
{/* 标题骨架 */}
<CardHeader className="pb-3">
<CardTitle>
<Skeleton className="h-5 w-3/4 rounded" />
</CardTitle>
</CardHeader>
{/* Content 骨架 */}
<CardContent className="flex-1 pb-3">
<div className="flex gap-2 mb-4">
<div className="h-6 w-16 bg-muted rounded animate-pulse" />
<div className="h-6 w-20 bg-muted rounded animate-pulse" />
<div className="h-6 w-14 bg-muted rounded animate-pulse" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="h-4 bg-muted rounded animate-pulse" />
</div>
{/* 内容骨架 */}
<CardContent className="flex-1">
<CardDescription>
<div className="space-y-2">
<Skeleton className="h-4 rounded" />
<Skeleton className="h-4 w-5/6 rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
</CardDescription>
</CardContent>
{/* Footer 骨架 */}
<CardFooter className="pt-3 border-t">
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
<div className="h-4 w-20 bg-muted rounded animate-pulse ml-auto" />
{/* 底部骨架 */}
<CardFooter className="pb-0 border-t border-border/50 flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24 rounded" />
</div>
<Skeleton className="h-4 w-20 rounded" />
</CardFooter>
</Card>
)

View File

@ -59,7 +59,7 @@ export function SidebarHotPosts({ posts, sortType }: { posts: Post[], sortType:
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-orange-500" />
{sortType === 'latest' ? '热门文章' : '最新文章'}
{sortType === 'latest' ? '最新文章' : '热门文章'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">

View File

@ -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(
<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 && <Avatar className="h-full w-full rounded-full">
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
<AvatarImage src={getGravatarFromUser({ user, size: 120 })} alt={user.nickname} />
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
</Avatar>}
{!user && <CircleUser className="w-full h-full fade-in" />}

View File

@ -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(
<div className="flex">
<div onClick={() => clickToUserProfile(commentState.user.username)} className="cursor-pointer fade-in w-12 h-12">
<Avatar className="h-full w-full rounded-full">
<AvatarImage src={getGravatarUrl({email: commentState.user.email, size: 120})} alt={commentState.user.nickname} />
<AvatarFallback className="rounded-full">{getFirstCharFromUser(commentState.user)}</AvatarFallback>
</Avatar>
<AvatarImage src={getGravatarFromUser({ user: commentState.user, size: 120 })} alt={commentState.user.nickname} />
<AvatarFallback className="rounded-full">{getFirstCharFromUser(commentState.user)}</AvatarFallback>
</Avatar>
</div>
<div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center">

View File

@ -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 (
<Dialog>
<form>
<DialogTrigger asChild>
<Button variant="outline">Edit</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
</DialogContent>
</form>
</Dialog>
)
}

View File

@ -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<typeof Sidebar>) {
return (
@ -68,7 +41,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavMain items={sidebarData.navMain} />
<NavUserCenter items={sidebarData.navUserCenter} />
</SidebarContent>
<SidebarFooter>
<NavUser />

View File

@ -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<any>;
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
}
]
}

View File

@ -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 (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="data-[state=open]:bg-accent rounded-sm"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@ -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<SVGProps<SVGSVGElement> & LucideProps>;
permission: ({ user }: { user: User }) => boolean
}[]
}) {
const { user } = useAuth();
const pathname = usePathname() ?? "/"
console.log("pathname", pathname)
if (!user) return null;
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarGroupLabel>General</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
item.permission({ user }) && <SidebarMenuItem key={item.title}>
<Link href={item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname===item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>

View File

@ -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<SVGProps<SVGSVGElement> & LucideProps>;
permission: ({ user }: { user: User }) => boolean
}[]
}) {
const { isMobile } = useSidebar()
const { user } = useAuth();
const pathname = usePathname() ?? "/"
if (!user) return null;
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Personal</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
item.permission({ user }) && <SidebarMenuItem key={item.title}>
<Link href={item.url}>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
)
}

View File

@ -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 (
<SidebarMenu>
@ -95,7 +105,7 @@ export function NavUser({}: {}) {
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>
<IconLogout />
Log out
</DropdownMenuItem>

View File

@ -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)",
},
}

View File

@ -0,0 +1,18 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function SettingPage() {
return <div>
<h2 className="text-2xl font-bold">
</h2>
<div className="grid w-full max-w-sm items-center gap-3 mt-4">
<Label htmlFor="themeColor"></Label>
<Input type="color" id="themeColor" />
</div>
</div>;
}
export function ColorPick() {
}

View File

@ -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 (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@ -11,7 +11,7 @@ export function SiteHeader() {
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">Documents</h1>
<h1 className="text-base font-medium">{title}</h1>
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
<a

View File

@ -0,0 +1,135 @@
"use client"
import { uploadFile } from "@/api/file";
import { updateUser } from "@/api/user";
import { ImageCropper } from "@/components/common/image-cropper";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
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 { useAuth } from "@/contexts/auth-context";
import { getFileUri } from "@/utils/client/file";
import { getGravatarFromUser } from "@/utils/common/gravatar";
import { getFallbackAvatarFromUsername } from "@/utils/common/username";
import { useEffect, useState } from "react";
import { toast } from "sonner";
interface UploadConstraints {
allowedTypes: string[];
maxSize: number;
}
interface PictureInputChangeEvent {
target: HTMLInputElement & { files?: FileList | null };
}
export function UserProfilePage() {
const { user } = useAuth();
if (!user) return null
const [nickname, setNickname] = useState(user.nickname || '')
const [username, setUsername] = useState(user.username || '')
const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl || '')
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [gender, setGender] = useState(user.gender || '')
useEffect(() => {
// if (!avatarFile) return
// uploadFile({ file: avatarFile! }).then(res => {
// setAvatarUrl(getFileUri(res.data.id))
// toast.success('Avatar uploaded successfully')
// }).catch(err => {
// console.log(err)
// toast.error(`Error: ${err?.response?.data?.message || err.message || 'Failed to upload avatar'}`)
// })
}, [avatarFile])
const handlePictureSelected = (e: PictureInputChangeEvent): void => {
const file: File | null = e.target.files?.[0] ?? null;
if (!file) {
setAvatarFile(null);
return;
}
const constraints: UploadConstraints = {
allowedTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
maxSize: 5 * 1024 * 1024, // 5 MB
};
if (!file.type || !file.type.startsWith('image/') || !constraints.allowedTypes.includes(file.type)) {
setAvatarFile(null);
toast.error('只允许上传 PNG / JPEG / WEBP / GIF 格式的图片');
return;
}
if (file.size > constraints.maxSize) {
setAvatarFile(null);
toast.error('图片大小不能超过 5MB');
return;
}
setAvatarFile(file);
}
const handleSubmit = () => {
if (nickname.trim() === '' || username.trim() === '') {
toast.error('Nickname and Username cannot be empty')
return
}
if ((username.length < 3 || username.length > 20) || (nickname.length < 1 || nickname.length > 20)) {
toast.error('Nickname and Username must be between 3 and 20 characters')
return
}
if (username === user.username && nickname === user.nickname && avatarUrl === user.avatarUrl && gender === user.gender) {
toast.warning('No changes made')
return
}
updateUser({ nickname, username, avatarUrl, gender, id: user.id }).then(res => {
toast.success('Profile updated successfully')
window.location.reload()
}).catch(err => {
console.log(err)
toast.error(`Error: ${err?.response.data?.message || err.message || 'Failed to update profile'}`)
})
}
return (
<div>
<h1 className="text-2xl font-bold">
Public Profile
</h1>
<Separator className="my-2" />
<div className="grid w-full max-w-sm items-center gap-3">
<Label htmlFor="picture">Picture</Label>
<Avatar className="h-40 w-40 rounded-xl border-2">
{!avatarFile && <AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />}
{avatarFile && <AvatarImage src={URL.createObjectURL(avatarFile)} alt={user.username} />}
<AvatarFallback>{getFallbackAvatarFromUsername(nickname || username)}</AvatarFallback>
</Avatar>
<div className="flex gap-3"><Input
id="picture"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/*"
onChange={handlePictureSelected}
/>
<ImageCropper />
</div>
<Input
id="picture-url"
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="若要用外链图像,请直接填写,不支持裁剪"
/>
<Label htmlFor="nickname">Nickname</Label>
<Input type="nickname" id="nickname" value={nickname} onChange={(e) => setNickname(e.target.value)} />
<Label htmlFor="username">Username</Label>
<Input type="username" id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<Label htmlFor="gender">Gender</Label>
<Input type="gender" id="gender" value={gender} onChange={(e) => setGender(e.target.value)} />
<Button className="max-w-1/3" onClick={handleSubmit}>Submit</Button>
</div>
</div>
)
}
export function PictureEditor({}){
}

View File

@ -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 (
<div>
<div className="grid w-full max-w-sm items-center gap-3">
<h1 className="text-2xl font-bold">
</h1>
<Label htmlFor="password">Old Password</Label>
<Input id="password" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
<Label htmlFor="password">New Password</Label>
<Input id="password" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
<Button className="max-w-1/3 border-2" onClick={handleSubmitPassword}>Submit</Button>
</div>
<Separator className="my-4" />
<div className="grid w-full max-w-sm items-center gap-3 py-4">
<h1 className="text-2xl font-bold">
</h1>
<Label htmlFor="email">email</Label>
<div className="flex gap-3">
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button variant="outline" className="border-2" onClick={handleSendVerifyCode}></Button>
</div>
<Label htmlFor="verify-code">verify code</Label>
<div className="flex gap-3">
<InputOTPControlled onChange={(value) => setVerifyCode(value)} />
<Button className="border-2" onClick={handleSubmitEmail}>Submit</Button>
</div>
</div>
</div>
)
}
function InputOTPControlled({ onChange }: { onChange: (value: string) => void }) {
const [value, setValue] = useState("")
useEffect(() => {
onChange(value)
}, [value, onChange])
return (
<div className="space-y-2">
<InputOTP
maxLength={6}
value={value}
onChange={(value) => setValue(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
)
}

View File

@ -36,7 +36,7 @@ export function AvatarWithDropdownMenu() {
{user ? <Avatar className="h-8 w-8 rounded-full">
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
</Avatar> : <CircleUser className="w-9 h-9" />}
</Avatar> : <CircleUser className="h-8 w-8" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">

View File

@ -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<OidcConfig[]>([])
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 => {

View File

@ -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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -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<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -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 */}
<div className="w-40 h-40 md:w-48 md:h-48 relative">
<Avatar className="h-full w-full rounded-full">
<AvatarImage src={getGravatarUrl({ email: user.email, size: 120 })} alt={user.nickname} />
<AvatarImage src={getGravatarFromUser({user})} alt={user.nickname} />
<AvatarFallback className="rounded-full">{getFirstCharFromUser(user)}</AvatarFallback>
</Avatar>
</div>

View File

@ -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<User | null>(initialUser);
useEffect(() => {
if (!user){
getLoginUser().then(res => {
setUser(res.data);
}).catch(() => {
setUser(null);
});
}
}, [user]);
const logout = async () => {
setUser(null);
await userLogout();

View File

@ -9,6 +9,11 @@ export interface User {
language: string;
}
export enum Role {
ADMIN = "admin",
USER = "user",
EDITOR = "editor",
}
export interface RegisterRequest {
username: string

View File

@ -0,0 +1,3 @@
export function getFileUri(id: number){
return `/api/v1/file/f/${id}`
}

View File

@ -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;
}

View File