拆解Mantis模板 - 表单部分和AuthGuard

拆解Mantis模板 - 表单部分和AuthGuard

拆解完了,可以慢慢写业务逻辑了

表单修改成使用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和其他未知错误(超时和网络错误)返回错误数据,这样在使用useQueryuseMutation的时候就可以进行判断了。

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 {
      // 忽略解析错误
    }
  }
});

LICENSED UNDER CC BY-NC-SA 4.0
Comment