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/dto"
|
||||
"github.com/snowykami/neo-blog/internal/service"
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
"github.com/snowykami/neo-blog/pkg/errs"
|
||||
"github.com/snowykami/neo-blog/pkg/resps"
|
||||
utils2 "github.com/snowykami/neo-blog/pkg/utils"
|
||||
)
|
||||
|
||||
type UserController struct {
|
||||
@ -125,6 +127,22 @@ func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
@ -184,3 +202,11 @@ func (u *UserController) ChangePassword(ctx context.Context, c *app.RequestConte
|
||||
func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) {
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type GetUserByUsernameReq struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type GetUserResp struct {
|
||||
User UserDto `json:"user"` // 用户信息
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/hertz/pkg/app"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/snowykami/neo-blog/pkg/resps"
|
||||
|
@ -10,14 +10,16 @@ func registerUserRoutes(group *route.RouterGroup) {
|
||||
userController := v1.NewUserController()
|
||||
userGroup := group.Group("/user").Use(middleware.UseAuth(true))
|
||||
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("/register", userController.Register)
|
||||
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code
|
||||
userGroupWithoutAuth.GET("/captcha", userController.GetCaptchaConfig)
|
||||
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
|
||||
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
|
||||
userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
|
||||
userGroupWithoutAuth.GET("/username/:username", userController.GetUserByUsername)
|
||||
userGroup.GET("/me", userController.GetUser)
|
||||
userGroupWithoutAuth.POST("/logout", userController.Logout)
|
||||
userGroup.PUT("/u/:id", userController.UpdateUser)
|
||||
|
@ -3,6 +3,10 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/snowykami/neo-blog/internal/dto"
|
||||
"github.com/snowykami/neo-blog/internal/model"
|
||||
@ -12,9 +16,6 @@ import (
|
||||
"github.com/snowykami/neo-blog/pkg/errs"
|
||||
"github.com/snowykami/neo-blog/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserService struct{}
|
||||
@ -330,6 +331,26 @@ func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) {
|
||||
}, 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) {
|
||||
user := &model.User{
|
||||
Model: gorm.Model{
|
||||
|
@ -11,14 +11,18 @@ const (
|
||||
RoleUser = "user"
|
||||
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
|
||||
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
|
||||
EnvKeyMode = "MODE" // 环境变量:运行模式
|
||||
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
||||
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
||||
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
||||
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
|
||||
EnvKeyTokenDurationDefault = 300
|
||||
EnvKeyRefreshTokenDurationDefault = 604800
|
||||
EnvKeyTokenDurationDefault = 300 // Token有效时长
|
||||
EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长
|
||||
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
||||
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
||||
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码
|
||||
|
@ -13,15 +13,15 @@ var Captcha = captchaUtils{}
|
||||
|
||||
type CaptchaConfig struct {
|
||||
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
|
||||
}
|
||||
|
||||
func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig {
|
||||
return &CaptchaConfig{
|
||||
Type: Env.Get("CAPTCHA_TYPE", "disable"),
|
||||
SiteSecret: Env.Get("CAPTCHA_SITE_SECRET", ""),
|
||||
SecretKey: Env.Get("CAPTCHA_SECRET_KEY", ""),
|
||||
Type: Env.Get(constant.EnvKeyCaptchaProvider, constant.CaptchaTypeDisable),
|
||||
SiteKey: Env.Get(constant.EnvKeyCaptchaSiteKey, ""),
|
||||
SecretKey: Env.Get(constant.EnvKeyCaptchaSecreteKey, ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,8 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcaptcha/react-hcaptcha": "^1.12.1",
|
||||
"@marsidev/react-turnstile": "^1.3.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@ -30,6 +32,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-google-recaptcha-v3": "^1.11.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"sonner": "^2.0.6",
|
||||
|
63
web/pnpm-lock.yaml
generated
63
web/pnpm-lock.yaml
generated
@ -8,6 +8,12 @@ importers:
|
||||
|
||||
.:
|
||||
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':
|
||||
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)
|
||||
@ -71,6 +77,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 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:
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0(react@19.1.0)
|
||||
@ -130,6 +139,10 @@ packages:
|
||||
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
||||
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':
|
||||
resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==}
|
||||
|
||||
@ -195,6 +208,15 @@ packages:
|
||||
'@formatjs/intl-localematcher@0.6.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@ -354,6 +376,12 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
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':
|
||||
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
|
||||
|
||||
@ -1689,6 +1717,9 @@ packages:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@ -2320,6 +2351,12 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
|
||||
peerDependencies:
|
||||
@ -2788,6 +2825,8 @@ snapshots:
|
||||
|
||||
'@babel/helper-validator-identifier@7.27.1': {}
|
||||
|
||||
'@babel/runtime@7.28.4': {}
|
||||
|
||||
'@emnapi/core@1.4.4':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.0.3
|
||||
@ -2878,6 +2917,15 @@ snapshots:
|
||||
dependencies:
|
||||
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/node@0.16.6':
|
||||
@ -2995,6 +3043,11 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@ -4499,6 +4552,10 @@ snapshots:
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
@ -5313,6 +5370,12 @@ snapshots:
|
||||
react: 19.1.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):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
@ -1,14 +1,26 @@
|
||||
import type { OidcConfig } from '@/models/oidc-config'
|
||||
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 { CaptchaProvider } from '@/models/captcha'
|
||||
|
||||
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 }>>(
|
||||
'/user/login',
|
||||
data,
|
||||
{ username, password, rememberMe },
|
||||
{ headers: { 'X-Captcha-Token': captcha || '' } },
|
||||
)
|
||||
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}`)
|
||||
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 { BackgroundProvider } from '@/contexts/background-context'
|
||||
import Footer from '@/components/layout/footer'
|
||||
import config from '@/config'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@ -14,25 +15,18 @@ export default function RootLayout({
|
||||
const pathname = usePathname()
|
||||
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 />
|
||||
</header>
|
||||
<motion.main
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.30,
|
||||
}}
|
||||
className="pt-16"
|
||||
>
|
||||
</motion.nav>
|
||||
|
||||
<BackgroundProvider>
|
||||
<div className='container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
|
||||
<div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
|
||||
</BackgroundProvider>
|
||||
</motion.main>
|
||||
<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,6 +13,7 @@ import { useEffect, useState } from "react";
|
||||
import { useStoredState } from '@/hooks/use-storage-state';
|
||||
import { listLabels } from "@/api/label";
|
||||
import { POST_SORT_TYPE } from "@/localstore";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
// 定义排序类型
|
||||
type SortType = 'latest' | 'popular';
|
||||
@ -85,7 +86,11 @@ export default function BlogHome() {
|
||||
<div className="">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* 主要内容区域 */}
|
||||
<div className="lg:col-span-3 self-start">
|
||||
<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">
|
||||
@ -143,9 +148,15 @@ export default function BlogHome() {
|
||||
</div>
|
||||
</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} />,
|
||||
@ -154,6 +165,8 @@ export default function BlogHome() {
|
||||
<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 { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getGravatarByUser } from "@/components/common/gravatar";
|
||||
import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
@ -60,7 +60,7 @@ export function CommentInput(
|
||||
<div className="fade-in-up">
|
||||
<div className="flex py-4 fade-in">
|
||||
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
|
||||
{user ? getGravatarByUser(user) : null}
|
||||
{user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
|
||||
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
|
@ -3,7 +3,7 @@ import { User } from "@/models/user";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
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 { Comment } from "@/models/comment";
|
||||
import { TargetType } from "@/models/types";
|
||||
@ -158,8 +158,8 @@ export function CommentItem(
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in">
|
||||
{getGravatarByUser(comment.user)}
|
||||
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
|
||||
<GravatarAvatar email={comment.user.email} size={120}/>
|
||||
</div>
|
||||
<div className="flex-1 pl-2 fade-in-up">
|
||||
<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"
|
||||
}) => {
|
||||
// 如果有自定义URL,使用自定义URL
|
||||
if (url) {
|
||||
if (url && url.trim() !== "") {
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded-full object-cover ${className}`}
|
||||
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||
alt={alt}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`rounded-full object-cover ${className}`}
|
||||
className={`rounded-full object-cover w-full h-full ${className}`}
|
||||
alt={alt}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
@ -63,14 +61,14 @@ interface User {
|
||||
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) {
|
||||
return <GravatarAvatar email="" className={className} />;
|
||||
}
|
||||
return (
|
||||
<GravatarAvatar
|
||||
email={user.email || ""}
|
||||
size={40}
|
||||
size={size}
|
||||
className={className}
|
||||
alt={user.displayName || user.name || "User Avatar"}
|
||||
url={user.avatarUrl}
|
||||
|
@ -47,7 +47,7 @@ const navbarMenuComponents = [
|
||||
export function Navbar() {
|
||||
const { navbarAdditionalClassName, setMode, mode } = useDevice()
|
||||
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">
|
||||
<span className="font-bold truncate">{config.metadata.name}</span>
|
||||
</div>
|
||||
@ -56,7 +56,6 @@ export function Navbar() {
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
|
||||
<GravatarAvatar email="snowykami@outlook.com" />
|
||||
<SidebarMenuClientOnly />
|
||||
</div>
|
||||
</nav>
|
||||
|
@ -14,10 +14,12 @@ import { Label } from "@/components/ui/label"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useState } from "react"
|
||||
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 { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Captcha from "../common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
@ -25,12 +27,18 @@ export function LoginForm({
|
||||
}: React.ComponentProps<"div">) {
|
||||
const t = useTranslations('Login')
|
||||
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 router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectBack = searchParams.get("redirect_back") || "/"
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
ListOidcConfigs()
|
||||
.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) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const res = await userLogin({username, password})
|
||||
const res = await userLogin({ username, password, captcha: captchaToken || "" })
|
||||
console.log("Login successful:", res)
|
||||
router.push(redirectBack)
|
||||
} catch (error) {
|
||||
@ -128,7 +146,19 @@ export function LoginForm({
|
||||
onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))}
|
||||
/>
|
||||
</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")}
|
||||
</Button>
|
||||
</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",
|
||||
postsPerPage: 12,
|
||||
commentsPerPage: 8,
|
||||
animationDurationSecond: 0.5,
|
||||
footer: {
|
||||
text: "Liteyuki ICP备 1145141919810",
|
||||
links: []
|
||||
|
@ -2,6 +2,10 @@
|
||||
"HomePage": {
|
||||
"title": "Hello world!"
|
||||
},
|
||||
"Captcha": {
|
||||
"doing": "正在检测你是不是机器人...",
|
||||
"success": "恭喜,你是人类!"
|
||||
},
|
||||
"Comment": {
|
||||
"collapse_replies": "收起",
|
||||
"comment": "评论",
|
||||
@ -41,6 +45,7 @@
|
||||
"secondsAgo": "秒前"
|
||||
},
|
||||
"Login": {
|
||||
"captcha_error": "验证错误,请重试。",
|
||||
"welcome": "欢迎回来",
|
||||
"with_oidc": "使用第三方身份提供者",
|
||||
"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 {
|
||||
username: string
|
||||
password: string
|
||||
|
Reference in New Issue
Block a user