表单修改成使用react-hook-form
花了点功夫终于弄完了。
使用了react-hook-form,和YUP集成起来也很方便,改造的用户注册如下:
import React, { useState } from 'react';
// material-ui
import Button from '@mui/material/Button';
import FormHelperText from '@mui/material/FormHelperText';
import Grid from '@mui/material/Grid';
import InputAdornment from '@mui/material/InputAdornment';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
// third-party
import * as Yup from 'yup';
// project imports
import IconButton from '@/components/@extended/IconButton';
// assets
import EyeOutlined from '@ant-design/icons/EyeOutlined';
import EyeInvisibleOutlined from '@ant-design/icons/EyeInvisibleOutlined';
import { Controller, useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { useMutation } from '@tanstack/react-query';
import { register } from '@/utils/axios/authAxios';
import { enqueueSnackbar } from 'notistack';
import DialogTitle from '@mui/material/DialogTitle';
import { DialogContent } from '@mui/material';
import Dialog from '@mui/material/Dialog';
import { AxiosError } from 'axios';
const validationSchema = Yup.object().shape({
username: Yup.string()
.required('必须输入用户名')
.max(255, '用户名不能超过255个字符')
.test('no-leading-trailing-whitespace', '用户名不能以空格开头或结尾', (value) => value === value.trim()),
email: Yup.string()
.required('必须输入邮箱')
.max(255, '电子邮箱不能超过255个字符')
.email('请输入有效的电子邮箱'),
password: Yup.string()
.required('必须输入密码')
.test('no-leading-trailing-whitespace', '密码不能以空格开头或结尾', (value) => value === value.trim())
.max(255, '密码不能超过255个字符')
.min(6, '密码不能少于6个字符'),
passwordConfirm: Yup.string()
.required('必须输入确认密码')
.test('no-leading-trailing-whitespace', '密码不能以空格开头或结尾', (value) => value === value.trim())
.max(255, '密码不能超过255个字符')
.min(6, '密码不能少于6个字符')
.oneOf([Yup.ref('password'), ''], '两次输入的密码不一致')
});
//注册提交的数据类型
export interface RegisterFormData {
username: string;
email: string;
password: string;
passwordConfirm: string;
}
interface RegisterProps {
open: boolean;
onClose: () => void;
}
// ============================|| JWT - REGISTER ||============================ //
export default function AuthRegister(props: RegisterProps) {
const [showPassword, setShowPassword] = useState(false);
const handleClickShowPassword = () => {
setShowPassword(!showPassword);
};
const handleMouseDownPassword = (event: React.MouseEvent) => {
event.preventDefault();
};
// React-hook-form
const {
control,
handleSubmit,
formState: { errors },
reset
} = useForm<RegisterFormData>({
defaultValues: {
username: '',
email: '',
password: '',
passwordConfirm: ''
},
resolver: yupResolver(validationSchema),
mode: 'onChange'
});
const mutation = useMutation({
mutationFn: register,
onSuccess: (response) => {
switch (response.status) {
case 200:
enqueueSnackbar(response.data.message, { variant: 'success' });
//清除表单内容并关闭注册模态框
reset();
props.onClose();
return;
default:
// 其他错误就显示该错误
enqueueSnackbar(response.data.message, { variant: 'error' });
}
},
onError: (error:AxiosError) => {
if (error.code !== 'UNAUTHORIZED') {
enqueueSnackbar(errorString, { variant: 'error' });
}
}
});
const onSubmit = (data: RegisterFormData) => {
mutation.mutate(data);
};
const errorString = import.meta.env.VITE_NETWORK_ERROR;
return (
<Dialog open={props.open} onClose={props.onClose}>
<DialogTitle align="center" sx={{ fontSize: '1.5rem', fontWeight: 400 }}>
注册新用户
</DialogTitle>
<DialogContent>
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={3}>
<Grid size={12}>
<Stack sx={{ gap: 1 }}>
<InputLabel htmlFor="username">用户名</InputLabel>
<Controller
name="username"
control={control}
render={({ field }) => {
return (
<OutlinedInput
{...field}
id="username"
type="text"
placeholder="请输入用户名"
fullWidth
error={Boolean(errors.username)}
autoComplete={'off'}
/>
);
}}
/>
</Stack>
{errors.username && (
<FormHelperText error id="standard-weight-helper-text-email-login">
{errors.username.message}
</FormHelperText>
)}
</Grid>
<Grid size={12}>
<Stack sx={{ gap: 1 }}>
<InputLabel htmlFor="email">电子邮件</InputLabel>
<Controller
name="email"
control={control}
render={({ field }) => {
return (
<OutlinedInput
{...field}
id="email"
type="email"
placeholder="请输入电子邮件地址"
fullWidth
error={Boolean(errors.email)}
autoComplete={'off'}
/>
);
}}
/>
</Stack>
{errors.email && (
<FormHelperText error id="standard-weight-helper-text-email-login">
{errors.email.message}
</FormHelperText>
)}
</Grid>
<Grid size={12}>
<Stack sx={{ gap: 1 }}>
<InputLabel htmlFor="password">密码</InputLabel>
<Controller
name="password"
control={control}
render={({ field }) => (
<OutlinedInput
{...field}
fullWidth
error={Boolean(errors.password)}
id="password"
autoComplete={'new-password'}
type={showPassword ? 'text' : 'password'}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
edge="end"
color="secondary"
>
{showPassword ? <EyeOutlined /> : <EyeInvisibleOutlined />}
</IconButton>
</InputAdornment>
}
placeholder="输入密码"
/>
)}
/>
</Stack>
{errors.password && (
<FormHelperText error id="standard-weight-helper-text-password-login">
{errors.password.message}
</FormHelperText>
)}
</Grid>
<Grid size={12}>
<Stack sx={{ gap: 1 }}>
<InputLabel htmlFor="passwordConfirm">密码</InputLabel>
<Controller
name="passwordConfirm"
control={control}
render={({ field }) => (
<OutlinedInput
{...field}
fullWidth
error={Boolean(errors.passwordConfirm)}
id="passwordConfirm"
autoComplete={'new-password'}
type={showPassword ? 'text' : 'password'}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
edge="end"
color="secondary"
>
{showPassword ? <EyeOutlined /> : <EyeInvisibleOutlined />}
</IconButton>
</InputAdornment>
}
placeholder="输入密码"
/>
)}
/>
</Stack>
{errors.passwordConfirm && (
<FormHelperText error id="standard-weight-helper-text-password-login">
{errors.passwordConfirm.message}
</FormHelperText>
)}
</Grid>
<Grid size={12} sx={{ display: 'flex', justifyContent: 'center' }}>
<Grid size={6} sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
<Button fullWidth size="large" variant="contained" color="primary"
disabled={mutation.isPending} type="submit" sx={{ whiteSpace: 'nowrap' }}>
{mutation.isPending ? '注册中' : '注册'}
</Button>
<Button fullWidth size="large" variant="contained" color="secondary"
onClick={props.onClose} sx={{ whiteSpace: 'nowrap' }}>
关闭
</Button>
</Grid>
</Grid>
</Grid>
</form>
</DialogContent>
</Dialog>
);
};
要注意的一个点就是mode: 'onChange',这个如果设置成all,会在焦点和变动的时候都验证,页面里没事,如果放在Modal框里,会容易出发浏览器的警告,而且离开焦点就验证也不符合我之前常用的逻辑,就统一设置成onChange了。
AuthGuard
要实现AuthGuard,其实主要逻辑在axios的拦截器里。
拦截器
目前的拦截器如下设置:
// 响应拦截器对于非200的响应,统一拦截之后,对于除了401和未知错误意外的错误,返回响应体,因为这是预设的响应体,不会被catch捕获,useQuery,useMutation等error捕获
// 对于401和其他错误,返回AxiosError,这个时候才能被上述的error捕获并处理
// 由于401响应是特殊情况,所以无需处理任何逻辑和弹出信息,直接清除登录状态即可
// 其他错误就是超时和无法连接服务器,显示统一的错误信息即可。
axiosInstanceWithToken.interceptors.response.use(
(response) => {
console.log('响应拦截器中统一拦截响应并返回响应体');
return response;
},
(error: AxiosError) => {
console.log('返回的是非200响应');
console.log('只有与服务器通信才会返回错误');
console.log(error);
const status = error.response?.status;
console.log(error.response);
switch (status) {
case 401:
console.log('401 错误,清除登录状态');
error.code = "UNAUTHORIZED";
useAuthStore.getState().clearAuth();
enqueueSnackbar('登录状态已过期,请重新登录', { variant: 'info' });
return Promise.reject(error);
case 400:
case 403:
case 404:
case 405:
case 500:
console.log('其他错误,返回错误响应');
return error.response;
default:
error.code = 'OTHER';
return Promise.reject(error);
}
}
);出现401错误(对于后端来说就是TOKEN失效和访问验证登录状态的地址返回401错误)时,将error.code设置为"UNAUTHORIZED" ,然后直接清除登录状态并弹一个消息通知框。
目前只有401和其他未知错误(超时和网络错误)返回错误数据,这样在使用useQuery和useMutation的时候就可以进行判断了。
AuthGuard中,要加上使用isLoggedIn,这样只要验证失败,就会重新渲染,并导向登录页。
所有的登录只要出现401,都会立刻导致AuthGuard重新渲染并导向登录页。登录页就不用再去后端校验了,逻辑简单了一下。
AuthGuard
编写完的AuthGuard如下:
import {Outlet, useNavigate} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {verifyLoginStatus} from '@/utils/axios/authAxios';
import React, {useEffect} from 'react';
import CustomCircularProgress from '@/components/@extended/CustomCircularProgress';
import useAuthStore from "@/store/authStore";
// ==============================|| LAYOUT - AUTH ||============================== //
// 路由守卫,用于登录页面和其他页面之间的跳转
export default function AuthLayout() {
// 这里加入isLoggedIn,是为了让其他页面退出登录的时候,也能够重新绘制路由守卫组件
const navigate = useNavigate();
const isLoggedIn = useAuthStore(state => state.isLoggedIn);
const clearAuth = useAuthStore(state => state.clearAuth);
// 这里采用和登录组件一样的校验方法
const { data: verifyData, isFetching, isError } = useQuery({
queryKey: ['verifyLogin'],
queryFn: verifyLoginStatus,
retry: false,
refetchOnWindowFocus: false,
staleTime: 0
});
useEffect(() => {
if (!isFetching) {
if (verifyData?.status !== 200 || isError) {
clearAuth();
navigate('/login');
} else if (!isLoggedIn) {
navigate('/login');
}
}
})
// 必须加上isLoggedIn的判断来显示页面
if (isFetching) {
return (
<CustomCircularProgress message={'校验登录状态......'}></CustomCircularProgress>
);
}
// 如果需要跳转,在等待 useEffect 执行期间,先返回 null 或不渲染任何内容
if (verifyData?.status !== 200 || !isLoggedIn) {
return null; // 或者 return <></>,避免闪烁
}
return (
<>
<Outlet/>
</>
);
}
其中只要验证响应不等于200(即验证用户TOKEN失效,后端这个端点默认其实要么返回200要么返回401),以及出错的情况下,清除登录信息(在401响应的时候其实拦截器已经清除了一次),然后导向/login就可以了。
登录页面
import { useNavigate } from 'react-router-dom';
// material-ui
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
// project imports
import AuthWrapper from '@/sections/auth/AuthWrapper';
import AuthLogin from '@/sections/auth/AuthLogin';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import React, { useEffect } from 'react';
import AuthRegister from '@/sections/auth/AuthRegister';
import Button from '@mui/material/Button';
import useAuthStore from '@/store/authStore';
// ================================|| JWT - LOGIN ||================================ //
export default function Login() {
const navigate = useNavigate();
const isLoggedIn = useAuthStore(state => state.isLoggedIn);
useDocumentTitle('登录');
useEffect(() => {
if (isLoggedIn) {
navigate('/portal');
}
}, [isLoggedIn, navigate]);
// 控住注册模态框的弹出
const [openRegister, setOpenRegister] = React.useState(false);
const handleOpenRegister = () => setOpenRegister(true);
const handleCloseRegister = () => setOpenRegister(false);
// 响应没成功的时候显示加载
// 响应成功之后检查状态码是不是200,如果是200说明校验成功
return (
<AuthWrapper>
<Grid container spacing={3}>
<Grid size={12}>
<Stack direction="row"
sx={{ alignItems: 'baseline', justifyContent: 'space-between', mb: { xs: -0.5, sm: 0.5 } }}>
<Typography variant="h3" sx={{fontWeight:"400"}}>登录</Typography>
<Button variant="text"
onClick={handleOpenRegister}>
注册新用户
</Button>
</Stack>
</Grid>
<Grid size={12}>
<AuthLogin />
</Grid>
</Grid>
<AuthRegister open={openRegister} onClose={handleCloseRegister} />
</AuthWrapper>
);
}登录页面就简单多了,只判断isLoggedIn就可以了。
整个程序里设置登录状态的只有登录成功的时候,清除登录状态就是所有401响应,然后AuthGuard因为使用了isLoggedIn,就会自动跳转。
经过测试毫无问题,而且在useAuthStore中也加上了一个窗口登录/登出的时候其他窗口也一起更改状态的事件:
// 监听 localStorage 变化,跨标签页同步登出状态
window.addEventListener('storage', (event) => {
// 如果storage发生变化,并且key是Altalion,则尝试读取新值,并把新值设置到状态中
if (event.key === 'Altalion') {
try {
const state = JSON.parse(event.newValue || '{}');
if (state && state.state) {
useAuthStore.setState(state.state);
}
} catch {
// 忽略解析错误
}
}
});