注册页 & 登录页
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