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: 8080spring:application:name: user-center# 数据源配置datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/user-center?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghaiusername: rootpassword: abc123# 配置日期返回格式jackson:# 日期格式date-format: yyyy-MM-dd HH:mm:ss# 时区time-zone: GMT+8# 非空的属性值才会被包含在结果中default-property-inclusion: non_nullmvc:pathmatch:# swagger配置路径匹配规则matching-strategy: ant_path_matchermybatis-plus:mapper-locations: classpath:/mapper/**.xmlconfiguration:# 开启控制台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 {@Beanpublic 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 {// 分页插件@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));return interceptor;}// 创建日期和更新日期自动更新@Overridepublic void insertFill(MetaObject metaObject) {setFieldValByName("createTime", new Date(),metaObject);setFieldValByName("updateTime",new Date(),metaObject);}// 更新日期自动更新@Overridepublic 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 {@ResourceSysUserService 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 {/*** 密码加密*/@Testvoid testJbcrypt() {String passwordToHash = "abc123";// BCrypt.gensalt()会随机生成一个数作为盐,因此密码相同情况下,每次的密文是不一样String hashedPassword = BCrypt.hashpw(passwordToHash, BCrypt.gensalt());System.out.println(hashedPassword);}/*** 密码校验*/@Testvoid 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 {@ResourceSysUserService 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 {@ResourceSysUserService 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);// 生成tokenone.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 {@Overridepublic 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 {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if(!(handler instanceof HandlerMethod)){return true;}// 从请求头中获取tokenString token = request.getHeader("X-Token");// 获取请求来源String referer = request.getHeader("Referer");boolean fromSwagger = referer.endsWith("swagger-ui/index.html");// 校验tokenif(!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)@ResponseBodypublic 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)@ResponseBodypublic 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 {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (!(handler instanceof HandlerMethod)) {return true;}// 从请求头中获取tokenString token = request.getHeader("Authorization");// 获取请求来源String referer = request.getHeader("Referer");boolean fromSwagger = referer.endsWith("swagger-ui/index.html");// 校验tokenif (!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 {// 分页插件@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));return interceptor;}// 创建日期和更新日期自动更新@Overridepublic void insertFill(MetaObject metaObject) {setFieldValByName("createTime", new Date(), metaObject);setFieldValByName("createBy", BaseUser.userName, metaObject);setFieldValByName("updateTime", new Date(), metaObject);setFieldValByName("updateBy", BaseUser.userName, metaObject);}// 更新日期自动更新@Overridepublic 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 nothingbreak;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: redirectbreak;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 的功能可能需要这个,比如 cookiechangeOrigin: true,// 去掉真实请求地址中的/apipathRewrite: { '/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%' }}><LoginFormcontentStyle={{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);}}><TabsactiveKey={type}onChange={setType}centereditems={[{key: 'account',label: '账户密码登录',},]}/><><ProFormTextname="username"fieldProps={{size: 'large',prefix: <UserOutlined />,}}placeholder={'请输入用户名'}rules={[{required: true,message: '用户名是必填项!',},]}/><ProFormText.Passwordname="password"fieldProps={{size: 'large',prefix: <LockOutlined />,}}placeholder={'请输入密码'}rules={[{required: true,message: '密码是必填项!',},]}/></><divstyle={{marginBottom: 24,}}><astyle={{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><TabsactiveKey={"account"}centereditems={[{key: 'account',label: '重置密码',},]}/><Formname="basic"style={{width: 328}}initialValues={{remember: false,}}layout="vertical"onFinish={onFinish}autoComplete="off"><Form.Itemlabel=""name="userName"rules={[{required: true,message: '请输入用户名',},]}><Inputsize={'large'}prefix={<UserOutlined/>}placeholder="请输入用户名"/></Form.Item><Form.Itemlabel=""name="password"rules={[{required: true,message: '请输入密码!',},]}><Input.Password size={'large'} prefix={<LockOutlined/>} placeholder="请输入密码"/></Form.Item><Form.Itemlabel=""name="newPassword"rules={[{required: true,message: '请确认密码!',},]}><Input.Password size={'large'} prefix={<LockOutlined/>} placeholder="请确认密码"/></Form.Item><divstyle={{marginBottom: 24,}}><astyle={{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 发生修改时重新执行 requestparams: {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;},},// 手动映射iconmenuDataRender: (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-expressionschildren && children.length > 0 ? (item.children = fixMenuItemIcon(children)) : null;});return menus;
};export default fixMenuItemIcon;

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

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

image-20240521171759248

image-20240521171823525

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

hmgif2

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

  • 请问菜单从服务端获取,为什么还要在 routes 配置好全部,否则就不能正常解析 · Issue #11137 · ant-design/ant-design-pro (github.com)
  • 动态菜单实现后,路由是全部的,可以通过url跳转到不显示的菜单项BUG] · Issue #10728 · ant-design/ant-design-pro (github.com)

实现源码

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

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 builderWORKDIR /app# 复制代码到容器
COPY pom.xml .
COPY src ./src# 打包并跳过Test检查
RUN mvn package -DskipTestsCMD ["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 builderWORKDIR /visualization
COPY . .
RUN npm install -g pnpm --registry=https://registry.npmmirror.com/
RUN pnpm install && pnpm run build# 第二阶段:生成最终容器映像
FROM nginxCOPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/docker-entrypoint.sh /docker-entrypoint.shWORKDIR /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 bashAPI_BASE_PATH=$API_BASE_PATH;
if [ -z "$API_BASE_PATH" ]; thenAPI_BASE_PATH="https://xxx.xxx/";
fiapiUrl="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" ]; thensed -i '45c '"$certOr"'' /etc/nginx/conf.d/default.confsed -i '46c '"$certOr"'' /etc/nginx/conf.d/default.confsed -i '60c '"$certOr"'' /etc/nginx/conf.d/default.confsed -i '61c '"$certOr"'' /etc/nginx/conf.d/default.conf
finginx -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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/339379.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

众汇:外汇狙击指标如何使用?

对于投资者来说&#xff0c;我们各位交易的目的是什么?WeTrade众汇认为那就是盈利。所以来说有一个指标对各位投资者来说那是相当有帮助的。这是因为对于交易者而言&#xff0c;利用这些指标可以快速识别盈利的买卖时机。当我们选择一个指标之后&#xff0c;深入了解其适用范围…

luckysheet的使用

前言 公司新需求要一个在线的excel编辑器 一、luckysheet是什么&#xff1f; LuckySheet是一款基于Web的在线表格组件&#xff0c;一款纯前端类似excel的在线表格&#xff0c;功能强大、配置简单、完全开源结合Vue3可以实现数据的动态展示和编辑&#xff0c;为用户提供良好的…

2024年四川省三支一扶报名流程图解✅

2024年四川省三支一扶报名流程图解✅ &#x1f534;时间安排 1、报名时间&#xff1a;5月31日—6月4日17:00 2、资格初审时间&#xff1a;5月31日—6月5日17:00 3、准考证打印时间&#xff1a;6月25日—6月29日 4、笔试时间&#xff1a;6月30日 5、笔试成绩&#xff1a;7…

C++:特殊类设计和四种类型转换

一、特殊类设计 1.1 不能被拷贝的类 拷贝只会放生在两个场景中&#xff1a;拷贝构造函数以及赋值运算符重载&#xff0c;因此想要让一个类禁止拷贝&#xff0c;只需让该类不能调用拷贝构造函数以及赋值运算符重载即可。 C98&#xff1a; 1、将拷贝构造函数与赋值运算符重载只…

【Unity Shader入门精要 第12章】屏幕后处理效果(三)

1. Bloom效果 Bloom描述的是图像中较亮的部分向周围一定范围内发生扩散&#xff0c;造成一种朦胧的效果&#xff0c;常用于表现游戏中的灯光或隧道出口之类的效果。 下面的例子将实现一个简单的Bloom效果&#xff0c;其原理是&#xff1a; 将原始图像中较亮&#xff08;灰度…

2023-2025年最值得选择的Java毕业设计选题大全:1000个热门选题推荐✅✅✅

&#x1f497;博主介绍&#xff1a;✌全网粉丝1W,CSDN作者、博客专家、全栈领域优质创作者&#xff0c;博客之星、平台优质作者、专注于Java、小程序技术领域和毕业项目实战✌&#x1f497; &#x1f31f;文末获取源码数据库&#x1f31f; 感兴趣的可以先收藏起来&#xff0c;还…

密码加密及验证

目录 为什么需要加密&#xff1f; 密码算法分类 对称密码算法 非对称密码算法 摘要算法 DigestUtils MD5在线解密工具原理 实现用户密码加密 代码实现 为什么需要加密&#xff1f; 在MySQL数据库中&#xff0c;我们常常需要对用户密码、身份证号、手机号码等敏感信息进…

centos8stream 编译安装 php-rabbit-mq模块

官方GitHub&#xff1a;https://github.com/php-amqp/php-amqp 环境依赖安装 dnf install cmake make -y 1.安装rabbitmq-c cd /usr/local/src/ wget https://github.com/alanxz/rabbitmq-c/archive/refs/tags/v0.14.0.tar.gz tar xvf v0.14.0.tar.gz cd rabbitmq-c-0.14.0/…

NoSQL是什么?NoSQL数据库存在SQL注入攻击?

一、NoSQL是什么&#xff1f; NoSQL&#xff08;Not Only SQL&#xff09;是一种非关系型数据库的概念。与传统的关系型数据库不同&#xff0c;NoSQL数据库使用不同的数据模型来存储和检索数据。NOSQL数据库通常更适合处理大规模的非结构化和半结构化数据&#xff0c;且能够…

Docker-----emqx部署

emqx通过Docker容器化部署流程 1.创建持久化挂载目录 mkdir -p /home/emqx/etc ------挂载emqx的配置文件目录 mkdir -p /home/emqx/data ------挂载emqx的存储目录 mkdir -p /home/emqx/log ------挂载emqx的日志目录 [root home]# mkdir -p /home/emqx/etc [root home]# mkd…

IC芯片晶片固定保护环氧胶有什么优点?

IC芯片晶片固定保护环氧胶有什么优点&#xff1f; IC芯片晶片固定环氧胶在电子设备制造和组装中被广泛使用&#xff0c;主要用于电子封装和芯片固定应用&#xff0c;具有多种显著优点&#xff0c;其主要优点包括但不限于以下几点&#xff1a; 高强度粘接&#xff1a;环氧胶的固…

开源VS闭源:大模型发展路径之争,你站哪一派?

文章目录 引言一、数据隐私1.1开源大模型的数据隐私1.2 闭源大模型的数据隐私1.3 综合考量 二、商业应用2.1 开源大模型的商业应用2.2 闭源大模型的商业应用2.3 商业应用的综合考量 三、社区参与3.1 开源大模型的社区参与3.2 闭源大模型的社区参与3.3 综合考量 结论 引言 在人…

1.JAVA小项目(零钱通)

一、说明 博客内容&#xff1a;B站韩顺平老师的视频&#xff0c;以及代码的整理。此项目分为两个版本&#xff1a; 面向过程思路实现面向对象思路实现 韩老师视频地址&#xff1a;【【零基础 快速学Java】韩顺平 零基础30天学会Java】 https://www.bilibili.com/video/BV1fh4…

Django基础学习(一)

前端开发 目的&#xff1a;开发一个平台(网站)- 前端开发&#xff1a; HTML, CSS,JavaScript- web框架&#xff1a;接收请求并进行处理- MySQL数据库&#xff1a;存储相应的数据1.快速开发网站 pip install flask创建项目并导入flask框架,然后建立网址和函数的对应关系。 fr…

mysql DDL——增删改

简略版&#xff1a; 文字化&#xff1a; 1.对全部字段添加数据&#xff1a;insert into 表名 values (值1&#xff0c;值2&#xff0c;值3...); 2.对指定字段添加数据&#xff1a;insert into 表名 (字段名1&#xff0c;字段名2...) values &#xff08;值1&#xff0c;值2..…

远程桌面连接不上的解决方法?

随着远程办公的兴起&#xff0c;远程桌面连接成为了日常工作中必不可少的工具之一。有时我们可能会遇到无法连接或连接不稳定的情况。本文将介绍一些常见的远程桌面连接问题及其解决方法。 问题一&#xff1a;无法连接远程桌面 当我们尝试连接远程桌面时&#xff0c;有时会遇到…

uniapp 怎么设置凸起的底部tabbar

1. uniapp 怎么设置凸起的底部tabbar 1.1. 方案一系统提供 1.1.1. 使用uniapp官方提供的属性midButton 使用时&#xff0c;list数组须为偶数 &#xff08;1&#xff09;pages.json "tabBar": {"custom": true,"color": "#8F8F94",&q…

树莓派4B 学习笔记3: 系统自动更新时间_测试CSI摄像头_安装OpenCv_4.6(未成功编译源码)_备份树莓派镜像

今日继续学习树莓派4B 4G&#xff1a;&#xff08;Raspberry Pi&#xff0c;简称RPi或RasPi&#xff09; 本文我只是安装了OpenCv 4.6&#xff0c;但编译源码失败了&#xff01;有关 OpenCv 部分仅做笔记暂存&#xff01; 本人所用树莓派4B 装载的系统与版本如下: 版本可用命令…

【记录】打印|用浏览器生成证件照打印PDF,打印在任意尺寸的纸上(简单无损!)

以前我打印证件照的时候&#xff0c;我总是在网上找在线证件照转换或者别的什么。但是我今天突然就琢磨了一下&#xff0c;用 PDF 打印应该也可以直接打印出来&#xff0c;然后就琢磨出来了&#xff0c;这么一条路大家可以参考一下。我觉得比在线转换成一张 a4 纸要方便的多&am…

基于PHP+MySQL组合开发的720VR全景小程序源码系统 一键生成三维实景 前后端分离带网站的安装代码包以及搭建教程

系统概述 这款源码系统是专门为实现 720VR 全景展示而设计的。它结合了先进的技术和创新的理念&#xff0c;能够将真实场景以全景的形式呈现给用户&#xff0c;让用户仿佛身临其境。该系统采用 PHP 进行后端开发&#xff0c;MySQL 作为数据库管理系统&#xff0c;确保了系统的…