技术栈 前端技术栈
“react”: “^18.2.0”,
ant-design-pro
后端技术栈
项目源码地址
前端项目搭建 快速搭建一个后端管理系统项目框架
初始化 antDesignPro 官网: https://pro.ant.design/zh-CN。开箱即用的中台前端/设计解决方案
我们提供了 pro-cli 来快速的初始化脚手架。
1 2 3 4 5 npm i @ant-design/pro-cli -g pro create user-center cd user-centerpnpm install
去除国际化
执行这个命令可以去掉项目中的国际化配置,再次启动可能会报引用错误,把多余的引用去掉即可
启动
访问
后端项目搭建 初始化 使用idea开发工具自带 Spring Initializr 完成项目创建
如果Java版本无法选中8,可以切换上面的 Server URL 为阿里的源 https://start.aliyun.com,然后就可以选择8版本了
接着点击 Next选择常用的开发依赖,下面我列出一些基本的依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 <?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 > <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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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: matching-strategy: ant_path_matcher mybatis-plus: mapper-locations: classpath:/mapper/**.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
SwaggerUI配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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;@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" )) .build(); } public ApiInfo webApiInfo () { return new ApiInfoBuilder() .title("用户中心接口文档" ) .build(); } }
MybatisPlus分页插件和自动插入当前日期 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @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); } }
启动类设置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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自带的代码生成器
注意:生成的文件会覆盖原有文件
统一结果返回类 Response
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 package com.szx.usercenter.util;import com.fasterxml.jackson.annotation.JsonInclude;@JsonInclude(JsonInclude.Include.NON_NULL) public class Response <T > { private String code; private String msg; private T data; public static <T> Response<T> success () { return rspMsg(ResponseEnum.SUCCESS); } public static <T> Response<T> error () { return rspMsg(ResponseEnum.SERVER_INNER_ERR); } public static <T> Response<T> rspMsg (ResponseEnum responseEnum) { Response<T> message = new Response<T>(); message.setCode(responseEnum.getCode()); message.setMsg(responseEnum.getMsg()); return message; } public static <T> Response<T> rspMsg (String code , String msg) { Response<T> message = new Response<T>(); message.setCode(code); message.setMsg(msg); return message; } 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; } 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.szx.usercenter.util;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; } }
编写测试接口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 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;@RestController @RequestMapping("/sysUser") @Api(tags = "用户管理") public class SusUserController { @Resource SysUserService sysUserService; @GetMapping("getAllUser") @ApiOperation("获取所有用户") public Response<List<SysUser>> getUserList() { return Response.rspData(sysUserService.list()); } @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页面试试
至此后端项目搭建完成
打包pom通用配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <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 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 1 2 3 4 5 6 7 8 9 10 11 12 13 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 1 2 3 4 5 6 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 = '用户和角色关联表' ;
1 2 3 4 5 6 7 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加密工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 package com.szx.usercenter;import cn.hutool.crypto.digest.BCrypt;import org.junit.jupiter.api.Test;import org.springframework.boot.test.context.SpringBootTest;@SpringBootTest public class UserPasswordTest { @Test void testJbcrypt () { String passwordToHash = "abc123" ; 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); } }
后端功能开发 注册逻辑
用户名不能有特殊字符,并且必须超过6位数
密码必须超过6位数
用户名不能重复
密码加密后保存到数据库中
注册接口开发 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @RestController @RequestMapping("/sysUser") @Api(tags = "用户管理") public class SusUserController { @Resource SysUserService sysUserService; @ApiOperation("用户注册") @PostMapping("register") public Response register (String username, String password) { return sysUserService.register(username,password); } }
实现 register 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Override public Response register (String username, String password) { if (!username.matches("^[a-zA-Z0-9_-]{6,16}$" )){ return Response.error("用户名必须超过6位数,并且不能有特殊字符" ); } if (password.length() < 6 ){ return Response.error("密码必须超过6位数" ); } if (this .getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, username)) != null ){ return Response.error("用户名已存在" ); } 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 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RestController @RequestMapping("/sysUser") @Api(tags = "用户管理") public class SusUserController { @Resource SysUserService sysUserService; @PostMapping("login") @ApiOperation("登录") public Response login (@RequestBody SysUser sysUser) { return sysUserService.login(sysUser); } }
login 方法实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Override public Response login (SysUser sysUser) { String userName = sysUser.getUserName(); String password = sysUser.getPassword(); 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("密码错误" ); } one.setPassword(null ); return Response.rspData(one); }
生成Token 给登录接口返回的内容中添加Token
在 login 实现方法中增加一个行代码,JwtHelper 的使用方法看的的这个文章 ,写的很详细
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @Override public Response login (SysUser sysUser) { String userName = sysUser.getUserName(); String password = sysUser.getPassword(); 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("密码错误" ); } one.setPassword(null ); one.setToken(JwtHelper.createToken(sysUser.getUserId(), sysUser.getUserName())); return Response.rspData(one); }
添加Token拦截器 编写 Token 配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 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;@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,进行校验,如果为空或者过期,则抛出自定义全局异常。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 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;public class TokenHandle implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)){ return true ; } String token = request.getHeader("X-Token" ); String referer = request.getHeader("Referer" ); boolean fromSwagger = referer.endsWith("swagger-ui/index.html" ); if (!fromSwagger && (StrUtil.isEmpty(token) || JwtHelper.tokenExpired(token))){ throw new CenterExceptionHandler(ResponseEnum.LOGIN_EXPIRED); } return true ; } }
自定义全局异常 新建全局异常处理类
GlobalExceptionHandler
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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;@RestControllerAdvice @Log4j2 public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) @ResponseBody public Response<Object> error (Exception e) { log.error(ExceptionUtil.getMessage(e)); e.printStackTrace(); return Response.error(e.getMessage()); } @ExceptionHandler(CenterExceptionHandler.class) @ResponseBody public Response<Object> businessExceptionHandler (CenterExceptionHandler e) { log.error("CenterExceptionHandler:" + e.getMessage(),e); return Response.rspMsg(e.getCode(),e.getMessage()); } }
新建自定义异常处理类
CenterExceptionHandler
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 package com.szx.usercenter.handle;import com.szx.usercenter.util.ResponseEnum;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;@Data public class CenterExceptionHandler extends RuntimeException { private String code; public CenterExceptionHandler (String message, String code) { super (message); this .code = code; } public CenterExceptionHandler (ResponseEnum errorCode) { super (errorCode.getMsg()); this .code = errorCode.getCode(); } public CenterExceptionHandler (String message) { super (message); this .code = ResponseEnum.ERROR.getCode(); } public CenterExceptionHandler () { super (ResponseEnum.ERROR.getMsg()); this .code = ResponseEnum.ERROR.getCode(); } }
然后再任何需要抛出异常的地方直接使用即可
例如
1 2 3 4 @GetMapping("testError") public Response testError () { throw new CenterExceptionHandler("测试异常" ); }
查询接口开发 接口实现类 SysUserServiceImpl 添加方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @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 类型,默认会出现一个类型转换错误的异常,如下图
前端传递的参数
我们可以修改配置文件,增加一个日期转换格式的配置
1 2 3 4 5 6 7 8 9 spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 default-property-inclusion: non_null
重启项目,再次查询就不会报错了
自动填充创建人和更新人 新建一个 BaseUser
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package com.szx.usercenter.contance;import lombok.Data;import org.springframework.stereotype.Component;@Data @Component public class BaseUser { public static String userName; }
然后再token拦截器中根据当前请求头中的tokne获取当前用户名,给BaseUser的userName赋值
修改 TokenHandle
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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;public class TokenHandle implements HandlerInterceptor { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true ; } String token = request.getHeader("Authorization" ); String referer = request.getHeader("Referer" ); boolean fromSwagger = referer.endsWith("swagger-ui/index.html" ); if (!fromSwagger && (StrUtil.isEmpty(token) || JwtHelper.tokenExpired(token))) { throw new CenterExceptionHandler(ResponseEnum.LOGIN_EXPIRED); } BaseUser.userName = JwtHelper.getUserName(token); return true ; } }
修该 MybatisPlusConfig
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 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;@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); } }
更新和创建时值自动填充
前端功能开发 登录逻辑梳理 首先找到登录页面对应的文件,位置在src/pages/User/Login/index.tsx
,然后观察代码,发现登录页面使用了 LoginForm
组件来实现的登录表单,LoginForm
是从 @ant-design/pro-components
中导出的,ProComponents 是基于 Ant Design 而开发的模板组件,提供了更高级别的抽象支持,开箱即用。可以显著地提升制作 CRUD 页面的效率,更加专注于页面。
loginForm组件使用文档:https://pro-components.antdigital.dev/components/form
点击登录会触发onFinish钩子函数,调用handleSubmit方法
login方法在src/services/ant-design-pro/api.ts
文件中声明
登录成功后调用 fetchUserInfo
方法获取用户信息
useModel
是 @umi/max
内置的数据流管理 插件 ,它是一种基于 hooks
范式的轻量级数据管理方案,可以在 Umi 项目中管理全局的共享数据。
文档地址:https://umijs.org/docs/max/data-flow#usemodel
useModel('@@initialState')
表示读取 app.tsx
文件中的 getInitialState
方法的返回值
1 const {initialState, setInitialState} = useModel('@@initialState' );
app.tsx 文件中的 getInitialState
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 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 接口地址
找到mock中对应的接口
下面我们按照这种格式编写后端接口即可
修改响应拦截器 找到 src/requestErrorConfig.tss
文件,这个文件中处理请求拦截和响应拦截
需要做的功能
给每个请求添加一个基础路径,配合代理完成跨域处理
给每个请求中添加token请求头
判断响应结果是否成功,如果不成功弹出错误提醒
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 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' ;export const errorConfig: RequestConfig = { 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; if (error.name === 'BizError' ) { const errorInfo: ResponseStructure | undefined = error.info; if (errorInfo) { const { errorMessage, errorCode } = errorInfo; switch (errorInfo.showType) { case ErrorShowType.SILENT: 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: break ; default : message.error(errorMessage); } } } else if (error.response) { message.error(`Response status:${error.response.status} ` ); } else if (error.request) { message.error('None response! Please retry.' ); } else { message.error(error?.data || error?.msg); } }, }, requestInterceptors: [ (config: RequestOptions) => { 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
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 export function setToken (token ) { localStorage .setItem('token' ,token) } export function getToken ( ) { return localStorage .getItem('token' ) }
设置代理 修改 config/proxy.ts
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 export default { dev: { '/api/edu' : { target: 'http://123.60.16.27:8101' , changeOrigin: true , pathRewrite: { '/api' : '' }, }, '/api/' : { target: 'http://localhost:8080' , changeOrigin: true , pathRewrite: { '/api' : '' }, }, }, test: { '/api/' : { target: 'https://proapi.azurewebsites.net' , changeOrigin: true , pathRewrite: { '^' : '' }, }, }, pre: { '/api/' : { target: 'your pre url' , changeOrigin: true , pathRewrite: { '^' : '' }, }, }, };
权限管理 找到 src/access.ts
文件
1 2 3 4 5 6 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
文件代码,删除多余代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 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
1 2 3 4 5 6 7 8 9 10 11 12 13 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
1 2 3 4 5 6 7 8 .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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 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" ;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;
动态获取菜单 官方提供的动态菜单实现方法:菜单的高级用法 - Ant Design Pro
前提说明:实现动态路由时,所有的路由都必须提前在 config/routes.ts
中注册好,如果动态返回了 routes.ts
中不存在的路由信息,页面将会无法访问,具体问题可参考(Issue #11137 )。只能动态返回 routes.ts
内的数据
修改 app.tsx
的 layout 方法,在配置中添加 menu
属性即可实现动态菜单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState } ) => { return { menu: { 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; }, }, menuDataRender: (menuData ) => fixMenuItemIcon(menuData), }; };
接口返回的data菜单格式和 config/routes.ts
中配置的保持一致,更多配置可以参考:Pro 的 Layout 组件 - Ant Design Pro
返回内容示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 { 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
文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import React from 'react' ;import * as allIcons from '@ant-design/icons' ;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]); } children && children.length > 0 ? (item.children = fixMenuItemIcon(children)) : null ; }); return menus; }; export default fixMenuItemIcon;
这里二级菜单的图标没有,官方是这样解释的
最终实现的效果,先给管理员和普通和用户分配不同的菜单
切换登录不同角色的用户,会显示不同的菜单
实现过程中遇到的问题以及解决方法
实现源码
https://gitee.com/szxio/user-center
函数式组件的父子组件方法互相调用 编写子组件 Child
注意:子组件需要使用 forwardRef 函数包裹,然后使用 useImperativeHandle 暴露属性和方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import React, {forwardRef, useImperativeHandle} from 'react' ;import {Button} from 'antd' ;const Child = forwardRef((props, ref ) => { const getChildStr = () => { console .log('子组件的getChildStr方法被触发' ); return '来自子组件的返回值' ; }; const getParentFn = () => { props?.parentAddCount?.(); }; useImperativeHandle(ref, () => { return { getChildStr, }; }); return ( <div className={'p-3 bg-amber-500' }> <div>我是子组件</div> <Button onClick={getParentFn}>调用父组件方法</Button> </div> ); }); export default Child;
编写父组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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(); const addCount = () => { setCount(count + 1 ); }; 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;
效果展示
图表 Ant Design Charts 官网地址 ·可视化组件库 | AntV (antgroup.com)
快速上手 安装
我们提供了 Ant Design 的 npm 包,通过下面的命令即可完成安装:
1 2 3 4 5 6 7 npm install @ant-design/charts --save yarn add @ant-design/charts --save pnpm add @ant-design/charts --save
成功安装完成之后,即可使用 import
或 require
进行引用:
1 import { Line } from '@ant-design/charts' ;
在需求明确的情况下,也可仅引入相关子包
1 2 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
1 2 3 4 5 <dependency > <groupId > com.deepoove</groupId > <artifactId > poi-tl</artifactId > <version > 1.12.2</version > </dependency >
准备一个模板文件,占位符使用双大括号占位
1 你好,我是{{name}},今年{{age}}岁
然后将模板放在 resources 目录下,编写代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @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); } }
效果展示
加载远程模板文件 在实际业务场景中,模板可能会有很多,并且不会保存在本地,这时就需要加载远程模板来进行处理
下面是示例代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @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); } }
编写接口返回处理后的文件 下面我们来实现编写一个接口,前端访问时携带参数,后端完成编译后返回文件给前端下载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @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" ); OutputStream outputStream = null ; try { outputStream = response.getOutputStream(); template.write(outputStream); outputStream.flush(); template.close(); outputStream.close(); } catch (IOException e) { throw new RuntimeException(e); } } }
前端代码编写
定义接口地址,并且请求中声明 responseType
1 2 3 4 5 6 7 8 9 import { request } from '@umijs/max' ;export async function getWordFun (age, name ) { return request(`/word/getWord?age=${age} &name=${name} ` , { method: 'get' , responseType: 'blob' , }); }
然后响应拦截器中判断 responseType
requestErrorConfig.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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; }, ], };
编写页面代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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;
下载的文件内容
图片
图片标签以@开始:{{@var}}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @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); } }
表格
表格标签以#开始:{{#var}}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @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()); 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); } }
表格行循环 我们希望根据一个集合的内容来决定表格的行数,这是就用到表格行循环
货物明细需要展示所有货物,{{goods}}
是个标准的标签,将 {{goods}}
置于循环行的上一行 ,循环行设置要循环的标签和内容,注意此时的标签应该使用 []
,以此来区别poi-tl的默认标签语法。
示例代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 @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()); 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; }
项目线上部署 Docker部署 首先编写Dockerfile
Java的Dockerfile 方式一:基于已经打包的jar包编写DockerFile
从阿里镜像获取源地址,以获取更快的下载速度
访问:https://cr.console.aliyun.com/cn-hangzhou/instances/artifact
1 2 3 4 5 6 7 8 9 10 11 FROM anolis-registry.cn-zhangjiakou.cr.aliyuncs.com/openanolis/openjdk:8-8.6 WORKDIR /app 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
1 2 3 4 5 6 7 8 9 10 11 12 FROM maven:3.8.1-jdk-8-slim as builder WORKDIR /app COPY pom.xml . COPY src ./src RUN mvn package -DskipTests CMD ["java" ,"-jar" ,"/app/target/user-center-0.0.1-SNAPSHOT.jar" ,"--spring.profiles.active=prod" ]
构建镜像
将Dockerfile和源码放在平级,然后运行下面命令构建镜像
1 docker build -t user-center:1.0.0 .
启动镜像
1 docker run -d --name=user-center -p 8080:8080 user-center:1.0.0
前端Dockerfile 方式一:在镜像中进行打包
参考文章:https://blog.51cto.com/u_16099258/10476241
编写 Dockerfile
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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
在根目录新建 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文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 server { listen 80 ; server_name localhost; 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文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #!/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 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
,忽略文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 node_modules .DS_Store dist .lock-wscript build/Release .dockerignore Dockerfile *docker-compose* logs *.log .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw* pids *.pid *.seed .git .hg .svn
构建结果
运行
1 2 3 4 5 6 7 8 9 10 //方式一: // 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