mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 02:56:22 +00:00
feat: add captcha support for user login and enhance user profile page
- Refactored userLogin function to include captcha handling. - Introduced getCaptchaConfig API to fetch captcha configuration. - Added Captcha component to handle different captcha providers (hCaptcha, reCaptcha, Turnstile). - Updated LoginForm component to integrate captcha verification. - Created UserProfile component to display user information with avatar. - Implemented getUserByUsername API to fetch user details by username. - Removed deprecated LoginRequest interface from user model. - Enhanced navbar and layout with animation effects. - Removed unused user page component and added dynamic user profile routing. - Updated localization files to include captcha-related messages. - Improved Gravatar component for better avatar handling.
This commit is contained in:
@ -10,8 +10,10 @@ import (
|
|||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/internal/service"
|
"github.com/snowykami/neo-blog/internal/service"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
"github.com/snowykami/neo-blog/pkg/resps"
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
|
utils2 "github.com/snowykami/neo-blog/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
@ -125,6 +127,22 @@ func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
resps.Ok(c, resps.Success, resp.User)
|
resps.Ok(c, resps.Success, resp.User)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UserController) GetUserByUsername(ctx context.Context, c *app.RequestContext) {
|
||||||
|
username := c.Param("username")
|
||||||
|
if username == "" {
|
||||||
|
resps.BadRequest(c, resps.ErrParamInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := u.service.GetUserByUsername(&dto.GetUserByUsernameReq{Username: username})
|
||||||
|
if err != nil {
|
||||||
|
serviceErr := errs.AsServiceError(err)
|
||||||
|
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resps.Ok(c, resps.Success, resp.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) {
|
func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) {
|
||||||
@ -184,3 +202,11 @@ func (u *UserController) ChangePassword(ctx context.Context, c *app.RequestConte
|
|||||||
func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) {
|
func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) {
|
||||||
// TODO: 实现修改邮箱功能
|
// TODO: 实现修改邮箱功能
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UserController) GetCaptchaConfig(ctx context.Context, c *app.RequestContext) {
|
||||||
|
resps.Ok(c, "ok", utils.H{
|
||||||
|
"provider": utils2.Env.Get(constant.EnvKeyCaptchaProvider),
|
||||||
|
"site_key": utils2.Env.Get(constant.EnvKeyCaptchaSiteKey),
|
||||||
|
"url": utils2.Env.Get(constant.EnvKeyCaptchaUrl),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -70,6 +70,10 @@ type GetUserReq struct {
|
|||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetUserByUsernameReq struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
type GetUserResp struct {
|
type GetUserResp struct {
|
||||||
User UserDto `json:"user"` // 用户信息
|
User UserDto `json:"user"` // 用户信息
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/pkg/resps"
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
|
@ -1,27 +1,29 @@
|
|||||||
package apiv1
|
package apiv1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/cloudwego/hertz/pkg/route"
|
"github.com/cloudwego/hertz/pkg/route"
|
||||||
"github.com/snowykami/neo-blog/internal/controller/v1"
|
"github.com/snowykami/neo-blog/internal/controller/v1"
|
||||||
"github.com/snowykami/neo-blog/internal/middleware"
|
"github.com/snowykami/neo-blog/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerUserRoutes(group *route.RouterGroup) {
|
func registerUserRoutes(group *route.RouterGroup) {
|
||||||
userController := v1.NewUserController()
|
userController := v1.NewUserController()
|
||||||
userGroup := group.Group("/user").Use(middleware.UseAuth(true))
|
userGroup := group.Group("/user").Use(middleware.UseAuth(true))
|
||||||
userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false))
|
userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false))
|
||||||
userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha())
|
userGroupWithoutAuthNeedsCaptcha := group.Group("/user").Use(middleware.UseCaptcha())
|
||||||
{
|
{
|
||||||
userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login)
|
userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login)
|
||||||
userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register)
|
userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register)
|
||||||
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code
|
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code
|
||||||
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
|
userGroupWithoutAuth.GET("/captcha", userController.GetCaptchaConfig)
|
||||||
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
|
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
|
||||||
userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
|
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
|
||||||
userGroup.GET("/me", userController.GetUser)
|
userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
|
||||||
userGroupWithoutAuth.POST("/logout", userController.Logout)
|
userGroupWithoutAuth.GET("/username/:username", userController.GetUserByUsername)
|
||||||
userGroup.PUT("/u/:id", userController.UpdateUser)
|
userGroup.GET("/me", userController.GetUser)
|
||||||
userGroup.PUT("/password/edit", userController.ChangePassword)
|
userGroupWithoutAuth.POST("/logout", userController.Logout)
|
||||||
userGroup.PUT("/email/edit", userController.ChangeEmail)
|
userGroup.PUT("/u/:id", userController.UpdateUser)
|
||||||
}
|
userGroup.PUT("/password/edit", userController.ChangePassword)
|
||||||
|
userGroup.PUT("/email/edit", userController.ChangeEmail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,379 +1,400 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sirupsen/logrus"
|
"net/http"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"strings"
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
"time"
|
||||||
"github.com/snowykami/neo-blog/internal/repo"
|
|
||||||
"github.com/snowykami/neo-blog/internal/static"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/pkg/utils"
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
"gorm.io/gorm"
|
"github.com/snowykami/neo-blog/internal/static"
|
||||||
"net/http"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"strings"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
"time"
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserService struct{}
|
type UserService struct{}
|
||||||
|
|
||||||
func NewUserService() *UserService {
|
func NewUserService() *UserService {
|
||||||
return &UserService{}
|
return &UserService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) {
|
func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) {
|
||||||
user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
|
user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
logrus.Warnf("User not found: %s", req.Username)
|
logrus.Warnf("User not found: %s", req.Username)
|
||||||
return nil, errs.ErrNotFound
|
return nil, errs.ErrNotFound
|
||||||
}
|
}
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, errs.ErrNotFound
|
return nil, errs.ErrNotFound
|
||||||
}
|
}
|
||||||
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
|
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
|
||||||
token, refreshToken, err := s.generate2Token(user.ID)
|
token, refreshToken, err := s.generate2Token(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to generate tokens:", err)
|
logrus.Errorln("Failed to generate tokens:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
resp := &dto.UserLoginResp{
|
resp := &dto.UserLoginResp{
|
||||||
Token: token,
|
Token: token,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
User: user.ToDto(),
|
User: user.ToDto(),
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) {
|
func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) {
|
||||||
// 验证邮箱验证码
|
// 验证邮箱验证码
|
||||||
if !utils.Env.GetAsBool("ENABLE_REGISTER", true) {
|
if !utils.Env.GetAsBool("ENABLE_REGISTER", true) {
|
||||||
return nil, errs.ErrForbidden
|
return nil, errs.ErrForbidden
|
||||||
}
|
}
|
||||||
if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) {
|
if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) {
|
||||||
ok, err := s.verifyEmail(req.Email, req.VerificationCode)
|
ok, err := s.verifyEmail(req.Email, req.VerificationCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to verify email:", err)
|
logrus.Errorln("Failed to verify email:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
|
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 检查用户名或邮箱是否已存在
|
// 检查用户名或邮箱是否已存在
|
||||||
usernameExist, err := repo.User.CheckUsernameExists(req.Username)
|
usernameExist, err := repo.User.CheckUsernameExists(req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
emailExist, err := repo.User.CheckEmailExists(req.Email)
|
emailExist, err := repo.User.CheckEmailExists(req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if usernameExist || emailExist {
|
if usernameExist || emailExist {
|
||||||
return nil, errs.New(http.StatusConflict, "Username or email already exists", nil)
|
return nil, errs.New(http.StatusConflict, "Username or email already exists", nil)
|
||||||
}
|
}
|
||||||
// 创建新用户
|
// 创建新用户
|
||||||
hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt"))
|
hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to hash password:", err)
|
logrus.Errorln("Failed to hash password:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
newUser := &model.User{
|
newUser := &model.User{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Nickname: req.Nickname,
|
Nickname: req.Nickname,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
Gender: "",
|
Gender: "",
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
}
|
}
|
||||||
err = repo.User.CreateUser(newUser)
|
err = repo.User.CreateUser(newUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
// 创建默认管理员账户
|
// 创建默认管理员账户
|
||||||
if newUser.ID == 1 {
|
if newUser.ID == 1 {
|
||||||
newUser.Role = constant.RoleAdmin
|
newUser.Role = constant.RoleAdmin
|
||||||
err = repo.User.UpdateUser(newUser)
|
err = repo.User.UpdateUser(newUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to update user role to admin:", err)
|
logrus.Errorln("Failed to update user role to admin:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 生成访问令牌和刷新令牌
|
// 生成访问令牌和刷新令牌
|
||||||
token, refreshToken, err := s.generate2Token(newUser.ID)
|
token, refreshToken, err := s.generate2Token(newUser.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to generate tokens:", err)
|
logrus.Errorln("Failed to generate tokens:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
resp := &dto.UserRegisterResp{
|
resp := &dto.UserRegisterResp{
|
||||||
Token: token,
|
Token: token,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
User: newUser.ToDto(),
|
User: newUser.ToDto(),
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) {
|
func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) {
|
||||||
generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef")
|
generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef")
|
||||||
kv := utils.KV.GetInstance()
|
kv := utils.KV.GetInstance()
|
||||||
kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10)
|
kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10)
|
||||||
|
|
||||||
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
|
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if utils.IsDevMode {
|
if utils.IsDevMode {
|
||||||
logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode)
|
logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode)
|
||||||
}
|
}
|
||||||
err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true)
|
err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
return &dto.VerifyEmailResp{Success: true}, nil
|
return &dto.VerifyEmailResp{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) {
|
func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) {
|
||||||
enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true)
|
enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
var oidcConfigsDtos []dto.UserOidcConfigDto
|
var oidcConfigsDtos []dto.UserOidcConfigDto
|
||||||
|
|
||||||
for _, oidcConfig := range enabledOidcConfigs {
|
for _, oidcConfig := range enabledOidcConfigs {
|
||||||
state := utils.Strings.GenerateRandomString(32)
|
state := utils.Strings.GenerateRandomString(32)
|
||||||
kvStore := utils.KV.GetInstance()
|
kvStore := utils.KV.GetInstance()
|
||||||
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
|
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
|
||||||
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
|
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
|
||||||
"client_id": oidcConfig.ClientID,
|
"client_id": oidcConfig.ClientID,
|
||||||
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的
|
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的
|
||||||
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
|
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
|
||||||
constant.ApiSuffix,
|
constant.ApiSuffix,
|
||||||
constant.OidcUri,
|
constant.OidcUri,
|
||||||
oidcConfig.Name,
|
oidcConfig.Name,
|
||||||
),
|
),
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "openid email profile",
|
"scope": "openid email profile",
|
||||||
"state": state,
|
"state": state,
|
||||||
})
|
})
|
||||||
|
|
||||||
if oidcConfig.Type == constant.OidcProviderTypeMisskey {
|
if oidcConfig.Type == constant.OidcProviderTypeMisskey {
|
||||||
// Misskey OIDC 特殊处理
|
// Misskey OIDC 特殊处理
|
||||||
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
|
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
|
||||||
"client_id": oidcConfig.ClientID,
|
"client_id": oidcConfig.ClientID,
|
||||||
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的
|
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的
|
||||||
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
|
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
|
||||||
constant.ApiSuffix,
|
constant.ApiSuffix,
|
||||||
constant.OidcUri,
|
constant.OidcUri,
|
||||||
oidcConfig.Name,
|
oidcConfig.Name,
|
||||||
),
|
),
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": "read:account",
|
"scope": "read:account",
|
||||||
"state": state,
|
"state": state,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
|
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
|
||||||
Name: oidcConfig.Name,
|
Name: oidcConfig.Name,
|
||||||
DisplayName: oidcConfig.DisplayName,
|
DisplayName: oidcConfig.DisplayName,
|
||||||
Icon: oidcConfig.Icon,
|
Icon: oidcConfig.Icon,
|
||||||
LoginUrl: loginUrl,
|
LoginUrl: loginUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return oidcConfigsDtos, nil
|
return oidcConfigsDtos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) {
|
func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) {
|
||||||
// 验证state
|
// 验证state
|
||||||
kvStore := utils.KV.GetInstance()
|
kvStore := utils.KV.GetInstance()
|
||||||
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
|
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
|
||||||
if !ok || storedName != req.Name {
|
if !ok || storedName != req.Name {
|
||||||
return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil)
|
return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil)
|
||||||
}
|
}
|
||||||
// 获取OIDC配置
|
// 获取OIDC配置
|
||||||
oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name)
|
oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if oidcConfig == nil {
|
if oidcConfig == nil {
|
||||||
return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil)
|
return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil)
|
||||||
}
|
}
|
||||||
// 请求访问令牌
|
// 请求访问令牌
|
||||||
tokenResp, err := utils.Oidc.RequestToken(
|
tokenResp, err := utils.Oidc.RequestToken(
|
||||||
oidcConfig.TokenEndpoint,
|
oidcConfig.TokenEndpoint,
|
||||||
oidcConfig.ClientID,
|
oidcConfig.ClientID,
|
||||||
oidcConfig.ClientSecret,
|
oidcConfig.ClientSecret,
|
||||||
req.Code,
|
req.Code,
|
||||||
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name,
|
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to request OIDC token:", err)
|
logrus.Errorln("Failed to request OIDC token:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken)
|
userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to request OIDC user info:", err)
|
logrus.Errorln("Failed to request OIDC user info:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绑定过登录
|
// 绑定过登录
|
||||||
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
|
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if userOpenID != nil {
|
if userOpenID != nil {
|
||||||
user, err := repo.User.GetUserByID(userOpenID.UserID)
|
user, err := repo.User.GetUserByID(userOpenID.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
token, refreshToken, err := s.generate2Token(user.ID)
|
token, refreshToken, err := s.generate2Token(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to generate tokens:", err)
|
logrus.Errorln("Failed to generate tokens:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
resp := &dto.OidcLoginResp{
|
resp := &dto.OidcLoginResp{
|
||||||
Token: token,
|
Token: token,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
User: user.ToDto(),
|
User: user.ToDto(),
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
} else {
|
} else {
|
||||||
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
|
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
|
||||||
user, err := repo.User.GetUserByEmail(userInfo.Email)
|
user, err := repo.User.GetUserByEmail(userInfo.Email)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
logrus.Errorln("Failed to get user by email:", err)
|
logrus.Errorln("Failed to get user by email:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if user != nil {
|
if user != nil {
|
||||||
userOpenID = &model.UserOpenID{
|
userOpenID = &model.UserOpenID{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Issuer: oidcConfig.Issuer,
|
Issuer: oidcConfig.Issuer,
|
||||||
Sub: userInfo.Sub,
|
Sub: userInfo.Sub,
|
||||||
}
|
}
|
||||||
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
|
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to create or update user OpenID:", err)
|
logrus.Errorln("Failed to create or update user OpenID:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
token, refreshToken, err := s.generate2Token(user.ID)
|
token, refreshToken, err := s.generate2Token(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to generate tokens:", err)
|
logrus.Errorln("Failed to generate tokens:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
resp := &dto.OidcLoginResp{
|
resp := &dto.OidcLoginResp{
|
||||||
Token: token,
|
Token: token,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
User: user.ToDto(),
|
User: user.ToDto(),
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
} else {
|
} else {
|
||||||
user = &model.User{
|
user = &model.User{
|
||||||
Username: userInfo.Name,
|
Username: userInfo.Name,
|
||||||
Nickname: userInfo.Name,
|
Nickname: userInfo.Name,
|
||||||
AvatarUrl: userInfo.Picture,
|
AvatarUrl: userInfo.Picture,
|
||||||
Email: userInfo.Email,
|
Email: userInfo.Email,
|
||||||
}
|
}
|
||||||
err = repo.User.CreateUser(user)
|
err = repo.User.CreateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to create user:", err)
|
logrus.Errorln("Failed to create user:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
userOpenID = &model.UserOpenID{
|
userOpenID = &model.UserOpenID{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Issuer: oidcConfig.Issuer,
|
Issuer: oidcConfig.Issuer,
|
||||||
Sub: userInfo.Sub,
|
Sub: userInfo.Sub,
|
||||||
}
|
}
|
||||||
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
|
err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to create or update user OpenID:", err)
|
logrus.Errorln("Failed to create or update user OpenID:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
token, refreshToken, err := s.generate2Token(user.ID)
|
token, refreshToken, err := s.generate2Token(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorln("Failed to generate tokens:", err)
|
logrus.Errorln("Failed to generate tokens:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
resp := &dto.OidcLoginResp{
|
resp := &dto.OidcLoginResp{
|
||||||
Token: token,
|
Token: token,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
User: user.ToDto(),
|
User: user.ToDto(),
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) {
|
func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) {
|
||||||
if req.UserID == 0 {
|
if req.UserID == 0 {
|
||||||
return nil, errs.New(http.StatusBadRequest, "user_id is required", nil)
|
return nil, errs.New(http.StatusBadRequest, "user_id is required", nil)
|
||||||
}
|
}
|
||||||
user, err := repo.User.GetUserByID(req.UserID)
|
user, err := repo.User.GetUserByID(req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, errs.ErrNotFound
|
return nil, errs.ErrNotFound
|
||||||
}
|
}
|
||||||
logrus.Errorln("Failed to get user by ID:", err)
|
logrus.Errorln("Failed to get user by ID:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, errs.ErrNotFound
|
return nil, errs.ErrNotFound
|
||||||
}
|
}
|
||||||
return &dto.GetUserResp{
|
return &dto.GetUserResp{
|
||||||
User: user.ToDto(),
|
User: user.ToDto(),
|
||||||
}, nil
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetUserByUsername(req *dto.GetUserByUsernameReq) (*dto.GetUserResp, error) {
|
||||||
|
if req.Username == "" {
|
||||||
|
return nil, errs.New(http.StatusBadRequest, "username is required", nil)
|
||||||
|
}
|
||||||
|
user, err := repo.User.GetUserByUsername(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errs.ErrNotFound
|
||||||
|
}
|
||||||
|
logrus.Errorln("Failed to get user by username:", err)
|
||||||
|
return nil, errs.ErrInternalServer
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, errs.ErrNotFound
|
||||||
|
}
|
||||||
|
return &dto.GetUserResp{
|
||||||
|
User: user.ToDto(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) {
|
func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) {
|
||||||
user := &model.User{
|
user := &model.User{
|
||||||
Model: gorm.Model{
|
Model: gorm.Model{
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
},
|
},
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Nickname: req.Nickname,
|
Nickname: req.Nickname,
|
||||||
Gender: req.Gender,
|
Gender: req.Gender,
|
||||||
AvatarUrl: req.AvatarUrl,
|
AvatarUrl: req.AvatarUrl,
|
||||||
}
|
}
|
||||||
err := repo.User.UpdateUser(user)
|
err := repo.User.UpdateUser(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, errs.ErrNotFound
|
return nil, errs.ErrNotFound
|
||||||
}
|
}
|
||||||
logrus.Errorln("Failed to update user:", err)
|
logrus.Errorln("Failed to update user:", err)
|
||||||
return nil, errs.ErrInternalServer
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
return &dto.UpdateUserResp{}, nil
|
return &dto.UpdateUserResp{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) generate2Token(userID uint) (string, string, error) {
|
func (s *UserService) generate2Token(userID uint) (string, string, error) {
|
||||||
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
|
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
|
||||||
tokenString, err := token.ToString()
|
tokenString, err := token.ToString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errs.ErrInternalServer
|
return "", "", errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
|
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
|
||||||
refreshTokenString, err := refreshToken.ToString()
|
refreshTokenString, err := refreshToken.ToString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errs.ErrInternalServer
|
return "", "", errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
err = repo.Session.SaveSession(refreshToken.SessionKey)
|
err = repo.Session.SaveSession(refreshToken.SessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errs.ErrInternalServer
|
return "", "", errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
return tokenString, refreshTokenString, nil
|
return tokenString, refreshTokenString, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) verifyEmail(email, code string) (bool, error) {
|
func (s *UserService) verifyEmail(email, code string) (bool, error) {
|
||||||
kv := utils.KV.GetInstance()
|
kv := utils.KV.GetInstance()
|
||||||
verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email)
|
verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email)
|
||||||
if !ok || verificationCode != code {
|
if !ok || verificationCode != code {
|
||||||
return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
|
return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
@ -10,15 +10,19 @@ const (
|
|||||||
ModeProd = "prod"
|
ModeProd = "prod"
|
||||||
RoleUser = "user"
|
RoleUser = "user"
|
||||||
RoleAdmin = "admin"
|
RoleAdmin = "admin"
|
||||||
EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL
|
EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL
|
||||||
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
|
EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
|
||||||
EnvKeyMode = "MODE" // 环境变量:运行模式
|
EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
|
||||||
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
|
||||||
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key
|
||||||
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
|
||||||
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
|
EnvKeyMode = "MODE" // 环境变量:运行模式
|
||||||
EnvKeyTokenDurationDefault = 300
|
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
||||||
EnvKeyRefreshTokenDurationDefault = 604800
|
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
||||||
|
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
||||||
|
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
|
||||||
|
EnvKeyTokenDurationDefault = 300 // Token有效时长
|
||||||
|
EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长
|
||||||
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
||||||
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
||||||
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码
|
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码
|
||||||
|
@ -12,16 +12,16 @@ type captchaUtils struct{}
|
|||||||
var Captcha = captchaUtils{}
|
var Captcha = captchaUtils{}
|
||||||
|
|
||||||
type CaptchaConfig struct {
|
type CaptchaConfig struct {
|
||||||
Type string
|
Type string
|
||||||
SiteSecret string // Site secret key for the captcha service
|
SiteKey string // Site secret key for the captcha service
|
||||||
SecretKey string // Secret key for the captcha service
|
SecretKey string // Secret key for the captcha service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig {
|
func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig {
|
||||||
return &CaptchaConfig{
|
return &CaptchaConfig{
|
||||||
Type: Env.Get("CAPTCHA_TYPE", "disable"),
|
Type: Env.Get(constant.EnvKeyCaptchaProvider, constant.CaptchaTypeDisable),
|
||||||
SiteSecret: Env.Get("CAPTCHA_SITE_SECRET", ""),
|
SiteKey: Env.Get(constant.EnvKeyCaptchaSiteKey, ""),
|
||||||
SecretKey: Env.Get("CAPTCHA_SECRET_KEY", ""),
|
SecretKey: Env.Get(constant.EnvKeyCaptchaSecreteKey, ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,6 +9,8 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hcaptcha/react-hcaptcha": "^1.12.1",
|
||||||
|
"@marsidev/react-turnstile": "^1.3.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@ -30,6 +32,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-google-recaptcha-v3": "^1.11.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
|
63
web/pnpm-lock.yaml
generated
63
web/pnpm-lock.yaml
generated
@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hcaptcha/react-hcaptcha':
|
||||||
|
specifier: ^1.12.1
|
||||||
|
version: 1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
'@marsidev/react-turnstile':
|
||||||
|
specifier: ^1.3.0
|
||||||
|
version: 1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@radix-ui/react-checkbox':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@ -71,6 +77,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.1.0
|
specifier: 19.1.0
|
||||||
version: 19.1.0(react@19.1.0)
|
version: 19.1.0(react@19.1.0)
|
||||||
|
react-google-recaptcha-v3:
|
||||||
|
specifier: ^1.11.0
|
||||||
|
version: 1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0(react@19.1.0)
|
version: 5.5.0(react@19.1.0)
|
||||||
@ -130,6 +139,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.4':
|
||||||
|
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@emnapi/core@1.4.4':
|
'@emnapi/core@1.4.4':
|
||||||
resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==}
|
resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==}
|
||||||
|
|
||||||
@ -195,6 +208,15 @@ packages:
|
|||||||
'@formatjs/intl-localematcher@0.6.1':
|
'@formatjs/intl-localematcher@0.6.1':
|
||||||
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
|
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
|
||||||
|
|
||||||
|
'@hcaptcha/loader@2.0.1':
|
||||||
|
resolution: {integrity: sha512-L36qqdOmv8fL6VBZcH34JUI0/SvC5KPOZ5N/m+5pQAPPhtXXRdU4o9iosZr12hWAM2qf5hC92kmi+XdqxKOEZQ==}
|
||||||
|
|
||||||
|
'@hcaptcha/react-hcaptcha@1.12.1':
|
||||||
|
resolution: {integrity: sha512-/A08MOAHa5L9B8UfNRkTR/+x2dOyfk3pI1/qgXI4NpDl/z4CjnSxaYCDtkbD21vEocN1KKCggQD3wJ7OcY494w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.3.0'
|
||||||
|
react-dom: '>= 16.3.0'
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@ -354,6 +376,12 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.29':
|
'@jridgewell/trace-mapping@0.3.29':
|
||||||
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
|
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
|
||||||
|
|
||||||
|
'@marsidev/react-turnstile@1.3.0':
|
||||||
|
resolution: {integrity: sha512-VO99Nynt+j4ETfMImQCj5LgbUKZ9mWPpy3RjP/3e/3vZu+FIphjEdU6g+cq4FeDoNshSxLlRzBTKcH5JMeM1GQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^17.0.2 || ^18.0.0 || ^19.0
|
||||||
|
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
|
||||||
|
|
||||||
'@mdx-js/mdx@3.1.0':
|
'@mdx-js/mdx@3.1.0':
|
||||||
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
|
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
|
||||||
|
|
||||||
@ -1689,6 +1717,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
hoist-non-react-statics@3.3.2:
|
||||||
|
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@ -2320,6 +2351,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.1.0
|
react: ^19.1.0
|
||||||
|
|
||||||
|
react-google-recaptcha-v3@1.11.0:
|
||||||
|
resolution: {integrity: sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.3 || ^17.0 || ^18.0 || ^19.0
|
||||||
|
react-dom: ^17.0 || ^18.0 || ^19.0
|
||||||
|
|
||||||
react-icons@5.5.0:
|
react-icons@5.5.0:
|
||||||
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
|
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2788,6 +2825,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-validator-identifier@7.27.1': {}
|
'@babel/helper-validator-identifier@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.4': {}
|
||||||
|
|
||||||
'@emnapi/core@1.4.4':
|
'@emnapi/core@1.4.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.0.3
|
'@emnapi/wasi-threads': 1.0.3
|
||||||
@ -2878,6 +2917,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@hcaptcha/loader@2.0.1': {}
|
||||||
|
|
||||||
|
'@hcaptcha/react-hcaptcha@1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.4
|
||||||
|
'@hcaptcha/loader': 2.0.1
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
@ -2995,6 +3043,11 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.4
|
'@jridgewell/sourcemap-codec': 1.5.4
|
||||||
|
|
||||||
|
'@marsidev/react-turnstile@1.3.0(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)
|
||||||
|
|
||||||
'@mdx-js/mdx@3.1.0(acorn@8.15.0)':
|
'@mdx-js/mdx@3.1.0(acorn@8.15.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@ -4499,6 +4552,10 @@ snapshots:
|
|||||||
|
|
||||||
highlight.js@11.11.1: {}
|
highlight.js@11.11.1: {}
|
||||||
|
|
||||||
|
hoist-non-react-statics@3.3.2:
|
||||||
|
dependencies:
|
||||||
|
react-is: 16.13.1
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
@ -5313,6 +5370,12 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
scheduler: 0.26.0
|
scheduler: 0.26.0
|
||||||
|
|
||||||
|
react-google-recaptcha-v3@1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
hoist-non-react-statics: 3.3.2
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
|
||||||
react-icons@5.5.0(react@19.1.0):
|
react-icons@5.5.0(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
@ -1,14 +1,26 @@
|
|||||||
import type { OidcConfig } from '@/models/oidc-config'
|
import type { OidcConfig } from '@/models/oidc-config'
|
||||||
import type { BaseResponse } from '@/models/resp'
|
import type { BaseResponse } from '@/models/resp'
|
||||||
import type { LoginRequest, RegisterRequest, User } from '@/models/user'
|
import type { RegisterRequest, User } from '@/models/user'
|
||||||
import axiosClient from './client'
|
import axiosClient from './client'
|
||||||
|
import { CaptchaProvider } from '@/models/captcha'
|
||||||
|
|
||||||
export async function userLogin(
|
export async function userLogin(
|
||||||
data: LoginRequest,
|
{
|
||||||
): Promise<BaseResponse<{ token: string, user: User }>> {
|
username,
|
||||||
|
password,
|
||||||
|
rememberMe,
|
||||||
|
captcha
|
||||||
|
}: {
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
rememberMe?: boolean,
|
||||||
|
captcha?: string,
|
||||||
|
}): Promise<BaseResponse<{ token: string, user: User }>> {
|
||||||
|
console.log("Logging in with captcha:", captcha)
|
||||||
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
|
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
|
||||||
'/user/login',
|
'/user/login',
|
||||||
data,
|
{ username, password, rememberMe },
|
||||||
|
{ headers: { 'X-Captcha-Token': captcha || '' } },
|
||||||
)
|
)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
@ -43,3 +55,21 @@ export async function getUserById(id: number): Promise<BaseResponse<User>> {
|
|||||||
const res = await axiosClient.get<BaseResponse<User>>(`/user/u/${id}`)
|
const res = await axiosClient.get<BaseResponse<User>>(`/user/u/${id}`)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserByUsername(username: string): Promise<BaseResponse<User>> {
|
||||||
|
const res = await axiosClient.get<BaseResponse<User>>(`/user/username/${username}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCaptchaConfig(): Promise<BaseResponse<{
|
||||||
|
provider: CaptchaProvider
|
||||||
|
siteKey: string
|
||||||
|
url?: string
|
||||||
|
}>> {
|
||||||
|
const res = await axiosClient.get<BaseResponse<{
|
||||||
|
provider: CaptchaProvider
|
||||||
|
siteKey: string
|
||||||
|
url?: string
|
||||||
|
}>>('/user/captcha')
|
||||||
|
return res.data
|
||||||
|
}
|
@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation'
|
|||||||
import { Navbar } from '@/components/layout/navbar'
|
import { Navbar } from '@/components/layout/navbar'
|
||||||
import { BackgroundProvider } from '@/contexts/background-context'
|
import { BackgroundProvider } from '@/contexts/background-context'
|
||||||
import Footer from '@/components/layout/footer'
|
import Footer from '@/components/layout/footer'
|
||||||
|
import config from '@/config'
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -14,25 +15,18 @@ export default function RootLayout({
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
|
<motion.nav
|
||||||
|
initial={{ y: -64, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
||||||
|
<header className="fixed top-0 left-0 h-16 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</header>
|
</header>
|
||||||
<motion.main
|
</motion.nav>
|
||||||
key={pathname}
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<BackgroundProvider>
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
|
||||||
transition={{
|
</BackgroundProvider>
|
||||||
type: 'tween',
|
|
||||||
ease: 'easeOut',
|
|
||||||
duration: 0.30,
|
|
||||||
}}
|
|
||||||
className="pt-16"
|
|
||||||
>
|
|
||||||
|
|
||||||
<BackgroundProvider>
|
|
||||||
<div className='container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
|
|
||||||
</BackgroundProvider>
|
|
||||||
</motion.main>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Page Title</h1>
|
|
||||||
<p>This is the User content.</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
27
web/src/app/(main)/u/[username]/page.tsx
Normal file
27
web/src/app/(main)/u/[username]/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
import { getUserByUsername } from "@/api/user";
|
||||||
|
import { UserPage } from "@/components/user";
|
||||||
|
import { User } from "@/models/user";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { username } = useParams() as { username: string };
|
||||||
|
const [user,setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserByUsername(username).then(res => {
|
||||||
|
setUser(res.data);
|
||||||
|
});
|
||||||
|
},[username]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<UserPage user={user} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -13,150 +13,163 @@ import { useEffect, useState } from "react";
|
|||||||
import { useStoredState } from '@/hooks/use-storage-state';
|
import { useStoredState } from '@/hooks/use-storage-state';
|
||||||
import { listLabels } from "@/api/label";
|
import { listLabels } from "@/api/label";
|
||||||
import { POST_SORT_TYPE } from "@/localstore";
|
import { POST_SORT_TYPE } from "@/localstore";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
// 定义排序类型
|
// 定义排序类型
|
||||||
type SortType = 'latest' | 'popular';
|
type SortType = 'latest' | 'popular';
|
||||||
|
|
||||||
export default function BlogHome() {
|
export default function BlogHome() {
|
||||||
const [labels, setLabels] = useState<Label[]>([]);
|
const [labels, setLabels] = useState<Label[]>([]);
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
|
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sortTypeLoaded) return;
|
if (!sortTypeLoaded) return;
|
||||||
const fetchPosts = async () => {
|
const fetchPosts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
let orderBy: string;
|
let orderBy: string;
|
||||||
let desc: boolean;
|
let desc: boolean;
|
||||||
switch (sortType) {
|
switch (sortType) {
|
||||||
case 'latest':
|
case 'latest':
|
||||||
orderBy = 'updated_at';
|
orderBy = 'updated_at';
|
||||||
desc = true;
|
desc = true;
|
||||||
break;
|
break;
|
||||||
case 'popular':
|
case 'popular':
|
||||||
orderBy = 'heat';
|
orderBy = 'heat';
|
||||||
desc = true;
|
desc = true;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
orderBy = 'updated_at';
|
orderBy = 'updated_at';
|
||||||
desc = true;
|
desc = true;
|
||||||
}
|
|
||||||
// 处理关键词,空格分割转逗号
|
|
||||||
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
|
|
||||||
const data = await listPosts({
|
|
||||||
page: 1,
|
|
||||||
size: 10,
|
|
||||||
orderBy: orderBy,
|
|
||||||
desc: desc,
|
|
||||||
keywords
|
|
||||||
});
|
|
||||||
setPosts(data.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch posts:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchPosts();
|
|
||||||
}, [sortType, sortTypeLoaded]);
|
|
||||||
|
|
||||||
// 获取标签
|
|
||||||
useEffect(() => {
|
|
||||||
listLabels().then(data => {
|
|
||||||
setLabels(data.data || []);
|
|
||||||
}).catch(error => {
|
|
||||||
console.error("Failed to fetch labels:", error);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 处理排序切换
|
|
||||||
const handleSortChange = (type: SortType) => {
|
|
||||||
if (sortType !== type) {
|
|
||||||
setSortType(type);
|
|
||||||
}
|
}
|
||||||
|
// 处理关键词,空格分割转逗号
|
||||||
|
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
|
||||||
|
const data = await listPosts({
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
orderBy: orderBy,
|
||||||
|
desc: desc,
|
||||||
|
keywords
|
||||||
|
});
|
||||||
|
setPosts(data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch posts:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
fetchPosts();
|
||||||
|
}, [sortType, sortTypeLoaded]);
|
||||||
|
|
||||||
return (
|
// 获取标签
|
||||||
<>
|
useEffect(() => {
|
||||||
{/* 主内容区域 */}
|
listLabels().then(data => {
|
||||||
<section className="py-16">
|
setLabels(data.data || []);
|
||||||
{/* 容器 - 关键布局 */}
|
}).catch(error => {
|
||||||
<div className="">
|
console.error("Failed to fetch labels:", error);
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
});
|
||||||
{/* 主要内容区域 */}
|
}, []);
|
||||||
<div className="lg:col-span-3 self-start">
|
|
||||||
{/* 文章列表标题 */}
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
|
||||||
{sortType === 'latest' ? '最新文章' : '热门文章'}
|
|
||||||
{posts.length > 0 && (
|
|
||||||
<span className="text-sm font-normal text-slate-500 ml-2">
|
|
||||||
({posts.length} 篇)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* 排序按钮组 */}
|
// 处理排序切换
|
||||||
<div className="flex items-center gap-2">
|
const handleSortChange = (type: SortType) => {
|
||||||
<Button
|
if (sortType !== type) {
|
||||||
variant={sortType === 'latest' ? 'default' : 'outline'}
|
setSortType(type);
|
||||||
size="sm"
|
}
|
||||||
onClick={() => handleSortChange('latest')}
|
};
|
||||||
disabled={loading}
|
|
||||||
className="transition-all duration-200"
|
|
||||||
>
|
|
||||||
<Clock className="w-4 h-4 mr-2" />
|
|
||||||
最新
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={sortType === 'popular' ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSortChange('popular')}
|
|
||||||
disabled={loading}
|
|
||||||
className="transition-all duration-200"
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-4 h-4 mr-2" />
|
|
||||||
热门
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 博客卡片网格 */}
|
return (
|
||||||
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
|
<>
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<section className="py-16">
|
||||||
|
{/* 容器 - 关键布局 */}
|
||||||
|
<div className="">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<motion.div
|
||||||
|
className="lg:col-span-3 self-start"
|
||||||
|
initial={{ y: 150, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
|
||||||
|
{/* 文章列表标题 */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
{sortType === 'latest' ? '最新文章' : '热门文章'}
|
||||||
|
{posts.length > 0 && (
|
||||||
|
<span className="text-sm font-normal text-slate-500 ml-2">
|
||||||
|
({posts.length} 篇)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
{/* 加载更多按钮 */}
|
{/* 排序按钮组 */}
|
||||||
{!loading && posts.length > 0 && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-center mt-12">
|
<Button
|
||||||
<Button size="lg" className="px-8">
|
variant={sortType === 'latest' ? 'default' : 'outline'}
|
||||||
加载更多文章
|
size="sm"
|
||||||
</Button>
|
onClick={() => handleSortChange('latest')}
|
||||||
</div>
|
disabled={loading}
|
||||||
)}
|
className="transition-all duration-200"
|
||||||
|
>
|
||||||
{/* 加载状态指示器 */}
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
{loading && (
|
最新
|
||||||
<div className="text-center py-8">
|
</Button>
|
||||||
<div className="inline-flex items-center gap-2 text-slate-600">
|
<Button
|
||||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
variant={sortType === 'popular' ? 'default' : 'outline'}
|
||||||
正在加载{sortType === 'latest' ? '最新' : '热门'}文章...
|
size="sm"
|
||||||
</div>
|
onClick={() => handleSortChange('popular')}
|
||||||
</div>
|
disabled={loading}
|
||||||
)}
|
className="transition-all duration-200"
|
||||||
</div>
|
>
|
||||||
|
<TrendingUp className="w-4 h-4 mr-2" />
|
||||||
{/* 侧边栏 */}
|
热门
|
||||||
<Sidebar
|
</Button>
|
||||||
cards={[
|
|
||||||
<SidebarAbout key="about" config={config} />,
|
|
||||||
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
|
|
||||||
<SidebarTags key="tags" labels={labels} />,
|
|
||||||
<SidebarMisskeyIframe key="misskey" />,
|
|
||||||
].filter(Boolean)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</>
|
|
||||||
);
|
{/* 博客卡片网格 */}
|
||||||
|
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
|
||||||
|
|
||||||
|
{/* 加载更多按钮 */}
|
||||||
|
{!loading && posts.length > 0 && (
|
||||||
|
<div className="text-center mt-12">
|
||||||
|
<Button size="lg" className="px-8">
|
||||||
|
加载更多文章
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态指示器 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="inline-flex items-center gap-2 text-slate-600">
|
||||||
|
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
正在加载{sortType === 'latest' ? '最新' : '热门'}文章...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 侧边栏 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: 200, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
cards={[
|
||||||
|
<SidebarAbout key="about" config={config} />,
|
||||||
|
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
|
||||||
|
<SidebarTags key="tags" labels={labels} />,
|
||||||
|
<SidebarMisskeyIframe key="misskey" />,
|
||||||
|
].filter(Boolean)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
@ -3,7 +3,7 @@ import { User } from "@/models/user";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getGravatarByUser } from "@/components/common/gravatar";
|
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
|
||||||
import { CircleUser } from "lucide-react";
|
import { CircleUser } from "lucide-react";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@ -60,7 +60,7 @@ export function CommentInput(
|
|||||||
<div className="fade-in-up">
|
<div className="fade-in-up">
|
||||||
<div className="flex py-4 fade-in">
|
<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">
|
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
||||||
{user ? getGravatarByUser(user) : null}
|
{user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
|
||||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 pl-2 fade-in-up">
|
<div className="flex-1 pl-2 fade-in-up">
|
||||||
|
@ -3,7 +3,7 @@ import { User } from "@/models/user";
|
|||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getGravatarByUser } from "@/components/common/gravatar";
|
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
|
||||||
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
|
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
|
||||||
import { Comment } from "@/models/comment";
|
import { Comment } from "@/models/comment";
|
||||||
import { TargetType } from "@/models/types";
|
import { TargetType } from "@/models/types";
|
||||||
@ -158,8 +158,8 @@ export function CommentItem(
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in">
|
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||||
{getGravatarByUser(comment.user)}
|
<GravatarAvatar email={comment.user.email} size={120}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 pl-2 fade-in-up">
|
<div className="flex-1 pl-2 fade-in-up">
|
||||||
<div className="flex gap-2 md:gap-4 items-center">
|
<div className="flex gap-2 md:gap-4 items-center">
|
||||||
|
1
web/src/components/common/captcha/captcha.css
Normal file
1
web/src/components/common/captcha/captcha.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* 选择 Turnstile 组件内的 iframe */
|
61
web/src/components/common/captcha/index.tsx
Normal file
61
web/src/components/common/captcha/index.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
"use client"
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
|
||||||
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||||
|
import { Turnstile } from "@marsidev/react-turnstile";
|
||||||
|
import { CaptchaProvider } from "@/models/captcha";
|
||||||
|
import "./captcha.css";
|
||||||
|
import { TurnstileWidget } from "./turnstile";
|
||||||
|
|
||||||
|
|
||||||
|
export type CaptchaProps = {
|
||||||
|
provider: CaptchaProvider;
|
||||||
|
siteKey: string;
|
||||||
|
url?: string;
|
||||||
|
onSuccess: (token: string) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReCaptchaWidget(props: CaptchaProps) {
|
||||||
|
return (
|
||||||
|
<GoogleReCaptchaProvider reCaptchaKey={props.siteKey} useEnterprise={false}>
|
||||||
|
<GoogleReCaptcha action="submit" onVerify={props.onSuccess} />
|
||||||
|
</GoogleReCaptchaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoCaptchaWidget(props: CaptchaProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
props.onSuccess("no-captcha");
|
||||||
|
}, [props, props.onSuccess]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HCaptchaWidget(props: CaptchaProps) {
|
||||||
|
return (
|
||||||
|
<HCaptcha
|
||||||
|
sitekey={props.siteKey}
|
||||||
|
onVerify={props.onSuccess}
|
||||||
|
onError={props.onError}
|
||||||
|
onExpire={() => props.onError?.("Captcha expired")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function AIOCaptchaWidget(props: CaptchaProps) {
|
||||||
|
switch (props.provider) {
|
||||||
|
case CaptchaProvider.HCAPTCHA:
|
||||||
|
return <HCaptchaWidget {...props} />;
|
||||||
|
case CaptchaProvider.RECAPTCHA:
|
||||||
|
return <ReCaptchaWidget {...props} />;
|
||||||
|
case CaptchaProvider.TURNSTILE:
|
||||||
|
return <TurnstileWidget {...props} />;
|
||||||
|
case CaptchaProvider.DISABLE:
|
||||||
|
return <NoCaptchaWidget {...props} />;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported captcha provider: ${props.provider}`);
|
||||||
|
}
|
||||||
|
}
|
54
web/src/components/common/captcha/turnstile.tsx
Normal file
54
web/src/components/common/captcha/turnstile.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { CaptchaProps } from ".";
|
||||||
|
import { Turnstile } from "@marsidev/react-turnstile";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
// 简单的转圈圈动画
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
|
||||||
|
<svg className="animate-spin" width="32" height="32" viewBox="0 0 50 50">
|
||||||
|
<circle className="opacity-25" cx="25" cy="25" r="20" fill="none" stroke="#e5e7eb" strokeWidth="5" />
|
||||||
|
<circle className="opacity-75" cx="25" cy="25" r="20" fill="none" stroke="#6366f1" strokeWidth="5" strokeDasharray="90 150" strokeDashoffset="0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 勾勾动画
|
||||||
|
function CheckMark() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 10 18 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfficialTurnstileWidget(props: CaptchaProps) {
|
||||||
|
return <div>
|
||||||
|
<Turnstile className="w-full" options={{ size: "invisible" }} siteKey={props.siteKey} onSuccess={props.onSuccess} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义包装组件
|
||||||
|
export function TurnstileWidget(props: CaptchaProps) {
|
||||||
|
const t = useTranslations("Captcha");
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success'>('loading');
|
||||||
|
|
||||||
|
// 只在验证通过时才显示勾
|
||||||
|
const handleSuccess = (token: string) => {
|
||||||
|
setStatus('success');
|
||||||
|
props.onSuccess(token);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative">
|
||||||
|
{status === 'loading' && <Spinner />}
|
||||||
|
{status === 'success' && <CheckMark />}
|
||||||
|
<div className="flex-1 text-center">{status === 'success' ? t("success") :t("doing")}</div>
|
||||||
|
<div className="absolute inset-0 opacity-0 pointer-events-none">
|
||||||
|
<OfficialTurnstileWidget {...props} onSuccess={handleSuccess} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -28,27 +28,25 @@ const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
|
|||||||
defaultType = "identicon"
|
defaultType = "identicon"
|
||||||
}) => {
|
}) => {
|
||||||
// 如果有自定义URL,使用自定义URL
|
// 如果有自定义URL,使用自定义URL
|
||||||
if (url) {
|
if (url && url.trim() !== "") {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={url}
|
src={url}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
className={`rounded-full object-cover ${className}`}
|
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
|
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={gravatarUrl}
|
src={gravatarUrl}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
className={`rounded-full object-cover ${className}`}
|
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
@ -63,14 +61,14 @@ interface User {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGravatarByUser(user?: User, className: string = ""): React.ReactElement {
|
export function getGravatarByUser({user, className="", size=640}:{user?: User, className?: string, size?: number}): React.ReactElement {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <GravatarAvatar email="" className={className} />;
|
return <GravatarAvatar email="" className={className} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<GravatarAvatar
|
<GravatarAvatar
|
||||||
email={user.email || ""}
|
email={user.email || ""}
|
||||||
size={40}
|
size={size}
|
||||||
className={className}
|
className={className}
|
||||||
alt={user.displayName || user.name || "User Avatar"}
|
alt={user.displayName || user.name || "User Avatar"}
|
||||||
url={user.avatarUrl}
|
url={user.avatarUrl}
|
||||||
|
@ -4,13 +4,13 @@ import * as React from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
NavigationMenuContent,
|
NavigationMenuContent,
|
||||||
NavigationMenuItem,
|
NavigationMenuItem,
|
||||||
NavigationMenuLink,
|
NavigationMenuLink,
|
||||||
NavigationMenuList,
|
NavigationMenuList,
|
||||||
NavigationMenuTrigger,
|
NavigationMenuTrigger,
|
||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
} from "@/components/ui/navigation-menu"
|
} from "@/components/ui/navigation-menu"
|
||||||
import GravatarAvatar from "@/components/common/gravatar"
|
import GravatarAvatar from "@/components/common/gravatar"
|
||||||
import { useDevice } from "@/contexts/device-context"
|
import { useDevice } from "@/contexts/device-context"
|
||||||
@ -21,157 +21,156 @@ import { Menu } from "lucide-react"
|
|||||||
import { Switch } from "../ui/switch"
|
import { Switch } from "../ui/switch"
|
||||||
|
|
||||||
const navbarMenuComponents = [
|
const navbarMenuComponents = [
|
||||||
{
|
{
|
||||||
title: "首页",
|
title: "首页",
|
||||||
href: "/"
|
href: "/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "文章",
|
title: "文章",
|
||||||
children: [
|
children: [
|
||||||
{ title: "归档", href: "/archives" },
|
{ title: "归档", href: "/archives" },
|
||||||
{ title: "标签", href: "/labels" },
|
{ title: "标签", href: "/labels" },
|
||||||
{ title: "随机", href: "/random" }
|
{ title: "随机", href: "/random" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "页面",
|
title: "页面",
|
||||||
children: [
|
children: [
|
||||||
{ title: "关于我", href: "/about" },
|
{ title: "关于我", href: "/about" },
|
||||||
{ title: "联系我", href: "/contact" },
|
{ title: "联系我", href: "/contact" },
|
||||||
{ title: "友链", href: "/links" },
|
{ title: "友链", href: "/links" },
|
||||||
{ title: "隐私政策", href: "/privacy-policy" },
|
{ title: "隐私政策", href: "/privacy-policy" },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { navbarAdditionalClassName, setMode, mode } = useDevice()
|
const { navbarAdditionalClassName, setMode, mode } = useDevice()
|
||||||
return (
|
return (
|
||||||
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full ${navbarAdditionalClassName}`}>
|
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
|
||||||
<div className="flex items-center justify-start">
|
<div className="flex items-center justify-start">
|
||||||
<span className="font-bold truncate">{config.metadata.name}</span>
|
<span className="font-bold truncate">{config.metadata.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<NavMenuCenter />
|
<NavMenuCenter />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
|
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
|
||||||
<GravatarAvatar email="snowykami@outlook.com" />
|
<SidebarMenuClientOnly />
|
||||||
<SidebarMenuClientOnly />
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
</nav>
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavMenuCenter() {
|
function NavMenuCenter() {
|
||||||
return (
|
return (
|
||||||
<NavigationMenu viewport={false} className="hidden md:block">
|
<NavigationMenu viewport={false} className="hidden md:block">
|
||||||
<NavigationMenuList className="flex space-x-1">
|
<NavigationMenuList className="flex space-x-1">
|
||||||
{navbarMenuComponents.map((item) => (
|
{navbarMenuComponents.map((item) => (
|
||||||
<NavigationMenuItem key={item.title}>
|
<NavigationMenuItem key={item.title}>
|
||||||
{item.href ? (
|
{item.href ? (
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link href={item.href} className="flex items-center gap-1 font-extrabold">
|
<Link href={item.href} className="flex items-center gap-1 font-extrabold">
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
) : item.children ? (
|
) : item.children ? (
|
||||||
<>
|
<>
|
||||||
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
|
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</NavigationMenuTrigger>
|
</NavigationMenuTrigger>
|
||||||
<NavigationMenuContent>
|
<NavigationMenuContent>
|
||||||
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">
|
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">
|
||||||
{item.children.map((child) => (
|
{item.children.map((child) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={child.title}
|
key={child.title}
|
||||||
title={child.title}
|
title={child.title}
|
||||||
href={child.href}
|
href={child.href}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
))}
|
))}
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenu>
|
</NavigationMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListItem({
|
function ListItem({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
href,
|
href,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
|
}: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
|
||||||
return (
|
return (
|
||||||
<li {...props} className="flex justify-center">
|
<li {...props} className="flex justify-center">
|
||||||
<NavigationMenuLink asChild>
|
<NavigationMenuLink asChild>
|
||||||
<Link href={href} className="flex flex-col items-center text-center w-full">
|
<Link href={href} className="flex flex-col items-center text-center w-full">
|
||||||
<div className="text-sm leading-none font-medium">{title}</div>
|
<div className="text-sm leading-none font-medium">{title}</div>
|
||||||
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuClientOnly() {
|
function SidebarMenuClientOnly() {
|
||||||
return <SidebarMenu />;
|
return <SidebarMenu />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenu() {
|
function SidebarMenu() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<button
|
<button
|
||||||
aria-label="打开菜单"
|
aria-label="打开菜单"
|
||||||
className="p-2 rounded-md hover:bg-accent transition-colors"
|
className="p-2 rounded-md hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<Menu className="w-6 h-6" />
|
<Menu className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" className="p-0 w-64">
|
<SheetContent side="right" className="p-0 w-64">
|
||||||
{/* 可访问性要求的标题,视觉上隐藏 */}
|
{/* 可访问性要求的标题,视觉上隐藏 */}
|
||||||
<SheetTitle className="sr-only">侧边栏菜单</SheetTitle>
|
<SheetTitle className="sr-only">侧边栏菜单</SheetTitle>
|
||||||
<nav className="flex flex-col gap-2 p-4">
|
<nav className="flex flex-col gap-2 p-4">
|
||||||
{navbarMenuComponents.map((item) =>
|
{navbarMenuComponents.map((item) =>
|
||||||
item.href ? (
|
item.href ? (
|
||||||
<Link
|
<Link
|
||||||
key={item.title}
|
key={item.title}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
|
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
) : item.children ? (
|
) : item.children ? (
|
||||||
<div key={item.title} className="mb-2">
|
<div key={item.title} className="mb-2">
|
||||||
<div className="font-bold px-3 py-2">{item.title}</div>
|
<div className="font-bold px-3 py-2">{item.title}</div>
|
||||||
<div className="flex flex-col pl-4">
|
<div className="flex flex-col pl-4">
|
||||||
{item.children.map((child) => (
|
{item.children.map((child) => (
|
||||||
<Link
|
<Link
|
||||||
key={child.title}
|
key={child.title}
|
||||||
href={child.href}
|
href={child.href}
|
||||||
className="py-2 px-3 rounded hover:bg-accent transition-colors"
|
className="py-2 px-3 rounded hover:bg-accent transition-colors"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{child.title}
|
{child.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet></div>
|
</Sheet></div>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -14,10 +14,12 @@ import { Label } from "@/components/ui/label"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import type { OidcConfig } from "@/models/oidc-config"
|
import type { OidcConfig } from "@/models/oidc-config"
|
||||||
import { ListOidcConfigs, userLogin } from "@/api/user"
|
import { getCaptchaConfig, ListOidcConfigs, userLogin } from "@/api/user"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
import Captcha from "../common/captcha"
|
||||||
|
import { CaptchaProvider } from "@/models/captcha"
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
@ -25,12 +27,18 @@ export function LoginForm({
|
|||||||
}: React.ComponentProps<"div">) {
|
}: React.ComponentProps<"div">) {
|
||||||
const t = useTranslations('Login')
|
const t = useTranslations('Login')
|
||||||
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
|
||||||
|
const [captchaProps, setCaptchaProps] = useState<{
|
||||||
|
provider: CaptchaProvider
|
||||||
|
siteKey: string
|
||||||
|
url?: string
|
||||||
|
} | null>(null)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||||
|
const [captchaError, setCaptchaError] = useState<string | null>(null)
|
||||||
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
|
const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ListOidcConfigs()
|
ListOidcConfigs()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@ -42,10 +50,20 @@ export function LoginForm({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCaptchaConfig()
|
||||||
|
.then((res) => {
|
||||||
|
setCaptchaProps(res.data)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching captcha config:", error)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
try {
|
try {
|
||||||
const res = await userLogin({username, password})
|
const res = await userLogin({ username, password, captcha: captchaToken || "" })
|
||||||
console.log("Login successful:", res)
|
console.log("Login successful:", res)
|
||||||
router.push(redirectBack)
|
router.push(redirectBack)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -128,7 +146,19 @@ export function LoginForm({
|
|||||||
onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))}
|
onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" onClick={handleLogin}>
|
{captchaProps &&
|
||||||
|
<div className="flex justify-center items-center w-full">
|
||||||
|
<Captcha {...captchaProps} onSuccess={setCaptchaToken} onError={setCaptchaError} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{captchaError && <div>
|
||||||
|
{t("captcha_error")}</div>}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={!captchaToken}
|
||||||
|
>
|
||||||
{t("login")}
|
{t("login")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
5
web/src/components/user/index.tsx
Normal file
5
web/src/components/user/index.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { User } from "@/models/user";
|
||||||
|
|
||||||
|
export function UserPage({user}: {user: User}) {
|
||||||
|
return <div>User: {user.username}</div>;
|
||||||
|
}
|
13
web/src/components/user/user-profile.tsx
Normal file
13
web/src/components/user/user-profile.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"use client"
|
||||||
|
import { getUserByUsername } from "@/api/user";
|
||||||
|
import { User } from "@/models/user";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getGravatarByUser } from "../common/gravatar";
|
||||||
|
|
||||||
|
export function UserProfile({ user }: { user: User }) {
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
{getGravatarByUser({user,className: "rounded-full mr-4"})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -16,6 +16,7 @@ const config = {
|
|||||||
bodyWidthMobile: "100vw",
|
bodyWidthMobile: "100vw",
|
||||||
postsPerPage: 12,
|
postsPerPage: 12,
|
||||||
commentsPerPage: 8,
|
commentsPerPage: 8,
|
||||||
|
animationDurationSecond: 0.5,
|
||||||
footer: {
|
footer: {
|
||||||
text: "Liteyuki ICP备 1145141919810",
|
text: "Liteyuki ICP备 1145141919810",
|
||||||
links: []
|
links: []
|
||||||
|
@ -2,6 +2,10 @@
|
|||||||
"HomePage": {
|
"HomePage": {
|
||||||
"title": "Hello world!"
|
"title": "Hello world!"
|
||||||
},
|
},
|
||||||
|
"Captcha": {
|
||||||
|
"doing": "正在检测你是不是机器人...",
|
||||||
|
"success": "恭喜,你是人类!"
|
||||||
|
},
|
||||||
"Comment": {
|
"Comment": {
|
||||||
"collapse_replies": "收起",
|
"collapse_replies": "收起",
|
||||||
"comment": "评论",
|
"comment": "评论",
|
||||||
@ -41,6 +45,7 @@
|
|||||||
"secondsAgo": "秒前"
|
"secondsAgo": "秒前"
|
||||||
},
|
},
|
||||||
"Login": {
|
"Login": {
|
||||||
|
"captcha_error": "验证错误,请重试。",
|
||||||
"welcome": "欢迎回来",
|
"welcome": "欢迎回来",
|
||||||
"with_oidc": "使用第三方身份提供者",
|
"with_oidc": "使用第三方身份提供者",
|
||||||
"or_continue_with_local_account": "或使用用户名和密码",
|
"or_continue_with_local_account": "或使用用户名和密码",
|
||||||
|
7
web/src/models/captcha.ts
Normal file
7
web/src/models/captcha.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export enum CaptchaProvider {
|
||||||
|
HCAPTCHA = "hcaptcha",
|
||||||
|
MCAPTCHA = "mcaptcha",
|
||||||
|
RECAPTCHA = "recaptcha",
|
||||||
|
TURNSTILE = "turnstile",
|
||||||
|
DISABLE = "disable",
|
||||||
|
}
|
@ -10,13 +10,6 @@ export interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
rememberMe?: boolean
|
|
||||||
captcha?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
|
Reference in New Issue
Block a user