用户中心项目搭建笔记
技术栈
前端技术栈
- “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
访问
后端项目搭建
初始化
使用idea开发工具自带 Spring Initializr 完成项目创建
如果Java版本无法选中8,可以切换上面的 Server URL 为阿里的源 https://start.aliyun.com,然后就可以选择8版本了
接着点击 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自带的代码生成器
注意:生成的文件会覆盖原有文件
统一结果返回类
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页面试试
至此后端项目搭建完成
打包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);}
}
后端功能开发
注册逻辑
- 用户名不能有特殊字符,并且必须超过6位数
- 密码必须超过6位数
- 用户名不能重复
- 密码加密后保存到数据库中
注册接口开发
@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("注册失败");
}
登录逻辑
- 根据用户名获取数据库表中保存的用户信息
- 在用传递进来的密码和表中的密码进行密码校验
- 校验成功返回用户信息
- 否则登录失败
登录接口开发
@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("测试异常");
}
查询接口开发
接口实现类 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 类型,默认会出现一个类型转换错误的异常,如下图
前端传递的参数
我们可以修改配置文件,增加一个日期转换格式的配置
spring:# 配置日期返回格式jackson:# 日期格式date-format: yyyy-MM-dd HH:mm:ss# 时区time-zone: GMT+8# 非空的属性值才会被包含在结果中default-property-inclusion: non_null
重启项目,再次查询就不会报错了
自动填充创建人和更新人
新建一个 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);}
}
更新和创建时值自动填充
前端功能开发
登录逻辑梳理
首先找到登录页面对应的文件,位置在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
方法的返回值
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 接口地址
找到mock中对应的接口
下面我们按照这种格式编写后端接口即可
修改响应拦截器
找到 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;
动态获取菜单
官方提供的动态菜单实现方法:菜单的高级用法 - 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;
这里二级菜单的图标没有,官方是这样解释的
最终实现的效果,先给管理员和普通和用户分配不同的菜单
切换登录不同角色的用户,会显示不同的菜单
实现过程中遇到的问题以及解决方法
- 请问菜单从服务端获取,为什么还要在 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;
效果展示
图表 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
成功安装完成之后,即可使用 import
或 require
进行引用:
import { Line } from '@ant-design/charts';
在需求明确的情况下,也可仅引入相关子包
# 统计图表
npm install @ant-design/plots --save
Java操作Word文档
poi-tl介绍
官方文档:https://deepoove.com/poi-tl/
poi-tl(poi template language)是Word模板引擎,使用模板和数据创建很棒的Word文档。
在文档的任何地方做任何事情(Do Anything Anywhere)是poi-tl的星辰大海。
方案 | 移植性 | 功能性 | 易用性 |
---|---|---|---|
Poi-tl | Java跨平台 | Word模板引擎,基于Apache POI,提供更友好的API | 低代码,准备文档模板和数据即可 |
Apache POI | Java跨平台 | Apache项目,封装了常见的文档操作,也可以操作底层XML结构 | 文档不全,这里有一个教程:Apache POI Word快速入门 |
Freemarker | XML跨平台 | 仅支持文本,很大的局限性 | 不推荐,XML结构的代码几乎无法维护 |
OpenOffice | 部署OpenOffice,移植性较差 | - | 需要了解OpenOffice的API |
HTML浏览器导出 | 依赖浏览器的实现,移植性较差 | HTML不能很好的兼容Word的格式,样式糟糕 | - |
Jacob、winlib | Windows平台 | - | 复杂,完全不推荐使用 |
poi-tl是一个基于Apache POI的Word模板引擎,也是一个免费开源的Java类库,你可以非常方便的加入到你的项目中,并且拥有着让人喜悦的特性。
Word模板引擎功能 | 描述 |
---|---|
文本 | 将标签渲染为文本 |
图片 | 将标签渲染为图片 |
表格 | 将标签渲染为表格 |
列表 | 将标签渲染为列表 |
图表 | 条形图(3D条形图)、柱形图(3D柱形图)、面积图(3D面积图)、折线图(3D折线图)、雷达图、饼图(3D饼图)、散点图等图表渲染 |
If Condition判断 | 根据条件隐藏或者显示某些文档内容(包括文本、段落、图片、表格、列表、图表等) |
Foreach Loop循环 | 根据集合循环某些文档内容(包括文本、段落、图片、表格、列表、图表等) |
Loop表格行 | 循环复制渲染表格的某一行 |
Loop表格列 | 循环复制渲染表格的某一列 |
Loop有序列表 | 支持有序列表的循环,同时支持多级列表 |
Highlight代码高亮 | word中代码块高亮展示,支持26种语言和上百种着色样式 |
Markdown | 将Markdown渲染为word文档 |
Word批注 | 完整的批注功能,创建批注、修改批注等 |
Word附件 | Word中插入附件 |
SDT内容控件 | 内容控件内标签支持 |
Textbox文本框 | 文本框内标签支持 |
图片替换 | 将原有图片替换成另一张图片 |
书签、锚点、超链接 | 支持设置书签,文档内锚点和超链接功能 |
Expression Language | 完全支持SpringEL表达式,可以扩展更多的表达式:OGNL, MVEL… |
样式 | 模板即样式,同时代码也可以设置样式 |
模板嵌套 | 模板包含子模板,子模板再包含子模板 |
合并 | Word合并Merge,也可以在指定位置进行合并 |
用户自定义函数(插件) | 插件化设计,在文档任何位置执行函数 |
快速上手
Maven
<dependency><groupId>com.deepoove</groupId><artifactId>poi-tl</artifactId><version>1.12.2</version>
</dependency>
准备一个模板文件,占位符使用双大括号占位
你好,我是{{name}},今年{{age}}岁
然后将模板放在 resources 目录下,编写代码
@Test
void test1() {// 定义模板对应的数据HashMap<String, Object> data = new HashMap<>();data.put("name", "张三");data.put("age", 18);// 加载本地模板文件InputStream inputStream = getClass().getResourceAsStream("/演示模板1.docx");// 渲染模板XWPFTemplate template = XWPFTemplate.compile(inputStream).render(data);try {// 写出到文件template.writeAndClose(new FileOutputStream("output.docx"));} catch (IOException e) {throw new RuntimeException(e);}
}
效果展示
加载远程模板文件
在实际业务场景中,模板可能会有很多,并且不会保存在本地,这时就需要加载远程模板来进行处理
下面是示例代码
@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;
下载的文件内容
图片
图片标签以@开始:{{@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);}
}
表格
表格标签以#开始:{{#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);}
}
表格行循环
我们希望根据一个集合的内容来决定表格的行数,这是就用到表格行循环
货物明细需要展示所有货物,
{{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;
}
项目线上部署
Docker部署
首先编写Dockerfile
Java的Dockerfile
方式一:基于已经打包的jar包编写DockerFile
从阿里镜像获取源地址,以获取更快的下载速度
访问:https://cr.console.aliyun.com/cn-hangzhou/instances/artifact
# 可以从阿里云的容器镜像服务中 找到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 .
启动镜像
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
在根目录新建 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
构建结果
运行
//方式一:
// 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