注册页 & 登录页
This commit is contained in:
		
							parent
							
								
									3be70555b4
								
							
						
					
					
						commit
						bd0332a582
					
				
							
								
								
									
										6
									
								
								src/api/email.api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/api/email.api.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | |||||||
|  | import axios from '@/utils/axios' | ||||||
|  | import { EmailSendDto, EmailSendResponse } from './email.interface' | ||||||
|  | 
 | ||||||
|  | export async function sendEmailVerifyCode(params: EmailSendDto) { | ||||||
|  |   return axios.get<EmailSendResponse>('/api/email/verifyCode', { params }) | ||||||
|  | } | ||||||
							
								
								
									
										28
									
								
								src/api/email.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/api/email.interface.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | |||||||
|  | /** 邮箱验证码场景 */ | ||||||
|  | export enum EmailVerifyCodeScene { | ||||||
|  |   register = 'register', | ||||||
|  |   forgetPassword = 'forgetPassword', | ||||||
|  |   changeEmail = 'changeEmail', | ||||||
|  |   deleteUser = 'deleteUser', | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** 验证码邮件发送入参 */ | ||||||
|  | export interface EmailSendDto { | ||||||
|  |   email: string | ||||||
|  |   scene: EmailVerifyCodeScene | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** 验证码邮件发送回参 */ | ||||||
|  | export interface EmailSendResponse { | ||||||
|  |   token: string | ||||||
|  |   userId?: string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** 邮箱验证入参 */ | ||||||
|  | export interface EmailVerifyDto { | ||||||
|  |   email: string | ||||||
|  |   /** @description 发送邮箱接口返回的Token */ | ||||||
|  |   token: string | ||||||
|  |   /** @description 邮箱验证码 */ | ||||||
|  |   verifyCode: string | ||||||
|  | } | ||||||
| @ -1 +1,2 @@ | |||||||
| export * as user from './user' | export * as user from './user.api' | ||||||
|  | export * as email from './email.api' | ||||||
|  | |||||||
| @ -2,13 +2,13 @@ import axios from '@/utils/axios' | |||||||
| import { type AxiosResponse } from 'axios' | import { type AxiosResponse } from 'axios' | ||||||
| import { | import { | ||||||
|   LoginInputDto, |   LoginInputDto, | ||||||
|   CreateUserDto, |   RegisterInputDto, | ||||||
|   Token, |   Token, | ||||||
|   TokenRefreshPayload, |   TokenRefreshPayload, | ||||||
|   User, |   User, | ||||||
| } from './user.interface' | } from './user.interface' | ||||||
| 
 | 
 | ||||||
| export async function register(data: CreateUserDto) { | export async function register(data: RegisterInputDto) { | ||||||
|   return axios.post<Token>('/api/users', data) |   return axios.post<Token>('/api/users', data) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -1,10 +1,13 @@ | |||||||
|  | import { EmailVerifyDto } from './email.interface' | ||||||
|  | 
 | ||||||
|  | /** 登录入参 */ | ||||||
| export interface LoginInputDto { | export interface LoginInputDto { | ||||||
|   email: string |   email: string | ||||||
|   password: string |   password: string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface CreateUserDto { | /** 注册入参 */ | ||||||
|   email: string | export interface RegisterInputDto extends EmailVerifyDto { | ||||||
|   password: string |   password: string | ||||||
|   username?: string |   username?: string | ||||||
| } | } | ||||||
| @ -18,6 +21,7 @@ export interface TokenRefreshPayload { | |||||||
|   refreshToken: string |   refreshToken: string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /** 用户 */ | ||||||
| export interface User { | export interface User { | ||||||
|   id: string |   id: string | ||||||
|   email: string |   email: string | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import NextLink, { type LinkProps as NextLinkProps } from 'next/link' | |||||||
| import { LinkProps } from '@mui/material/Link' | import { LinkProps } from '@mui/material/Link' | ||||||
| import { ThemeProvider, createTheme } from '@mui/material' | import { ThemeProvider, createTheme } from '@mui/material' | ||||||
| import { ToastContainer } from 'react-toastify' | import { ToastContainer } from 'react-toastify' | ||||||
|  | import styled from '@emotion/styled' | ||||||
| import 'react-toastify/dist/ReactToastify.css' | import 'react-toastify/dist/ReactToastify.css' | ||||||
| import '@fontsource/roboto/300.css' | import '@fontsource/roboto/300.css' | ||||||
| import '@fontsource/roboto/400.css' | import '@fontsource/roboto/400.css' | ||||||
| @ -32,12 +33,18 @@ const theme = createTheme({ | |||||||
|   }, |   }, | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
|  | const StyledToastContainer = styled(ToastContainer)` | ||||||
|  |   .Toastify__toast-body { | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |   } | ||||||
|  | ` | ||||||
|  | 
 | ||||||
| export default function App({ Component, pageProps }: AppProps) { | export default function App({ Component, pageProps }: AppProps) { | ||||||
|   return ( |   return ( | ||||||
|     <ThemeProvider theme={theme}> |     <ThemeProvider theme={theme}> | ||||||
|       <CssBaseline /> |       <CssBaseline /> | ||||||
|       <Component {...pageProps} /> |       <Component {...pageProps} /> | ||||||
|       <ToastContainer |       <StyledToastContainer | ||||||
|         position="top-center" |         position="top-center" | ||||||
|         autoClose={3000} |         autoClose={3000} | ||||||
|         hideProgressBar |         hideProgressBar | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ import TextField from '@mui/material/TextField' | |||||||
| import Link from '@mui/material/Link' | import Link from '@mui/material/Link' | ||||||
| import Grid from '@mui/material/Grid' | import Grid from '@mui/material/Grid' | ||||||
| import Box from '@mui/material/Box' | import Box from '@mui/material/Box' | ||||||
| import LockOutlinedIcon from '@mui/icons-material/LockOutlined' | import LoginIcon from '@mui/icons-material/Login' | ||||||
| import Typography from '@mui/material/Typography' | import Typography from '@mui/material/Typography' | ||||||
| import Container from '@mui/material/Container' | import Container from '@mui/material/Container' | ||||||
| import { useFormik } from 'formik' | import { useFormik } from 'formik' | ||||||
| @ -34,7 +34,7 @@ export default function Login() { | |||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Container component="main" maxWidth="xs"> |     <Container component="main" sx={{ width: 400 }}> | ||||||
|       <Box |       <Box | ||||||
|         sx={{ |         sx={{ | ||||||
|           marginTop: '20vh', |           marginTop: '20vh', | ||||||
| @ -43,8 +43,8 @@ export default function Login() { | |||||||
|           alignItems: 'center', |           alignItems: 'center', | ||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|         <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}> |         <Avatar sx={{ m: 1, bgcolor: 'primary.main' }}> | ||||||
|           <LockOutlinedIcon /> |           <LoginIcon /> | ||||||
|         </Avatar> |         </Avatar> | ||||||
|         <Typography component="h1" variant="h5"> |         <Typography component="h1" variant="h5"> | ||||||
|           登录 |           登录 | ||||||
| @ -89,9 +89,10 @@ export default function Login() { | |||||||
|               </Link> |               </Link> | ||||||
|             </Grid> |             </Grid> | ||||||
|             <Grid item> |             <Grid item> | ||||||
|               <Link href="/register" variant="body2"> |               <Typography component="span" variant="body2"> | ||||||
|                 注册 |                 没有账号? | ||||||
|               </Link> |               </Typography> | ||||||
|  |               <Link href="/register">注册</Link> | ||||||
|             </Grid> |             </Grid> | ||||||
|           </Grid> |           </Grid> | ||||||
|         </Box> |         </Box> | ||||||
|  | |||||||
| @ -2,28 +2,35 @@ import Avatar from '@mui/material/Avatar' | |||||||
| import Button from '@mui/material/Button' | import Button from '@mui/material/Button' | ||||||
| import TextField from '@mui/material/TextField' | import TextField from '@mui/material/TextField' | ||||||
| import Link from '@mui/material/Link' | import Link from '@mui/material/Link' | ||||||
| import Grid from '@mui/material/Grid' | import Grid from '@mui/material/Unstable_Grid2' | ||||||
| import Box from '@mui/material/Box' | import Box from '@mui/material/Box' | ||||||
| import LockOutlinedIcon from '@mui/icons-material/LockOutlined' | import AppRegistrationIcon from '@mui/icons-material/AppRegistration' | ||||||
| import Typography from '@mui/material/Typography' | import Typography from '@mui/material/Typography' | ||||||
| import Container from '@mui/material/Container' | import Container from '@mui/material/Container' | ||||||
| import { useFormik } from 'formik' | import { useFormik } from 'formik' | ||||||
| import { useRouter } from 'next/router' | import { useRouter } from 'next/router' | ||||||
| import * as yup from '@/utils/validation' | import * as yup from '@/utils/validation' | ||||||
| import * as api from '@/api' | import * as api from '@/api' | ||||||
|  | import { RegisterInputDto } from '@/api/user.interface' | ||||||
|  | import { EmailVerifyCodeScene } from '@/api/email.interface' | ||||||
|  | import { useCountdown, COUNTDOWN_SECONDS } from '@/utils/useCountdown' | ||||||
| 
 | 
 | ||||||
| export default function Register() { | export default function Register() { | ||||||
|   const router = useRouter() |   const router = useRouter() | ||||||
|  |   const { countdown, setCountdown } = useCountdown() | ||||||
| 
 | 
 | ||||||
|   const formik = useFormik({ |   const formik = useFormik<RegisterInputDto>({ | ||||||
|     initialValues: { |     initialValues: { | ||||||
|       email: '', |       email: '', | ||||||
|       password: '', |       password: '', | ||||||
|  |       verifyCode: '', | ||||||
|  |       token: '', | ||||||
|     }, |     }, | ||||||
|     validateOnChange: false, |     validateOnChange: false, | ||||||
|     validationSchema: yup.object({ |     validationSchema: yup.object({ | ||||||
|       email: yup.emailSchema, |       email: yup.emailSchema, | ||||||
|       password: yup.passwordSchema, |       password: yup.passwordSchema, | ||||||
|  |       verifyCode: yup.verifyCodeSchema, | ||||||
|     }), |     }), | ||||||
|     onSubmit: async (values) => { |     onSubmit: async (values) => { | ||||||
|       const res = await api.user.register(values) |       const res = await api.user.register(values) | ||||||
| @ -33,8 +40,23 @@ export default function Register() { | |||||||
|     }, |     }, | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|  |   async function sendVerifyCode() { | ||||||
|  |     await formik.validateField('email') | ||||||
|  |     if (formik.errors.email) return | ||||||
|  |     setCountdown(COUNTDOWN_SECONDS) | ||||||
|  |     try { | ||||||
|  |       const res = await api.email.sendEmailVerifyCode({ | ||||||
|  |         email: formik.values.email, | ||||||
|  |         scene: EmailVerifyCodeScene.register, | ||||||
|  |       }) | ||||||
|  |       formik.setFieldValue('token', res.data.token) | ||||||
|  |     } catch (err) { | ||||||
|  |       setCountdown(0) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Container component="main" maxWidth="xs"> |     <Container component="main" sx={{ width: 400 }}> | ||||||
|       <Box |       <Box | ||||||
|         sx={{ |         sx={{ | ||||||
|           marginTop: '20vh', |           marginTop: '20vh', | ||||||
| @ -43,8 +65,8 @@ export default function Register() { | |||||||
|           alignItems: 'center', |           alignItems: 'center', | ||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|         <Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}> |         <Avatar sx={{ m: 1, bgcolor: 'primary.main' }}> | ||||||
|           <LockOutlinedIcon /> |           <AppRegistrationIcon /> | ||||||
|         </Avatar> |         </Avatar> | ||||||
|         <Typography component="h1" variant="h5"> |         <Typography component="h1" variant="h5"> | ||||||
|           注册 |           注册 | ||||||
| @ -53,7 +75,7 @@ export default function Register() { | |||||||
|           component="form" |           component="form" | ||||||
|           onSubmit={formik.handleSubmit} |           onSubmit={formik.handleSubmit} | ||||||
|           noValidate |           noValidate | ||||||
|           sx={{ mt: 1 }} |           sx={{ mt: 1, width: '100%' }} | ||||||
|         > |         > | ||||||
|           <TextField |           <TextField | ||||||
|             {...formik.getFieldProps('email')} |             {...formik.getFieldProps('email')} | ||||||
| @ -66,6 +88,34 @@ export default function Register() { | |||||||
|             autoFocus |             autoFocus | ||||||
|             sx={{ mt: 1 }} |             sx={{ mt: 1 }} | ||||||
|           /> |           /> | ||||||
|  |           <Grid container spacing={2}> | ||||||
|  |             <Grid> | ||||||
|  |               <TextField | ||||||
|  |                 {...formik.getFieldProps('verifyCode')} | ||||||
|  |                 required | ||||||
|  |                 error={formik.touched.verifyCode && !!formik.errors.verifyCode} | ||||||
|  |                 helperText={ | ||||||
|  |                   (formik.touched.verifyCode && formik.errors.verifyCode) || ' ' | ||||||
|  |                 } | ||||||
|  |                 fullWidth | ||||||
|  |                 label="验证码" | ||||||
|  |                 autoComplete="verifyCode" | ||||||
|  |                 autoFocus | ||||||
|  |                 sx={{ mt: 1 }} | ||||||
|  |               /> | ||||||
|  |             </Grid> | ||||||
|  |             <Grid mt={2} sx={{ flexGrow: 1 }}> | ||||||
|  |               <Button | ||||||
|  |                 size="large" | ||||||
|  |                 fullWidth | ||||||
|  |                 variant="contained" | ||||||
|  |                 disabled={countdown > 0} | ||||||
|  |                 onClick={sendVerifyCode} | ||||||
|  |               > | ||||||
|  |                 {countdown > 0 ? countdown : '发送验证码'} | ||||||
|  |               </Button> | ||||||
|  |             </Grid> | ||||||
|  |           </Grid> | ||||||
|           <TextField |           <TextField | ||||||
|             {...formik.getFieldProps('password')} |             {...formik.getFieldProps('password')} | ||||||
|             required |             required | ||||||
| @ -83,10 +133,11 @@ export default function Register() { | |||||||
|             注册 |             注册 | ||||||
|           </Button> |           </Button> | ||||||
|           <Grid container justifyContent="flex-end" sx={{ mt: 2 }}> |           <Grid container justifyContent="flex-end" sx={{ mt: 2 }}> | ||||||
|             <Grid item> |             <Grid> | ||||||
|               <Link href="/login" variant="body2"> |               <Typography component="span" variant="body2"> | ||||||
|                 登录 |                 已有账号? | ||||||
|               </Link> |               </Typography> | ||||||
|  |               <Link href="/login">登录</Link> | ||||||
|             </Grid> |             </Grid> | ||||||
|           </Grid> |           </Grid> | ||||||
|         </Box> |         </Box> | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								src/utils/useCountdown.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/utils/useCountdown.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | import { useState, useRef, useEffect } from 'react' | ||||||
|  | 
 | ||||||
|  | /** 倒计时 */ | ||||||
|  | export function useCountdown() { | ||||||
|  |   const [countdown, setCountdown] = useState(0) | ||||||
|  |   const previousCountdownRef = useRef(countdown) | ||||||
|  |   useEffect(() => { | ||||||
|  |     previousCountdownRef.current = countdown | ||||||
|  |   }, [countdown]) | ||||||
|  | 
 | ||||||
|  |   const intervalRef = useRef<number>() | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     intervalRef.current = window.setInterval(() => { | ||||||
|  |       const previousCountdown = previousCountdownRef.current | ||||||
|  |       if (previousCountdown > 0) { | ||||||
|  |         const newCountdown = previousCountdown - 1 | ||||||
|  |         setCountdown(newCountdown) | ||||||
|  |       } | ||||||
|  |     }, 1000) | ||||||
|  |     return () => window.clearInterval(intervalRef.current) | ||||||
|  |   }, []) | ||||||
|  | 
 | ||||||
|  |   return { countdown, setCountdown } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const COUNTDOWN_SECONDS = 60 | ||||||
| @ -14,3 +14,8 @@ export const passwordSchema = yup | |||||||
|   .matches(/^(.*)?\d+(.*)?$/, '至少一个数字') |   .matches(/^(.*)?\d+(.*)?$/, '至少一个数字') | ||||||
|   .matches(/^(?=.*[a-z])(?=.*[A-Z])[^]*$/, '大写字母和小写字母') |   .matches(/^(?=.*[a-z])(?=.*[A-Z])[^]*$/, '大写字母和小写字母') | ||||||
|   .matches(/^[^\s].*[^\s]$/, '首尾字符不能是空格') |   .matches(/^[^\s].*[^\s]$/, '首尾字符不能是空格') | ||||||
|  | 
 | ||||||
|  | export const verifyCodeSchema = yup | ||||||
|  |   .string() | ||||||
|  |   .required('请输入验证码') | ||||||
|  |   .length(6, '验证码为6位数') | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user