feat: 更新验证码逻辑,优化邮件验证功能,增加验证码冷却时间,改进用户体验Closes #17

This commit is contained in:
2025-09-24 09:33:09 +08:00
parent 291f52395b
commit e44637fb2a
19 changed files with 138 additions and 58 deletions

View File

@ -1,7 +1,7 @@
import type { OidcConfig } from '@/models/oidc-config'
import type { BaseResponse } from '@/models/resp'
import type { User } from '@/models/user'
import { CaptchaProvider } from '@/models/captcha'
import { CaptchaProvider } from '@/types/captcha'
import axiosClient from './client'
export async function userLogin(
@ -97,8 +97,8 @@ export async function updateUser(data: Partial<User>): Promise<BaseResponse<User
return res.data
}
export async function requestEmailVerifyCode(email: string): Promise<BaseResponse<{ coolDown: number }>> {
const res = await axiosClient.post<BaseResponse<{ coolDown: number }>>('/user/email/verify', { email })
export async function requestEmailVerifyCode({email, captchaToken}: {email: string, captchaToken?: string}): Promise<BaseResponse<{ coolDown: number }>> {
const res = await axiosClient.post<BaseResponse<{ coolDown: number }>>('/user/email/verify', { email }, { headers: { 'X-Captcha-Token': captchaToken } })
return res.data
}

View File

@ -5,7 +5,7 @@ import { RegisterForm } from '@/components/auth/register/register-form'
function PageContent() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex w-full max-w-md flex-col gap-6">
<AuthHeader />
<RegisterForm />
</div>

View File

@ -3,7 +3,7 @@ import { ResetPasswordForm } from "@/components/auth/reset-password/reset-passwo
export default function Page() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex w-full max-w-md flex-col gap-6">
<AuthHeader />
<ResetPasswordForm />
</div>

View File

@ -18,7 +18,7 @@ import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import Captcha from "@/components/common/captcha"
import { CaptchaProvider } from "@/models/captcha"
import { CaptchaProvider } from "@/types/captcha"
import { toast } from "sonner"
import { useAuth } from "@/contexts/auth-context"
import { registerPath, resetPasswordPath } from "@/hooks/use-route"

View File

@ -15,7 +15,7 @@ import { getCaptchaConfig, requestEmailVerifyCode, userRegister } from "@/api/us
import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import Captcha from "@/components/common/captcha"
import { CaptchaProvider } from "@/models/captcha"
import { CaptchaProvider } from "@/types/captcha"
import { toast } from "sonner"
import { CurrentLogged } from "@/components/auth/common/current-logged"
import { SectionDivider } from "@/components/common/section-divider"
@ -41,13 +41,22 @@ export function RegisterForm({
url?: string
} | null>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [isLogging, setIsLogging] = useState(false)
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [sendingVerifyCode, setSendingVerifyCode] = useState(false)
const [registering, setRegistering] = useState(false)
const [coolDown, setCoolDown] = useState(0)
useEffect(() => {
if (coolDown <= 0) return
const id = setInterval(() => {
setCoolDown(c => (c > 1 ? c - 1 : 0))
}, 1000)
return () => clearInterval(id)
}, [coolDown])
useEffect(() => {
getCaptchaConfig()
@ -68,13 +77,19 @@ export function RegisterForm({
}
const handleSendVerifyCode = () => {
requestEmailVerifyCode(email)
if (!email || coolDown > 0 || sendingVerifyCode) return;
setSendingVerifyCode(true);
requestEmailVerifyCode({ email, captchaToken: captchaToken || '' })
.then(() => {
toast.success(t("send_verify_code_success"))
})
.catch((error: BaseErrorResponse) => {
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
})
.finally(() => {
setSendingVerifyCode(false);
setCoolDown(60);
})
}
const handleRegister = () => {
@ -83,9 +98,9 @@ export function RegisterForm({
return;
}
if (!captchaToken) {
toast.error(t("please_complete_captcha_verification"));
return;
}
setRegistering(true)
userRegister({ username, password, email, verifyCode, captchaToken })
.then(res => {
toast.success(t("register_success") + ` ${res.data.user.nickname || res.data.user.username}`);
@ -98,12 +113,12 @@ export function RegisterForm({
setCaptchaToken(null)
})
.finally(() => {
setIsLogging(false)
setRegistering(false)
})
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<div className={cn("", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">{t("title")}</CardTitle>
@ -164,11 +179,10 @@ export function RegisterForm({
<InputOTPControlled
onChange={value => setVerifyCode(value)}
/>
<Button onClick={handleSendVerifyCode} disabled={!email} variant="outline" className="border-2" type="button">
{commonT("send_verify_code")}
<Button onClick={handleSendVerifyCode} disabled={!email || coolDown > 0 || sendingVerifyCode} variant="outline" className="border-2" type="button">
{commonT("send_verify_code")}{coolDown > 0 ? `(${coolDown})` : ""}
</Button>
</div>
</div>
{captchaProps &&
<div className="flex justify-center items-center w-full">
@ -179,9 +193,9 @@ export function RegisterForm({
type="button"
className="w-full"
onClick={handleRegister}
disabled={!captchaToken || isLogging || !username || !password || !email}
disabled={!captchaToken || registering || !username || !password || !email || !(verifyCode.length == 6)}
>
{isLogging ? t("registering") : t("register")}
{registering ? t("registering") : t("register")}
</Button>
{/* 注册链接 */}
<div className="text-center text-sm">

View File

@ -10,7 +10,7 @@ import {
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useState } from "react"
import { useEffect, useState } from "react"
import { requestEmailVerifyCode, resetPassword } from "@/api/user"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
@ -28,15 +28,31 @@ export function ResetPasswordForm({
const [email, setEmail] = useState("")
const [verifyCode, setVerifyCode] = useState("")
const [newPassword, setNewPassword] = useState("")
const [coolDown, setCoolDown] = useState(0)
const [sendingVerifyCode, setSendingVerifyCode] = useState(false)
useEffect(() => {
if (coolDown <= 0) return
const id = setInterval(() => {
setCoolDown(c => (c > 1 ? c - 1 : 0))
}, 1000)
return () => clearInterval(id)
}, [coolDown])
const handleSendVerifyCode = () => {
requestEmailVerifyCode(email)
if (coolDown > 0 || !email || sendingVerifyCode) return
setSendingVerifyCode(true)
requestEmailVerifyCode({ email })
.then(() => {
toast.success(t("send_verify_code_success"))
})
.catch((error: BaseErrorResponse) => {
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
})
.finally(() => {
setSendingVerifyCode(false)
setCoolDown(60)
})
}
const handleResetPassword = () => {
@ -66,18 +82,23 @@ export function ResetPasswordForm({
<Label htmlFor="email">{commonT("email")}</Label>
<div className="flex gap-3">
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button
disabled={!email}
variant="outline"
className="border-2"
type="button"
onClick={handleSendVerifyCode}>{t("send_verify_code")}
</Button>
</div>
</div>
<div className="grid gap-3">
<Label htmlFor="verify_code">{t("verify_code")}</Label>
<InputOTPControlled onChange={value => setVerifyCode(value)} />
<div className="flex gap-3 justify-between">
<InputOTPControlled onChange={value => setVerifyCode(value)} />
<Button
disabled={!email || coolDown > 0}
variant="outline"
className="border-2"
type="button"
onClick={handleSendVerifyCode}>
{t("send_verify_code")}{coolDown > 0 ? `(${coolDown})` : ""}
</Button>
</div>
</div>
<Button
type="button"

View File

@ -0,0 +1,21 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { CaptchaProps } from "@/types/captcha";
import AIOCaptchaWidget from ".";
import { cn } from "@/lib/utils";
export function DialogCaptcha({ className, ...props }: React.ComponentProps<"div"> & CaptchaProps) {
return (
<div className={cn(className)}>
<Dialog>
<DialogTrigger>{props.children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Captcha</DialogTitle>
</DialogHeader>
<AIOCaptchaWidget {...props} />
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -3,19 +3,12 @@
import { useEffect } from "react";
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { CaptchaProvider } from "@/models/captcha";
import "./captcha.css";
import { TurnstileWidget } from "./turnstile";
import { CaptchaProps, CaptchaProvider } from "@/types/captcha";
export type CaptchaProps = {
provider: CaptchaProvider;
siteKey: string;
url?: string;
onSuccess: (token: string) => void;
onError: (error: string) => void;
onAbort?: () => void;
};
export function ReCaptchaWidget(props: CaptchaProps) {
return (

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { CaptchaProps } from ".";
import { Turnstile } from "@marsidev/react-turnstile";
import { useTranslations } from "next-intl";
import { CaptchaProps } from "@/types/captcha";
const TURNSTILE_TIMEOUT = 15
// 简单的转圈圈动画

View File

@ -34,7 +34,7 @@ export function UserSecurityPage() {
}
const handleSendVerifyCode = () => {
requestEmailVerifyCode(email)
requestEmailVerifyCode({email})
.then(() => {
toast.success(t("send_verify_code_success"))
})

View File

@ -16,6 +16,7 @@ const config = {
bodyWidthMobile: "100vw",
postsPerPage: 9,
commentsPerPage: 8,
verifyCodeCoolDown: 60,
animationDurationSecond: 0.618,
footer: {
text: "Liteyuki ICP备 1145141919810",

View File

@ -156,6 +156,7 @@
"Register": {
"title": "注册",
"already_have_account": "已经有账号?",
"please_fill_in_all_required_fields": "请填写所有必填项",
"register": "注册",
"registering": "注册中...",
"register_a_new_account": "注册一个新账号",

View File

@ -1,7 +0,0 @@
export enum CaptchaProvider {
HCAPTCHA = "hcaptcha",
MCAPTCHA = "mcaptcha",
RECAPTCHA = "recaptcha",
TURNSTILE = "turnstile",
DISABLE = "disable",
}

16
web/src/types/captcha.ts Normal file
View File

@ -0,0 +1,16 @@
export enum CaptchaProvider {
HCAPTCHA = "hcaptcha",
MCAPTCHA = "mcaptcha",
RECAPTCHA = "recaptcha",
TURNSTILE = "turnstile",
DISABLE = "disable",
}
export type CaptchaProps = {
provider: CaptchaProvider;
siteKey: string;
url?: string;
onSuccess: (token: string) => void;
onError: (error: string) => void;
onAbort?: () => void;
};