React + SpringBoot开发用户中心管理系统

用户中心项目搭建笔记

技术栈

前端技术栈

  • “react”: “^18.2.0”,
  • ant-design-pro

后端技术栈

  • SpringBoot 2.6.x

项目源码地址

  • https://gitee.com/szxio/user-center

前端项目搭建

快速搭建一个后端管理系统项目框架

初始化

antDesignPro 官网: https://pro.ant.design/zh-CN。开箱即用的中台前端/设计解决方案

我们提供了 pro-cli 来快速的初始化脚手架。

# 使用 npm
npm i @ant-design/pro-cli -g
pro create user-center
cd user-center
pnpm install

去除国际化

pnpm i18n-remove

执行这个命令可以去掉项目中的国际化配置,再次启动可能会报引用错误,把多余的引用去掉即可

启动

pnpm start

image-20240511145232628

访问

image-20240511145311283

后端项目搭建

初始化

使用idea开发工具自带 Spring Initializr 完成项目创建

image-20240511145850969

如果Java版本无法选中8,可以切换上面的 Server URL 为阿里的源 https://start.aliyun.com,然后就可以选择8版本了

image-20240511150017423

接着点击 Next选择常用的开发依赖,下面我列出一些基本的依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.szx</groupId>
    <artifactId>user-center</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>user-center</name>
    <description>user-center</description>
    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.6.13</spring-boot.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.26</version>
        </dependency>

        <!--swagger-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-boot-starter</artifactId>
            <version>3.0.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.szx.usercenter.UserCenterApplication</mainClass>
                    <skip>true</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

配置文件

application.yml

server:
  port: 8080

spring:
  application:
    name: user-center

  # 数据源配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/user-center?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: abc123

  # 配置日期返回格式
  jackson:
    # 日期格式
    date-format: yyyy-MM-dd HH:mm:ss
    # 时区
    time-zone: GMT+8
    # 非空的属性值才会被包含在结果中
    default-property-inclusion: non_null

  mvc:
    pathmatch:
      # swagger配置路径匹配规则
      matching-strategy: ant_path_matcher


mybatis-plus:
  mapper-locations: classpath:/mapper/**.xml
  configuration:
    # 开启控制台SQL输出
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

SwaggerUI配置

package com.szx.usercenter.config;


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;



/**
 * @author songzx
 * @create 2022-09-22 11:21
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {
    @Bean
    public Docket webApiConfig(){
        return new Docket(DocumentationType.SWAGGER_2)
                .groupName("webApi")
                .apiInfo(webApiInfo())
                .select()
                .paths(path -> !path.contains("/error"))  // 过滤掉SwaggerUI自带的error路径的api
                .build();
    }

    public ApiInfo webApiInfo(){
        return new ApiInfoBuilder()
                .title("用户中心接口文档")
                .build();
    }
}

MybatisPlus分页插件和自动插入当前日期

@Configuration
public class MybatisPlusConfig implements MetaObjectHandler {

    // 分页插件
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    // 创建日期和更新日期自动更新
    @Override
    public void insertFill(MetaObject metaObject) {
        setFieldValByName("createTime", new Date(),metaObject);
        setFieldValByName("updateTime",new Date(),metaObject);
    }

    // 更新日期自动更新
    @Override
    public void updateFill(MetaObject metaObject) {
        setFieldValByName("updateTime",new Date(),metaObject);
    }
}

启动类设置

package com.szx.usercenter;

import lombok.extern.log4j.Log4j2;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;

import java.net.InetAddress;
import java.net.UnknownHostException;

@Log4j2
@SpringBootApplication
@MapperScan("com.szx.usercenter.mapper")
public class UserCenterApplication {

    public static void main(String[] args) throws UnknownHostException {
        ConfigurableApplicationContext ioc = SpringApplication.run(UserCenterApplication.class, args);
        Environment env = ioc.getEnvironment();
        String host = InetAddress.getLocalHost().getHostAddress();
        String port = env.getProperty("server.port");
        log.info("\n    ----------------------------------------------------------\n\t" +
                        "Application '{}' 正在运行中... Access URLs:\n\t" +
                        "Local: \t\thttp://localhost:{}\n\t" +
                        "External: \thttp://{}:{}\n\t" +
                        "Doc: \thttp://{}:{}/doc.html\n\t" +
                        "SwaggerDoc: \thttp://{}:{}/swagger-ui/index.html\n\t" +
                        "----------------------------------------------------------",
                env.getProperty("spring.application.name"),
                env.getProperty("server.port"),
                host, port,
                host, port,
                host, port);
    }

}

IDEA自带的代码生成器

image-20240511151420847

image-20240511151606132

注意:生成的文件会覆盖原有文件

统一结果返回类

Response

package com.szx.usercenter.util;

import com.fasterxml.jackson.annotation.JsonInclude;

/**
 * @author songzx
 * @date 2023/6/4
 * @apiNote
 */
@JsonInclude(JsonInclude.Include.NON_NULL) // 值等于null的属性不返回
public class Response<T> {
    private String code;

    private String msg;

    private T data;


    /**
     * @title 成功消息
     * @return
     */
    public static <T> Response<T> success() {
        return rspMsg(ResponseEnum.SUCCESS);
    }

    /**
     * @title 失败消息
     * @return
     */
    public static <T> Response<T> error() {
        return rspMsg(ResponseEnum.SERVER_INNER_ERR);
    }

    /**
     * @title 自定义消息
     * @return
     */
    public static <T> Response<T> rspMsg(ResponseEnum responseEnum) {
        Response<T> message = new Response<T>();
        message.setCode(responseEnum.getCode());
        message.setMsg(responseEnum.getMsg());
        return message;
    }

    /**
     * @title 自定义消息
     * @return
     */
    public static <T> Response<T> rspMsg(String code , String msg) {
        Response<T> message = new Response<T>();
        message.setCode(code);
        message.setMsg(msg);
        return message;
    }

    /**
     * @title 返回数据
     * @param data
     * @return
     */
    public static <T> Response<T> rspData(T data) {
        Response<T> responseData = new Response<T>();
        responseData.setCode(ResponseEnum.SUCCESS.getCode());
        responseData.setData(data);
        return responseData;
    }

    public static <T> Response<T> error(T data) {
        Response<T> responseData = new Response<T>();
        responseData.setCode(ResponseEnum.ERROR.getCode());
        responseData.setData(data);
        return responseData;
    }

    /**
     * @title 返回数据-自定义code
     * @param data
     * @return
     */
    public static <T> Response<T> rspData(String code , T data) {
        Response<T> responseData = new Response<T>();
        responseData.setCode(code);
        responseData.setData(data);
        return responseData;
    }


    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

枚举类 ResponseEnum

package com.szx.usercenter.util;

/**
 * @author songzx
 * @create 2023-12-05 14:25
 */
public enum ResponseEnum {
    // 可以根据自己的实际需要增加状态码
    SUCCESS("200", "成功"),
    ERROR("500","系统异常"),
    SERVER_INNER_ERR("500","系统繁忙"),
    LOGIN_EXPIRED("401","登录过期"),
    PARAM_LACK("100" , "非法参数"),
    OPERATION_FAILED("101" ,"操作失败");

    private String code;
    private String msg;

    ResponseEnum(String code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }
}

编写测试接口

package com.szx.usercenter.controller;

import com.szx.usercenter.domain.SysUser;
import com.szx.usercenter.service.SysUserService;
import com.szx.usercenter.util.Response;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;

/**
 * @author songzx
 * @create 2024-05-11 10:23
 */
@RestController
@RequestMapping("/sysUser")
@Api(tags = "用户管理")
public class SusUserController {
    @Resource
    SysUserService sysUserService;

    /**
     * 获取所有用户
     * @return
     */
    @GetMapping("getAllUser")
    @ApiOperation("获取所有用户")
    public Response<List<SysUser>> getUserList() {
        return Response.rspData(sysUserService.list());
    }

    /**
     * 登录
     * @param sysUser
     * @return
     */
    @PostMapping("login")
    @ApiOperation("登录")
    public Response login(@RequestBody SysUser sysUser) {
        SysUser login = sysUserService.login(sysUser);
        if(login != null){
            login.setPassword(null);
            return Response.rspData(login);
        }else{
            return Response.error("用户名或密码错误");
        }
    }
}

重启项目,访问Swagger页面试试

image-20240511152136291

至此后端项目搭建完成

打包pom通用配置

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version> <!-- 或者你使用的版本 -->
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <version>2.3.7.RELEASE</version>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

SQL建表语句

可以复制若依的表来使用,下面是sql地址

https://gitee.com/y_project/RuoYi-Vue/blob/master/sql/ry_20231130.sql

用户表 sys_user

drop table if exists sys_user;
create table sys_user (
  user_id           bigint(20)      not null auto_increment    comment '用户ID',
  dept_id           bigint(20)      default null               comment '部门ID',
  user_name         varchar(30)     not null                   comment '用户账号',
  nick_name         varchar(30)     not null                   comment '用户昵称',
  user_type         varchar(2)      default '00'               comment '用户类型(00系统用户)',
  email             varchar(50)     default ''                 comment '用户邮箱',
  phonenumber       varchar(11)     default ''                 comment '手机号码',
  sex               char(1)         default '0'                comment '用户性别(0男 1女 2未知)',
  avatar            varchar(100)    default ''                 comment '头像地址',
  password          varchar(100)    default ''                 comment '密码',
  status            char(1)         default '0'                comment '帐号状态(0正常 1停用)',
  del_flag          char(1)         default '0'                comment '删除标志(0代表存在 2代表删除)',
  login_ip          varchar(128)    default ''                 comment '最后登录IP',
  login_date        datetime                                   comment '最后登录时间',
  create_by         varchar(64)     default ''                 comment '创建者',
  create_time       datetime                                   comment '创建时间',
  update_by         varchar(64)     default ''                 comment '更新者',
  update_time       datetime                                   comment '更新时间',
  remark            varchar(500)    default null               comment '备注',
  primary key (user_id)
) engine=innodb auto_increment=100 comment = '用户信息表';

角色表 sys_role

drop table if exists sys_role;
create table sys_role (
  role_id              bigint(20)      not null auto_increment    comment '角色ID',
  role_name            varchar(30)     not null                   comment '角色名称',
  role_key             varchar(100)    not null                   comment '角色权限字符串',
  del_flag             char(1)         default '0'                comment '删除标志(0代表存在 1代表删除)',
  create_by            varchar(64)     default ''                 comment '创建者',
  create_time          datetime                                   comment '创建时间',
  update_by            varchar(64)     default ''                 comment '更新者',
  update_time          datetime                                   comment '更新时间',
  remark               varchar(500)    default null               comment '备注',
  primary key (role_id)
) engine=innodb auto_increment=100 comment = '角色信息表';

用户角色表 sys_user_role

drop table if exists sys_user_role;
create table sys_user_role (
  user_id   bigint(20) not null comment '用户ID',
  role_id   bigint(20) not null comment '角色ID',
  primary key(user_id)
) engine=innodb comment = '用户和角色关联表';

角色菜单表 sys_role_menu

drop table if exists sys_role_menu;
create table sys_role_menu (
  role_id   	 bigint(0) 	not null comment '角色ID',
  routes    	 text                comment '保存的routes数据',
  checked_keys   text 				 comment '选中的key',
  primary key(role_id)
) engine=innodb comment = '角色和菜单关联表';

密码的加密和校验

用到了hutool包中的BCrypt加密工具类

package com.szx.usercenter;

import cn.hutool.crypto.digest.BCrypt;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

/**
 * @author songzx
 * @date 2024/5/12
 * @apiNote
 */
@SpringBootTest
public class UserPasswordTest {
    /**
     * 密码加密
     */
    @Test
    void testJbcrypt() {
        String passwordToHash = "abc123";
        // BCrypt.gensalt()会随机生成一个数作为盐,因此密码相同情况下,每次的密文是不一样
        String hashedPassword = BCrypt.hashpw(passwordToHash, BCrypt.gensalt());
        System.out.println(hashedPassword);
    }

    /**
     * 密码校验
     */
    @Test
    void testJbcryptCheck() {
        String passwordToCheck = "abc123";
        String hashedPassword = "$2a$10$wpngf2ng8ynf2WQGLSgh6.ztH7q7Bn0mhsH.7x08qLevfzISmSzd2";
        boolean checkpw = BCrypt.checkpw(passwordToCheck, hashedPassword);
        System.out.println(checkpw);
    }
}

image-20240512170629568

image-20240512170542708

后端功能开发

注册逻辑

  1. 用户名不能有特殊字符,并且必须超过6位数
  2. 密码必须超过6位数
  3. 用户名不能重复
  4. 密码加密后保存到数据库中

注册接口开发

@RestController
@RequestMapping("/sysUser")
@Api(tags = "用户管理")
public class SusUserController {
    @Resource
    SysUserService sysUserService;
    
    /**
     * 用户注册
     * @param username 用户名
     * @param password 密码
     * @return
     */
    @ApiOperation("用户注册")
    @PostMapping("register")
    public Response register(String username, String password){
        return sysUserService.register(username,password);
    }
}

实现 register 方法

@Override
public Response register(String username, String password) {
    // 1.用户名不能有特殊字符,并且必须超过6位数
    if(!username.matches("^[a-zA-Z0-9_-]{6,16}$")){
        return Response.error("用户名必须超过6位数,并且不能有特殊字符");
    }
    // 2.密码必须超过6位数
    if(password.length() < 6){
        return Response.error("密码必须超过6位数");
    }
    // 3.用户名不能重复
    if(this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, username)) != null){
        return Response.error("用户名已存在");
    }
    // 4.添加用户到表中
    SysUser sysUser = new SysUser();
    sysUser.setUserName(username);
    sysUser.setNickName(username);
    sysUser.setPassword(BCrypt.hashpw(password, BCrypt.gensalt())); // 密码加密保存
    boolean isOk = this.save(sysUser);

    return isOk ? Response.success() : Response.error("注册失败");
}

登录逻辑

  1. 根据用户名获取数据库表中保存的用户信息
  2. 在用传递进来的密码和表中的密码进行密码校验
  3. 校验成功返回用户信息
  4. 否则登录失败

登录接口开发

@RestController
@RequestMapping("/sysUser")
@Api(tags = "用户管理")
public class SusUserController {
    @Resource
    SysUserService sysUserService;
    
	/**
     * 登录
     * @param sysUser
     * @return
     */
    @PostMapping("login")
    @ApiOperation("登录")
    public Response login(@RequestBody SysUser sysUser) {
        return sysUserService.login(sysUser);
    }
}

login 方法实现

@Override
public Response login(SysUser sysUser) {
    // 1.获取用户填写的用户名和密码
    String userName = sysUser.getUserName();
    String password = sysUser.getPassword();

    // 2.校验用户名密码
    if(userName == null || password == null){
        return Response.error("用户名或密码不能为空");
    }
    SysUser one = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, userName));
    if(one == null){
        return Response.error("用户名不存在");
    }
    if(!BCrypt.checkpw(password, one.getPassword())){
        return Response.error("密码错误");
    }
    // 3.返回用户信息,清空返回体中的密码
    one.setPassword(null);
    return Response.rspData(one);
}

生成Token

给登录接口返回的内容中添加Token

在 login 实现方法中增加一个行代码,JwtHelper 的使用方法看的的这个文章,写的很详细

/**
 * 登录
 * @param sysUser
 * @return
 */
@Override
public Response login(SysUser sysUser) {
    // 1.获取用户填写的用户名和密码
    String userName = sysUser.getUserName();
    String password = sysUser.getPassword();

    // 2.校验用户名密码
    if(userName == null || password == null){
        return Response.error("用户名或密码不能为空");
    }
    SysUser one = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, userName));
    if(one == null){
        return Response.error("用户名不存在");
    }
    if(!BCrypt.checkpw(password, one.getPassword())){
        return Response.error("密码错误");
    }
    // 3.返回用户信息,清空返回体中的密码
    one.setPassword(null);
    // 生成token
    one.setToken(JwtHelper.createToken(sysUser.getUserId(), sysUser.getUserName()));

    return Response.rspData(one);
}

添加Token拦截器

编写 Token 配置类

package com.szx.usercenter.config;

import com.szx.usercenter.handle.TokenHandle;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author songzx
 * @date 2024/5/12
 * @apiNote
 */
@Configuration
public class TokenConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TokenHandle())
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/sysUser/login",
                        "/sysUser/register",
                        "/swagger-ui.html",
                        "/swagger-ui/index.html",
                        "/swagger-resources",
                        "/v2/api-docs",
                        "/v2/api-docs-ext",
                        "/doc.html",
                        "/swagger-resources/configuration/ui",
                        "/swagger-resources/configuration/security",
                        "/swagger-resources/configuration/ui",
                        "/webjars/**",
                        "/swagger-resources/**"
                );
    }
}

TokenHandle 代码,从请求头中获取 X-Token,进行校验,如果为空或者过期,则抛出自定义全局异常。

package com.szx.usercenter.handle;

import cn.hutool.core.util.StrUtil;
import com.szx.usercenter.util.JwtHelper;
import com.szx.usercenter.util.ResponseEnum;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;


import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author songzx
 * @date 2024/5/12
 * @apiNote
 */
public class TokenHandle implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if(!(handler instanceof HandlerMethod)){
            return true;
        }
        // 从请求头中获取token
        String token = request.getHeader("X-Token");
        // 获取请求来源
        String referer = request.getHeader("Referer");

        boolean fromSwagger = referer.endsWith("swagger-ui/index.html");

        // 校验token
        if(!fromSwagger && (StrUtil.isEmpty(token) || JwtHelper.tokenExpired(token))){
            // 如果token校验失败则抛出自定义全局异常
            throw new CenterExceptionHandler(ResponseEnum.LOGIN_EXPIRED);
        }
        return true;
    }
}

自定义全局异常

新建全局异常处理类

GlobalExceptionHandler

package com.szx.usercenter.handle;

import cn.hutool.core.exceptions.ExceptionUtil;
import com.szx.usercenter.util.Response;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * @author songzx
 * @date 2024/5/12
 * @apiNote
 */
@RestControllerAdvice
@Log4j2
public class GlobalExceptionHandler {
    // 全局异常处理
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public Response<Object> error(Exception e){
        log.error(ExceptionUtil.getMessage(e));
        e.printStackTrace();
        // 将异常转成string返回出去
        return Response.error(e.getMessage());
    }


    /**
     * 处理自定义的异常-CenterExceptionHandler
     * @param e
     * @return
     */
    @ExceptionHandler(CenterExceptionHandler.class)
    @ResponseBody
    public Response<Object>  businessExceptionHandler(CenterExceptionHandler e){
        log.error("CenterExceptionHandler:" + e.getMessage(),e);
        return Response.rspMsg(e.getCode(),e.getMessage());
    }
}

新建自定义异常处理类

CenterExceptionHandler

package com.szx.usercenter.handle;

import com.szx.usercenter.util.ResponseEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 自定义全局异常处理类
 * @author songzx
 * @date 2024/5/12
 * @apiNote
 */
@Data
public class CenterExceptionHandler extends RuntimeException{
    /**
     * 错误码
     */
    private String code;

    /**
     * 业务异常
     *
     * @param message     信息
     * @param code        错误码
     */
    public CenterExceptionHandler(String message, String code) {
        super(message);
        this.code = code;
    }

    /**
     * 业务异常
     *
     * @param errorCode 错误代码
     */
    public CenterExceptionHandler(ResponseEnum errorCode) {
        super(errorCode.getMsg());
        this.code = errorCode.getCode();
    }

    /**
     * 默认业务异常,状态默认500
     *
     * @param message 信息
     */
    public CenterExceptionHandler(String message) {
        super(message);
        this.code = ResponseEnum.ERROR.getCode();
    }

    /**
     * 默认业务异常
     */
    public CenterExceptionHandler() {
        super(ResponseEnum.ERROR.getMsg());
        this.code = ResponseEnum.ERROR.getCode();
    }
}

然后再任何需要抛出异常的地方直接使用即可

例如

@GetMapping("testError")
public Response testError(){
    throw new CenterExceptionHandler("测试异常");
}

image-20240512195337458

查询接口开发

接口实现类 SysUserServiceImpl 添加方法

// 用户信息脱敏方法
@Override
public SysUser getSefUser(SysUser user) {
    SysUser sysUser = ObjUtil.clone(user);
    sysUser.setPassword(null);
    return sysUser;
}

@Override
public Response getPageUserList(SysUser sysUser) {
  Page<SysUser> sysUserPage = new Page<>(sysUser.getCurrent(), sysUser.getPageSize());

  LambdaQueryWrapper<SysUser> qw = new LambdaQueryWrapper<>();
  // 用户名称查询
  if (StrUtil.isNotEmpty(sysUser.getUserName())) {
    qw.like(SysUser::getUserName, sysUser.getUserName());
  }

  // 手机号查询
  if (StrUtil.isNotEmpty(sysUser.getPhonenumber())) {
    qw.like(SysUser::getPhonenumber, sysUser.getPhonenumber());
  }

  // 创建日期查询,查询当天内的所有数据
  if (ObjectUtil.isNotEmpty(sysUser.getCreateTime())) {
    Date startDate = DateUtil.beginOfDay(sysUser.getCreateTime()); // 将前端传来的日期转换为当天的开始时间
    Date endDate = DateUtil.endOfDay(startDate);  // 将结束日期设置为当天的结束时间
    qw.between(SysUser::getCreateTime, startDate, endDate);
  }

  this.page(sysUserPage, qw);

  // 返回的用户信息脱敏
  List<SysUser> userList = sysUserPage.getRecords();
  sysUserPage.setRecords(
      userList.stream().map(user -> getSefUser(user)).collect(Collectors.toList()));

  return Response.rspData(sysUserPage);
}

这里前端传递过来的日期格式是字符串类型的日期,例如:2024-05-14 17:12:47,但是后端定义的 createTime 字段类型是 Date 类型,默认会出现一个类型转换错误的异常,如下图

image-20240517171439008

前端传递的参数

image-20240517171311614

image-20240517171337859

我们可以修改配置文件,增加一个日期转换格式的配置

spring:
  # 配置日期返回格式
  jackson:
    # 日期格式
    date-format: yyyy-MM-dd HH:mm:ss
    # 时区
    time-zone: GMT+8
    # 非空的属性值才会被包含在结果中
    default-property-inclusion: non_null

重启项目,再次查询就不会报错了

image-20240517171617614

自动填充创建人和更新人

新建一个 BaseUser

package com.szx.usercenter.contance;

import lombok.Data;
import org.springframework.stereotype.Component;

/**
 * @author songzx
 * @date 2024/5/18
 * @apiNote
 */
@Data
@Component // 这里注意添加@Component注解,交给Spring容器管理
public class BaseUser {
    public static String userName;
}

然后再token拦截器中根据当前请求头中的tokne获取当前用户名,给BaseUser的userName赋值

修改 TokenHandle

package com.szx.usercenter.handle;

import cn.hutool.core.util.StrUtil;
import com.szx.usercenter.contance.BaseUser;
import com.szx.usercenter.util.JwtHelper;
import com.szx.usercenter.util.ResponseEnum;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author songzx
 * @date 2024/5/12
 * @apiNote
 */
public class TokenHandle implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        // 从请求头中获取token
        String token = request.getHeader("Authorization");
        // 获取请求来源
        String referer = request.getHeader("Referer");

        boolean fromSwagger = referer.endsWith("swagger-ui/index.html");

        // 校验token
        if (!fromSwagger && (StrUtil.isEmpty(token) || JwtHelper.tokenExpired(token))) {
            throw new CenterExceptionHandler(ResponseEnum.LOGIN_EXPIRED);
        }

        // 获取用户名
        BaseUser.userName = JwtHelper.getUserName(token);
        return true;
    }
}

修该 MybatisPlusConfig

package com.szx.usercenter.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.szx.usercenter.contance.BaseUser;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Date;

/**
 * @author songzx
 * @create 2024-05-11 10:08
 */
@Configuration
public class MybatisPlusConfig implements MetaObjectHandler {

    // 分页插件
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    // 创建日期和更新日期自动更新
    @Override
    public void insertFill(MetaObject metaObject) {
        setFieldValByName("createTime", new Date(), metaObject);
        setFieldValByName("createBy", BaseUser.userName, metaObject);
        setFieldValByName("updateTime", new Date(), metaObject);
        setFieldValByName("updateBy", BaseUser.userName, metaObject);
    }

    // 更新日期自动更新
    @Override
    public void updateFill(MetaObject metaObject) {
        setFieldValByName("updateTime", new Date(), metaObject);
        setFieldValByName("updateBy", BaseUser.userName, metaObject);
    }
}

更新和创建时值自动填充

image-20240518111601052

前端功能开发

登录逻辑梳理

首先找到登录页面对应的文件,位置在src/pages/User/Login/index.tsx,然后观察代码,发现登录页面使用了 LoginForm 组件来实现的登录表单,LoginForm 是从 @ant-design/pro-components 中导出的,ProComponents 是基于 Ant Design 而开发的模板组件,提供了更高级别的抽象支持,开箱即用。可以显著地提升制作 CRUD 页面的效率,更加专注于页面。

image-20240512101318285

loginForm组件使用文档:https://pro-components.antdigital.dev/components/form

点击登录会触发onFinish钩子函数,调用handleSubmit方法

image-20240512101903528

image-20240511180444921

login方法在src/services/ant-design-pro/api.ts 文件中声明

image-20240512102226082

登录成功后调用 fetchUserInfo 方法获取用户信息

image-20240512102451836

useModel@umi/max 内置的数据流管理插件,它是一种基于 hooks 范式的轻量级数据管理方案,可以在 Umi 项目中管理全局的共享数据。

文档地址:https://umijs.org/docs/max/data-flow#usemodel

useModel('@@initialState') 表示读取 app.tsx 文件中的 getInitialState 方法的返回值

const {initialState, setInitialState} = useModel('@@initialState');

app.tsx 文件中的 getInitialState 代码如下

import {currentUser as queryCurrentUser} from '@/services/ant-design-pro/api';
const loginPath = '/user/login';

export async function getInitialState(): Promise<{
  settings?: Partial<LayoutSettings>;
  currentUser?: API.CurrentUser;
  loading?: boolean;
  fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
}> {
  const fetchUserInfo = async () => {
    try {
      console.log("获取用户信息")
      const msg = await queryCurrentUser({
        skipErrorHandler: true,
      });
      return msg.data;
    } catch (error) {
      history.push(loginPath);
    }
    return undefined;
  };
  // 如果不是登录页面,执行
  const {location} = history;
  if (location.pathname !== loginPath) {
    const currentUser = await fetchUserInfo();
    return {
      fetchUserInfo,
      currentUser,
      settings: defaultSettings as Partial<LayoutSettings>,
    };
  }
  return {
    fetchUserInfo,
    settings: defaultSettings as Partial<LayoutSettings>,
  };
}

查看 queryCurrentUser 接口地址

image-20240512104040157

找到mock中对应的接口

image-20240512104147557

下面我们按照这种格式编写后端接口即可

修改响应拦截器

找到 src/requestErrorConfig.tss 文件,这个文件中处理请求拦截和响应拦截

需要做的功能

  • 给每个请求添加一个基础路径,配合代理完成跨域处理
  • 给每个请求中添加token请求头
  • 判断响应结果是否成功,如果不成功弹出错误提醒
import type {RequestOptions} from '@@/plugin-request/request';
import type {RequestConfig} from '@umijs/max';
import {message, notification} from 'antd';
import {getToken} from '@/utils';

// 错误处理方案: 错误类型
enum ErrorShowType {
  SILENT = 0,
  WARN_MESSAGE = 1,
  ERROR_MESSAGE = 2,
  NOTIFICATION = 3,
  REDIRECT = 9,
}

// 与后端约定的响应数据格式
interface ResponseStructure {
  success: boolean;
  data: any;
  errorCode?: number;
  errorMessage?: string;
  showType?: ErrorShowType;
}

// 请求前缀
const baseURL = '/api';

/**
 * @name 错误处理
 * pro 自带的错误处理, 可以在这里做自己的改动
 * @doc https://umijs.org/docs/max/request#配置
 */
export const errorConfig: RequestConfig = {
  // 错误处理: umi@3 的错误处理方案。
  errorConfig: {
    // 错误抛出
    errorThrower: (res) => {
      const { success, data, errorCode, errorMessage, showType } =
        res as unknown as ResponseStructure;
      if (!success) {
        const error: any = new Error(errorMessage);
        error.name = 'BizError';
        error.info = { errorCode, errorMessage, showType, data };
        throw error; // 抛出自制的错误
      }
    },
    // 错误接收及处理
    errorHandler: (error: any, opts: any) => {
      if (opts?.skipErrorHandler) throw error;
      // 我们的 errorThrower 抛出的错误。
      if (error.name === 'BizError') {
        const errorInfo: ResponseStructure | undefined = error.info;
        if (errorInfo) {
          const { errorMessage, errorCode } = errorInfo;
          switch (errorInfo.showType) {
            case ErrorShowType.SILENT:
              // do nothing
              break;
            case ErrorShowType.WARN_MESSAGE:
              message.warning(errorMessage);
              break;
            case ErrorShowType.ERROR_MESSAGE:
              message.error(errorMessage);
              break;
            case ErrorShowType.NOTIFICATION:
              notification.open({
                description: errorMessage,
                message: errorCode,
              });
              break;
            case ErrorShowType.REDIRECT:
              // TODO: redirect
              break;
            default:
              message.error(errorMessage);
          }
        }
      } else if (error.response) {
        // Axios 的错误
        // 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围
        message.error(`Response status:${error.response.status}`);
      } else if (error.request) {
        // 请求已经成功发起,但没有收到响应
        // \`error.request\` 在浏览器中是 XMLHttpRequest 的实例,
        // 而在node.js中是 http.ClientRequest 的实例
        message.error('None response! Please retry.');
      } else {
        // 发送请求时出了点问题
        message.error(error?.data || error?.msg);
      }
    },
  },

  // 请求拦截器
  requestInterceptors: [
    (config: RequestOptions) => {
      // 给请求头中加一个abc参数
      config.headers.Authorization = getToken();
      // 拦截请求配置,进行个性化处理。
      const url = baseURL + config?.url;
      return { ...config, url };
    },
  ],

  // 响应拦截器
  responseInterceptors: [
    (response) => {
      const sucCodes = ['200', 200];
      // 拦截响应数据,进行个性化处理
      const { data } = response as unknown as ResponseStructure;
      if (!sucCodes.includes(data?.code)) {
        // 返回错误信息交给错误处理器
        return Promise.reject(data);
      }
      return response;
    },
  ],
};

用到的 getToken 方法

/**
 * 设置token
 * @param token
 */
export function setToken(token){
  localStorage.setItem('token',token)
}

/**
 * 获取token
 */
export function getToken(){
  return localStorage.getItem('token')
}

设置代理

修改 config/proxy.ts 代码

/**
 * @name 代理的配置
 * @see 在生产环境 代理是无法生效的,所以这里没有生产环境的配置
 * -------------------------------
 * The agent cannot take effect in the production environment
 * so there is no configuration of the production environment
 * For details, please see
 * https://pro.ant.design/docs/deploy
 *
 * @doc https://umijs.org/docs/guides/proxy
 */
export default {
  // 如果需要自定义本地开发服务器  请取消注释按需调整
  dev: {
    '/api/edu': {
      target: 'http://123.60.16.27:8101',
      changeOrigin: true,
      pathRewrite: { '/api': '' },
    },
    // localhost:8000/api/** -> https://preview.pro.ant.design/api/**
    '/api/': {
      // 要代理的地址
      target: 'http://localhost:8080',
      // 配置了这个可以从 http 代理到 https
      // 依赖 origin 的功能可能需要这个,比如 cookie
      changeOrigin: true,
      // 去掉真实请求地址中的/api
      pathRewrite: { '/api': '' },
    },
  },

  /**
   * @name 详细的代理配置
   * @doc https://github.com/chimurai/http-proxy-middleware
   */
  test: {
    // localhost:8000/api/** -> https://preview.pro.ant.design/api/**
    '/api/': {
      target: 'https://proapi.azurewebsites.net',
      changeOrigin: true,
      pathRewrite: { '^': '' },
    },
  },
  pre: {
    '/api/': {
      target: 'your pre url',
      changeOrigin: true,
      pathRewrite: { '^': '' },
    },
  },
};

权限管理

找到 src/access.ts 文件

export default function access(initialState: { currentUser?: API.CurrentUser } | undefined) {
  const {currentUser} = initialState ?? {};
  return {
    canAdmin: currentUser && currentUser.access?.includes("admin"),
  };
}

access 方法的 initialState 参数就是 app.tsx 文件中的 getInitialState 方法的返回值,这里是 Umi 框架帮我们封装好的

参考文档:权限管理 - Ant Design Pro

登录功能实现

修改 src/pages/Login/index.tsx 文件代码,删除多余代码

import { Footer } from '@/components';
import { login } from '@/services/ant-design-pro/api';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginForm, ProFormText } from '@ant-design/pro-components';
import { history, useModel, Helmet } from '@umijs/max';
import { message, Tabs } from 'antd';
import Settings from '../../../config/defaultSettings';
import React, { useState } from 'react';
import { flushSync } from 'react-dom';
import { setToken } from '@/utils';
import ForgotPasswordForm from '@/pages/Login/ForgotPasswordForm';
import useStyles from './useStyles.less';

const getUserRole = async () => {
  return ['admin'];
};

const Login: React.FC = () => {
  const [type, setType] = useState<string>('account');
  const { initialState, setInitialState } = useModel('@@initialState');
  const [forgotPassword, setForgotPassword] = useState(false);
  const fetchUserInfo = async (data) => {
    if (data) {
      let roles = await getUserRole();
      flushSync(() => {
        // 更新全局保存的用户信息
        setInitialState((s) => ({
          ...s,
          currentUser: {
            ...data,
            access: roles,
          },
        }));
      });
    }
  };
  const handleSubmit = async (values) => {
    // 登录
    let { data } = await login({
      userName: values.username,
      password: values.password,
    });
    if (data.token) {
      setToken(data.token);
      const defaultLoginSuccessMessage = '登录成功!';
      message.success(defaultLoginSuccessMessage);
      await fetchUserInfo(data);
      const urlParams = new URL(window.location.href).searchParams;
      history.push(urlParams.get('redirect') || '/');
    }
  };

  const updatePasswordStatus = (flag) => {
    setForgotPassword(flag);
  };

  return (
    <div className={useStyles.container}>
      <Helmet>
        <title>
          {'登录'}- {Settings.title}
        </title>
      </Helmet>

      {/*忘记密码,重置密码表单*/}
      {forgotPassword && <ForgotPasswordForm updatePasswordStatus={updatePasswordStatus} />}

      {!forgotPassword && (
        <div style={{ marginTop: '5%' }}>
          <LoginForm
            contentStyle={{
              minWidth: 280,
              maxWidth: '75vw',
            }}
            logo={<img alt="logo" src="/logo.svg" />}
            title="用户管理中心"
            initialValues={{
              autoLogin: false,
              username: 'admin001',
              password: 'Abc123',
            }}
            onFinish={async (values) => {
              await handleSubmit(values as API.LoginParams);
            }}
          >
            <Tabs
              activeKey={type}
              onChange={setType}
              centered
              items={[
                {
                  key: 'account',
                  label: '账户密码登录',
                },
              ]}
            />

            <>
              <ProFormText
                name="username"
                fieldProps={{
                  size: 'large',
                  prefix: <UserOutlined />,
                }}
                placeholder={'请输入用户名'}
                rules={[
                  {
                    required: true,
                    message: '用户名是必填项!',
                  },
                ]}
              />
              <ProFormText.Password
                name="password"
                fieldProps={{
                  size: 'large',
                  prefix: <LockOutlined />,
                }}
                placeholder={'请输入密码'}
                rules={[
                  {
                    required: true,
                    message: '密码是必填项!',
                  },
                ]}
              />
            </>

            <div
              style={{
                marginBottom: 24,
              }}
            >
              <a
                style={{
                  float: 'right',
                  marginBottom: 20,
                }}
                onClick={() => updatePasswordStatus(true)}
              >
                忘记密码 ?
              </a>
            </div>
          </LoginForm>
        </div>
      )}
      <Footer />
    </div>
  );
};
export default Login;

登录接口 src/services/ant-design-pro/api.ts

import {request} from '@umijs/max';

/** 登录接口  */
export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
  return request<API.LoginResult>('/sysUser/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    data: body,
    ...(options || {}),
  });
}

样式文件 useStyles.less

.container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  overflow: auto;
  background-image: url(https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/V-_oS6r-i7wAAAAAAAAAAAAAFl94AQBr);
  background-size: 100% 100%;
}

忘记密码

新建文件 src/pages/Login/ForgotPasswordForm.tsx

import React from "react";
import {Button, Form, Input, message, Tabs} from 'antd';
import {LockOutlined, UserOutlined} from "@ant-design/icons";
import {updatePasswordFun} from "@/services/ant-design-pro/login";

/*
 * 组件名: ForgotPasswordForm
 * 组件用途: 重置密码表单
 * 创建日期: 2024/5/14
 */
const ForgotPasswordForm = (props) => {
  const onFinish = (values) => {
    if (values.password.length < 6) {
      message.error("密码长度至少6位")
      return;
    }
    // 密码必须同时包含数字和大小写字母
    if (!/[A-Z]/.test(values.password) || !/[a-z]/.test(values.password) || !/[0-9]/.test(values.password)) {
      message.error("密码必须同时包含数字和大小写字母")
      return;
    }
    // 两次密码必须一致
    if (values.password !== values.newPassword) {
      message.error("两次密码不一致")
      return;
    }

    updatePasswordFun(values.userName, values.newPassword).then(() => {
      message.success("更新密码成功,返回登录")
      props.updatePasswordStatus(false)
    })
  };

  return (
    <div className={"ant-pro-form-login-container"} style={{
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      flex: "none",
      height: "auto"
    }}>

      <div className="ant-pro-form-login-header" style={{
        marginTop: "5%"
      }}>
        <span className="ant-pro-form-login-logo ">
          <img alt="logo" src="/logo.svg"/>
        </span>
        <span className="ant-pro-form-login-title ">
          用户管理中心
        </span>
      </div>

      <Tabs
        activeKey={"account"}
        centered
        items={[
          {
            key: 'account',
            label: '重置密码',
          },
        ]}
      />

      <Form
        name="basic"
        style={{
          width: 328
        }}
        initialValues={{
          remember: false,
        }}
        layout="vertical"
        onFinish={onFinish}
        autoComplete="off"
      >
        <Form.Item
          label=""
          name="userName"
          rules={[
            {
              required: true,
              message: '请输入用户名',
            },
          ]}

        >
          <Input
            size={'large'}
            prefix={
              <UserOutlined/>
            }
            placeholder="请输入用户名"/>
        </Form.Item>

        <Form.Item
          label=""
          name="password"
          rules={[
            {
              required: true,
              message: '请输入密码!',
            },
          ]}
        >
          <Input.Password size={'large'} prefix={<LockOutlined/>} placeholder="请输入密码"/>
        </Form.Item>

        <Form.Item
          label=""
          name="newPassword"
          rules={[
            {
              required: true,
              message: '请确认密码!',
            },
          ]}
        >
          <Input.Password size={'large'} prefix={<LockOutlined/>} placeholder="请确认密码"/>
        </Form.Item>

        <div
          style={{
            marginBottom: 24,
          }}
        >
          <a
            style={{
              float: 'right',
              marginBottom: 20
            }}
            onClick={() => props.updatePasswordStatus(false)}
          >
            返回登录
          </a>
        </div>

        <Button type="primary" htmlType="submit" size="large" block>
          确认
        </Button>
      </Form>
    </div>
  );
};

export default ForgotPasswordForm;

image-20240517173734307

动态获取菜单

官方提供的动态菜单实现方法:菜单的高级用法 - Ant Design Pro

前提说明:实现动态路由时,所有的路由都必须提前在 config/routes.ts 中注册好,如果动态返回了 routes.ts 中不存在的路由信息,页面将会无法访问,具体问题可参考(Issue #11137)。只能动态返回 routes.ts 内的数据

修改 app.tsx 的 layout 方法,在配置中添加 menu 属性即可实现动态菜单

// ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
  return {
    // ... 省略其他代码
    menu: {
      // 每当 initialState?.currentUser?.userid 发生修改时重新执行 request
      params: {
        userId: initialState?.currentUser?.userId,
        roleIds: initialState?.currentUser?.sysRoleList?.map((item) => item.roleId),
      },
      request: async (params, defaultMenuData) => {
        // 调用接口获取菜单数据
        let { data } = await getRoleMenuFun(params.roleIds);

        if (data.length > 0) {
          return data;
        }
        return defaultMenuData;
      },
    },
    // 手动映射icon
    menuDataRender: (menuData) => fixMenuItemIcon(menuData),
  };
};

接口返回的data菜单格式和 config/routes.ts 中配置的保持一致,更多配置可以参考:Pro 的 Layout 组件 - Ant Design Pro

返回内容示例:

{
  path: '/user/login',
  layout: false, // 页面是否在layout布局样式下显示,设置成false会单独显示
  hideInMenu: true, // 是否隐藏菜单,这里只是设置不在左侧菜单列表中显示,仍可以访问
  name: '登录',
  component: './Login',
},
{
  path: '/welcome',
  name: '欢迎',
  icon: 'smile',
  component: './Welcome',
},
{
  path: 'test',
  name: '一级菜单',
  routes: [
    {
      path: 'test1',
      name: '二级菜单1',
      routes: [
        {
          path: 'test1-1',
          name: '三级菜单1-1',
          component: './Test',
        },
        {
          path: 'test1-2',
          name: '三级菜单1-2',
          component: './Test',
        },
      ],
    },
    {
      path: 'test2',
      name: '二级菜单2',
      component: './Test',
    },
  ],
},

当我们使用了动态返回的菜单时,图标就不出现了,这时需要手动映射icon图标,可参考这里

添加 src/utils/fixMenuItemIcon.ts 文件

import React from 'react';
import * as allIcons from '@ant-design/icons';

// FIX从接口获取菜单时icon为string类型
const fixMenuItemIcon = (menus, iconType = 'Outlined') => {
  menus.forEach((item) => {
    const { icon, children } = item;
    if (typeof icon === 'string') {
      let fixIconName = icon.slice(0, 1).toLocaleUpperCase() + icon.slice(1) + iconType;
      console.log(fixIconName, 'fixIconName');
      item.icon = React.createElement(allIcons[fixIconName] || allIcons[icon]);
    }
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
    children && children.length > 0 ? (item.children = fixMenuItemIcon(children)) : null;
  });
  return menus;
};

export default fixMenuItemIcon;

这里二级菜单的图标没有,官方是这样解释的

最终实现的效果,先给管理员和普通和用户分配不同的菜单

image-20240521171759248

image-20240521171823525

切换登录不同角色的用户,会显示不同的菜单

hmgif2

实现过程中遇到的问题以及解决方法

实现源码

https://gitee.com/szxio/user-center

函数式组件的父子组件方法互相调用

编写子组件 Child

注意:子组件需要使用 forwardRef 函数包裹,然后使用 useImperativeHandle 暴露属性和方法

import React, {forwardRef, useImperativeHandle} from 'react';
import {Button} from 'antd';

// 子组件使用forwardRef函数包裹
// forwardRef函数接收两个参数,第一个参数是props,第二个参数是ref
const Child = forwardRef((props, ref) => {
  // 定义方法,将来由父组件调用
  const getChildStr = () => {
    console.log('子组件的getChildStr方法被触发');
    return '来自子组件的返回值';
  };

  const getParentFn = () => {
    // 调用父组件的方法
    props?.parentAddCount?.();
  };

  // useImperativeHandle函数接收两个参数,第一个参数是ref,第二个参数是一个函数
  // 这个函数返回一个对象,这个对象中的属性和方法会被暴露给父组件
  useImperativeHandle(ref, () => {
    return {
      getChildStr,
    };
  });

  return (
    <div className={'p-3 bg-amber-500'}>
      <div>我是子组件</div>

      <Button onClick={getParentFn}>调用父组件方法</Button>
    </div>
  );
});

export default Child;

编写父组件

import React, { useRef } from 'react';
import Child from '@/pages/test/Child';
import { Button } from 'antd';

const Index = () => {
  let [count, setCount] = React.useState(0);
  let childRef = useRef();

  // 提供给子组件调用的方法,子组件使用 prop.parentAddCount() 实现调用父组件的方法
  const addCount = () => {
    setCount(count + 1);
  };

  // 调用子组件方法,使用 childRef.current 获取子组件暴露的属性和方法
  const getChildStr = () => {
    let childStr = childRef.current?.getChildStr();
    console.log(childStr);
  };

  return (
    <>
      <Child ref={childRef} parentAddCount={addCount} />

      <div style={{ marginTop: 20 }}>count:{count}</div>
      <Button onClick={getChildStr}>调用子组件的方法</Button>
    </>
  );
};

export default Index;

效果展示

image-20240518215529255

图表 Ant Design Charts

官网地址

·可视化组件库 | AntV (antgroup.com)

快速上手

安装

我们提供了 Ant Design 的 npm 包,通过下面的命令即可完成安装:

npm install @ant-design/charts --save

#yarn
yarn add @ant-design/charts --save

#pnpm
pnpm add @ant-design/charts --save

成功安装完成之后,即可使用 importrequire 进行引用:

import { Line } from '@ant-design/charts';

在需求明确的情况下,也可仅引入相关子包

# 统计图表
npm install @ant-design/plots --save

Java操作Word文档

poi-tl介绍

官方文档:https://deepoove.com/poi-tl/

poi-tl(poi template language)是Word模板引擎,使用模板和数据创建很棒的Word文档

在文档的任何地方做任何事情(Do Anything Anywhere)是poi-tl的星辰大海。

方案 移植性 功能性 易用性
Poi-tl Java跨平台 Word模板引擎,基于Apache POI,提供更友好的API 低代码,准备文档模板和数据即可
Apache POI Java跨平台 Apache项目,封装了常见的文档操作,也可以操作底层XML结构 文档不全,这里有一个教程:Apache POI Word快速入门
Freemarker XML跨平台 仅支持文本,很大的局限性 不推荐,XML结构的代码几乎无法维护
OpenOffice 部署OpenOffice,移植性较差 - 需要了解OpenOffice的API
HTML浏览器导出 依赖浏览器的实现,移植性较差 HTML不能很好的兼容Word的格式,样式糟糕 -
Jacob、winlib Windows平台 - 复杂,完全不推荐使用

poi-tl是一个基于Apache POI的Word模板引擎,也是一个免费开源的Java类库,你可以非常方便的加入到你的项目中,并且拥有着让人喜悦的特性。

Word模板引擎功能 描述
文本 将标签渲染为文本
图片 将标签渲染为图片
表格 将标签渲染为表格
列表 将标签渲染为列表
图表 条形图(3D条形图)、柱形图(3D柱形图)、面积图(3D面积图)、折线图(3D折线图)、雷达图、饼图(3D饼图)、散点图等图表渲染
If Condition判断 根据条件隐藏或者显示某些文档内容(包括文本、段落、图片、表格、列表、图表等)
Foreach Loop循环 根据集合循环某些文档内容(包括文本、段落、图片、表格、列表、图表等)
Loop表格行 循环复制渲染表格的某一行
Loop表格列 循环复制渲染表格的某一列
Loop有序列表 支持有序列表的循环,同时支持多级列表
Highlight代码高亮 word中代码块高亮展示,支持26种语言和上百种着色样式
Markdown 将Markdown渲染为word文档
Word批注 完整的批注功能,创建批注、修改批注等
Word附件 Word中插入附件
SDT内容控件 内容控件内标签支持
Textbox文本框 文本框内标签支持
图片替换 将原有图片替换成另一张图片
书签、锚点、超链接 支持设置书签,文档内锚点和超链接功能
Expression Language 完全支持SpringEL表达式,可以扩展更多的表达式:OGNL, MVEL…
样式 模板即样式,同时代码也可以设置样式
模板嵌套 模板包含子模板,子模板再包含子模板
合并 Word合并Merge,也可以在指定位置进行合并
用户自定义函数(插件) 插件化设计,在文档任何位置执行函数

快速上手

Maven

<dependency>
  <groupId>com.deepoove</groupId>
  <artifactId>poi-tl</artifactId>
  <version>1.12.2</version>
</dependency>

准备一个模板文件,占位符使用双大括号占位

你好,我是{{name}},今年{{age}}

然后将模板放在 resources 目录下,编写代码

@Test
void test1() {
  // 定义模板对应的数据
  HashMap<String, Object> data = new HashMap<>();
  data.put("name", "张三");
  data.put("age", 18);

  // 加载本地模板文件
  InputStream inputStream = getClass().getResourceAsStream("/演示模板1.docx");

  // 渲染模板
  XWPFTemplate template = XWPFTemplate.compile(inputStream).render(data);

  try {
    // 写出到文件
    template.writeAndClose(new FileOutputStream("output.docx"));
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

效果展示

image-20240523094721108

加载远程模板文件

在实际业务场景中,模板可能会有很多,并且不会保存在本地,这时就需要加载远程模板来进行处理

下面是示例代码

@Test
void test2() {
  try {
    // 加载远程模板
    String templateUrl =
        "https://szx-bucket1.oss-cn-hangzhou.aliyuncs.com/picgo/%E6%BC%94%E7%A4%BA%E6%A8%A1%E6%9D%BF1.docx";
    URL url = new URL(templateUrl);
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    InputStream inputStream = conn.getInputStream();

    // 定义模板对应的数据
    HashMap<String, Object> data = new HashMap<>();
    data.put("name", "张三");
    data.put("age", 18);

    // 渲染模板
    XWPFTemplate template = XWPFTemplate.compile(inputStream).render(data);

    // 写出到文件
    template.writeAndClose(new FileOutputStream("output2.docx"));
  } catch (Exception e) {
    throw new RuntimeException(e);
  }
}

编写接口返回处理后的文件

下面我们来实现编写一个接口,前端访问时携带参数,后端完成编译后返回文件给前端下载

@Api(tags = "模板管理")
@RestController
@RequestMapping("/word")
public class WordController {
  @GetMapping("getWord")
  public void getWord(String name, Integer age, HttpServletResponse response) {
    // 定义模板对应的数据
    HashMap<String, Object> data = new HashMap<>();
    data.put("name", name);
    data.put("age", age);

    // 加载本地模板文件
    InputStream inputStream = getClass().getResourceAsStream("/演示模板1.docx");

    // 渲染模板
    XWPFTemplate template = XWPFTemplate.compile(inputStream).render(data);

    // 设置响应头,指定文件类型和内容长度
    response.setContentType(
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
    response.setHeader("Content-Disposition", "attachment; filename=output.docx");

    // 将生成的文件直接写出到HTTP响应输出流
    OutputStream outputStream = null;
    try {
      outputStream = response.getOutputStream();
      template.write(outputStream);
      outputStream.flush();

      // 关闭资源
      template.close();
      outputStream.close();
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }
}

前端代码编写

定义接口地址,并且请求中声明 responseType

import { request } from '@umijs/max';

// 下载报告
export async function getWordFun(age, name) {
  return request(`/word/getWord?age=${age}&name=${name}`, {
    method: 'get',
    responseType: 'blob', // 使用blob下载
  });
}

然后响应拦截器中判断 responseType

requestErrorConfig.ts

/**
 * @name 错误处理
 * pro 自带的错误处理, 可以在这里做自己的改动
 * @doc https://umijs.org/docs/max/request#配置
 */
export const errorConfig: RequestConfig = {

  // 响应拦截器
  responseInterceptors: [
    (response) => {
      // 拦截响应数据,进行个性化处理
      const res = response as unknown as ResponseStructure;

      // 判断流数据
      if (res.request.responseType === 'blob') {
        return response;
      }

      // 判断状态码
      if (!sucCodes.includes(res.data?.code)) {
        return Promise.reject(res.data);
      }

      return response;
    },
  ],
};

编写页面代码

import React from 'react';
import { ProForm, ProFormDigit, ProFormText } from '@ant-design/pro-components';
import { getWordFun } from '@/services/ant-design-pro/reportApi';

const Report = () => {
  const onFinish = async (values) => {
    let res = await getWordFun(values.age, values.name);
    // 接收流文件数据并下载
    const blob = new Blob([res], {
      type: res.type,
    });
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = 'test.docx';
    link.click();
  };
  return (
    <>
      <ProForm title="新建表单" onFinish={onFinish}>
        <ProFormText name="name" label="名称" placeholder="请输入名称" />
        <ProFormDigit type={'number'} name="age" label="年龄" placeholder="请输入年龄" />
      </ProForm>
    </>
  );
};

export default Report;

image-20240523132308219

下载的文件内容

image-20240523101613378

图片

图片标签以@开始:{{@var}}

@Test
void test3() {
  // 定义模板对应的数据
  HashMap<String, Object> data = new HashMap<>();
  data.put("name", "张三");
  data.put("age", 18);
  data.put("img", Pictures.ofUrl("http://deepoove.com/images/icecream.png")
          .size(100, 100).create());


  // 加载本地模板文件
  InputStream inputStream = getClass().getResourceAsStream("/演示模板1.docx");

  // 渲染模板
  XWPFTemplate template = XWPFTemplate.compile(inputStream).render(data);

  try {
    // 写出到文件
    template.writeAndClose(new FileOutputStream("output.docx"));
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

image-20240523134209524

image-20240523134123298

表格

表格标签以#开始:{{#var}}

// 插入表格
@Test
void test4() {
  // 定义模板对应的数据
  HashMap<String, Object> data = new HashMap<>();
  data.put("name", "张三");
  data.put("age", 18);
  data.put(
      "img", Pictures.ofUrl("http://deepoove.com/images/icecream.png").size(100, 100).create());

  // 第0行居中且背景为蓝色的表格
  RowRenderData row0 =
      Rows.of("学历", "时间").textColor("FFFFFF").bgColor("4472C4").center().create();
  RowRenderData row1 = Rows.create("本科", "2015~2019");
  RowRenderData row2 = Rows.create("研究生", "2019~2021");
  data.put("eduList", Tables.create(row0, row1, row2));

  // 加载本地模板文件
  InputStream inputStream = getClass().getResourceAsStream("/演示模板1.docx");

  // 渲染模板
  XWPFTemplate template = XWPFTemplate.compile(inputStream).render(data);

  try {
    // 写出到文件
    template.writeAndClose(new FileOutputStream("output.docx"));
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

image-20240523135141764

image-20240523135112583

表格行循环

我们希望根据一个集合的内容来决定表格的行数,这是就用到表格行循环

货物明细需要展示所有货物,{{goods}} 是个标准的标签,将 {{goods}} 置于循环行的上一行,循环行设置要循环的标签和内容,注意此时的标签应该使用 [] ,以此来区别poi-tl的默认标签语法。

示例代码

// 循环行表格
@Test
void test5() {
    Good good = new Good();
    good.setName("小米14");
    good.setPrice("4599");
    good.setColor("黑色");
    good.setTime("2024-05-23");

    Good good2 = new Good();
    good2.setName("苹果15");
    good2.setPrice("7599");
    good2.setColor("黑色");
    good2.setTime("2024-05-23");

    Good good3 = new Good();
    good3.setName("华为Meta60");
    good3.setPrice("7999");
    good3.setColor("白色");
    good3.setTime("2024-05-23");

    ArrayList<Good> goods = new ArrayList<>();
    goods.add(good);
    goods.add(good2);
    goods.add(good3);

    // 定义模板对应的数据
    HashMap<String, Object> data = new HashMap<>();
    data.put("name", "张三");
    data.put("age", 18);
    data.put(
        "img", Pictures.ofUrl("http://deepoove.com/images/icecream.png").size(100, 100).create());

    // 第0行居中且背景为蓝色的表格
    RowRenderData row0 =
        Rows.of("学历", "时间").textColor("FFFFFF").bgColor("4472C4").center().create();
    RowRenderData row1 = Rows.create("本科", "2015~2019");
    RowRenderData row2 = Rows.create("研究生", "2019~2021");
    data.put("eduList", Tables.create(row0, row1, row2));

    // 添加采购列表数据
    data.put("goods", goods);

    // 加载本地模板文件
    InputStream inputStream = getClass().getResourceAsStream("/演示模板1.docx");

    // 定义行循环插件
    LoopRowTableRenderPolicy policy = new LoopRowTableRenderPolicy();
    // 绑定插件
    Configure config = Configure.builder().bind("goods", policy).build();
    // 渲染模板
    XWPFTemplate template = XWPFTemplate.compile(inputStream, config).render(data);

    try {
        // 写出到文件
        template.writeAndClose(new FileOutputStream("output.docx"));
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

@Data
public class Good {
  private String name;
  private String price;
  private String color;
  private String time;
}

image-20240523142219092

项目线上部署

Docker部署

首先编写Dockerfile

Java的Dockerfile

方式一:基于已经打包的jar包编写DockerFile

从阿里镜像获取源地址,以获取更快的下载速度

访问:https://cr.console.aliyun.com/cn-hangzhou/instances/artifact

image-20240525171839394

image-20240525171950744

# 可以从阿里云的容器镜像服务中 找到openjdk,选择相对应的版本
FROM anolis-registry.cn-zhangjiakou.cr.aliyuncs.com/openanolis/openjdk:8-8.6

# 这里就是进入创建好的目录
WORKDIR /app

# 将打包后的jar包复制到指定目录(这里我是复制到了创建好的工作目录)下,并重命名
COPY ./user-center-0.0.1-SNAPSHOT.jar ./user-center-0.0.1-SNAPSHOT.jar

# 运行命令
CMD ["java","-jar","/app/user-center-0.0.1-SNAPSHOT.jar","--spring.profiles.active=prod"]

方式二:只上传代码,其他都交给Docker

FROM maven:3.8.1-jdk-8-slim as builder

WORKDIR /app

# 复制代码到容器
COPY pom.xml .
COPY src ./src

# 打包并跳过Test检查
RUN mvn package -DskipTests

CMD ["java","-jar","/app/target/user-center-0.0.1-SNAPSHOT.jar","--spring.profiles.active=prod"]

构建镜像

将Dockerfile和源码放在平级,然后运行下面命令构建镜像

docker build -t user-center:1.0.0 .

image-20240525181335994

启动镜像

docker run -d --name=user-center -p 8080:8080 user-center:1.0.0
前端Dockerfile

方式一:在镜像中进行打包

参考文章:https://blog.51cto.com/u_16099258/10476241

编写 Dockerfile

# 第一阶段:构建前端产出物
FROM node:20.11.1 AS builder

WORKDIR /visualization
COPY . .
RUN npm install -g pnpm --registry=https://registry.npmmirror.com/
RUN pnpm install && pnpm run build


# 第二阶段:生成最终容器映像
FROM nginx

COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/docker-entrypoint.sh /docker-entrypoint.sh


WORKDIR /home/visualization
COPY --from=builder /visualization/dist .

RUN chmod +x /docker-entrypoint.sh

image-20240526112246307

在根目录新建 docker 文件夹,放两个文件

1、新建nginx.conf文件,用于配置前端项目访问nginx配置文件
2、新建docker-entrypoint.sh文件,执行脚本动态修改nginx.conf中的代理请求地址

nginx.conf内容
~根据项目情况做出修改,gzip配置前端无则可删除
~ /dev是前端代理跨域的基准地址,要保持统一,代理到后端的地址,做代理的目的是后面可以根据容器run动态改变proxy_pass地址
~如果项目无https则可删除443监听

~有https则需要配置证书ssl_certificate、ssl_certificate_key,此文件的路径为后面 运行容器时(run) -v将宿主机的目录映射至容器,就是容器的目录

新建nginx.conf文件

server {
    listen 80;

    server_name  localhost;
    # gzip config
#     gzip off;
#     gzip_min_length 1k;
#     gzip_comp_level 9;
#     gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
#     gzip_vary off;
#     gzip_disable "MSIE [1-6]\.";
    #

    location / {
        root   /home/visualization;
        index  index.html index.htm;
        try_files $uri $uri/ /index.html;
    }

    location ^~/api/ {
      # 代理
      proxy_pass http://cx5k97.natappfree.cc/;

      access_log /var/log/nginx/dev_access.log;
      error_log /var/log/nginx/dev_error.log;
    }
}

新建docker-entrypoint.sh文件

#!/usr/bin/env bash

API_BASE_PATH=$API_BASE_PATH;
if [ -z "$API_BASE_PATH" ]; then
    API_BASE_PATH="https://xxx.xxx/";
fi

apiUrl="proxy_pass  $API_BASE_PATH;"
sed -i '22c '"$apiUrl"'' /etc/nginx/conf.d/default.conf
sed -i '75c '"$apiUrl"'' /etc/nginx/conf.d/default.conf

# 变量CERT判断是否需要证书https, $CERT存在则不需要
certOr="#"
if [ -n "$CERT" ]; then
 sed -i '45c '"$certOr"'' /etc/nginx/conf.d/default.conf
 sed -i '46c '"$certOr"'' /etc/nginx/conf.d/default.conf
 sed -i '60c '"$certOr"'' /etc/nginx/conf.d/default.conf
 sed -i '61c '"$certOr"'' /etc/nginx/conf.d/default.conf
fi

nginx -g "daemon off;"

然后在根目录新建 .dockerignore,忽略文件

# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
.DS_Store
dist

# node-waf configuration
.lock-wscript

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
.dockerignore
Dockerfile
*docker-compose*

# Logs
logs
*.log

# Runtime data
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
pids
*.pid
*.seed
.git
.hg
.svn

构建结果

image-20240526093128275

运行

//方式一:
// contanier_hello为容器名称
// -p 9090:80  将容器里面的80端口映射到宿主机的8080端口,80端口就是nginx里面配置,多个端口多个配置,必须确保服务器已经开了此端口
docker run -d --name user-center-web -p 8000:80 user-center-web:1.0.0

//方式二:
// 运行容器的时候改变nginx代理地址
// -e API_BASE_PATH就是上面sh文件中定义的变量 把nginx的后端接口地址改为http://www.baidu.com,这个地址一定不要格式错误,不然nginx会解析不出来

docker run -d --name user-center-web -p 80:80 -e "API_BASE_PATH=http://8g6igw.natappfree.cc/" user-center-web:1.0.0

最近更新

  1. TCP协议是安全的吗?

    2024-06-06 03:08:01       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-06-06 03:08:01       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-06-06 03:08:01       18 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-06-06 03:08:01       20 阅读

热门阅读

  1. MySQL——索引

    2024-06-06 03:08:01       8 阅读
  2. 【Android】PopupWindow焦点控制方式解析

    2024-06-06 03:08:01       11 阅读
  3. MySQL之数据库事务机制学习笔记(五)

    2024-06-06 03:08:01       9 阅读
  4. GPT-4o:突破与革新

    2024-06-06 03:08:01       8 阅读
  5. 如何评价GPT-4o?

    2024-06-06 03:08:01       9 阅读
  6. 如何评价GPT-4o?

    2024-06-06 03:08:01       11 阅读
  7. uniapp uni.showModal 出现点击没有反应

    2024-06-06 03:08:01       9 阅读
  8. 语音控制系统的安全挑战与防御策略(下)

    2024-06-06 03:08:01       8 阅读
  9. VRRP

    VRRP

    2024-06-06 03:08:01      6 阅读
  10. 安卓Launcher启动详解

    2024-06-06 03:08:01       8 阅读