注册页 & 登录页

This commit is contained in:
秦秋旭 2023-02-24 15:45:28 +08:00
parent 3be70555b4
commit bd0332a582
10 changed files with 154 additions and 24 deletions

6
src/api/email.api.ts Normal file
View 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 })
}

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

View File

@ -1 +1,2 @@
export * as user from './user' export * as user from './user.api'
export * as email from './email.api'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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位数')