动力节点-动力云客项目实战

278 阅读34分钟

一、项目技术选型及开发工具

前后端分离的项目(前端项目+ 后端项目)

前端:Html、CSS、JavaScript、Vue3、Axios、Router、Element Plus

后端:Spring Boot、Spring Security、MyBatis、MyBatis-plus、MySQL、Redis、MinIo、Nginx

相关组件:HiKariCP(Spring Boot默认数据库连接池)、Spring-Data-Redis(Spring整合Redis)、Lombok(代码生成工具)、jwt(Json web token)、EasyExcel(Excel处理类库)、ECharts(前端图表库)

服务器:MySQL、Redis、Linux

项目依赖管理:Maven

项目开发工具:IDEA、Apifox

数据库sql📎dlyk.sql

简单的项目效果可移至b站观看

www.bilibili.com/video/BV19H…

具体代码可转至我的码云

gitee.com/love-ovo/lo…

二、前端组件添加

npm create vite@latest
npm install element-plus --save
npm install axios --save
npm install vue-router --save
npm install @element-plus/icons-vue --save
npm install echarts --save
//语法结构:import ... from ... 结构,从依赖中的vue框架导入createApp()函数
import { createApp } from 'vue'
//从依赖中的element-plus框架导入ElementPlus组件
import ElementPlus from 'element-plus'
//从依赖的element-plus框架导入css样式,导入样式不需要from
import 'element-plus/dist/index.css'
//从element-plus/icons-vue导入所有的图标组件
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
//从element-plus/dist/locale/zh-cn.mjs导入zhCn中文组件
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
//从router.js这个文件导入router组件
import router from './router/router.js'
//从./App.vue这个页面导入App组件(vue页面本身也叫组件,组件名默认是页面的文件名)
import App from './App.vue'
//使用上面导入的createApp()函数创建vue应用,创建vue应用时需要给它一个vue页面/组件,不能凭空创建
let app = createApp(App);
//在创建的vue应用中使用ElementPlus组件
app.use(ElementPlus, {locale: zhCn});
//循环出所有的图标,注册到vue应用中
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
}
app.use(ElementPlus)
//在创建的vue应用中使用router组件
app.use(router)
app.mount('#app');

三、后端基本组建

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>
  
  <groupId>com.peng</groupId>
  <artifactId>peng-dlyk-server</artifactId>
  <version>1.0-SNAPSHOT</version>

  <name>peng-dlyk-server</name>
  <description>peng-dlyk-server</description>

  <properties>
    <java.version>17</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <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>3.0.3</version>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <scope>runtime</scope>
      <optional>true</optional>
    </dependency>

    <dependency>
      <groupId>com.mysql</groupId>
      <artifactId>mysql-connector-j</artifactId>
      <scope>runtime</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-configuration-processor</artifactId>
      <optional>true</optional>
    </dependency>

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

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>org.mybatis.spring.boot</groupId>
      <artifactId>mybatis-spring-boot-starter-test</artifactId>
      <version>3.0.3</version>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-boot-starter</artifactId>
      <version>3.5.3.1</version> <!-- 确保版本号正确 -->
    </dependency>

    <dependency>
      <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--jwt依赖-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>4.4.0</version>
        </dependency>

        <!--Page Helper分页插件的依赖-->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>1.4.7</version>
        </dependency>

        <!--aop-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!--EasyExcel-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
            <version>3.3.3</version>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.15.1</version>  <!-- 最新版本 -->
        </dependency>

        <!--minio-->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.5.17</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
server:
  port: 8080
  servlet:
    context-path: /

#配置数据库连接相关信息
spring:
  datasource:
    url: jdbc:mysql://192.168.37.130:3306/dlyk?useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: 123456@Pyc
    hikari:
      #最大连接数,默认是10
      maximum-pool-size: 30
      #最小空闲连接,默认是10
      minimum-idle: 30
      #等待连接池分配连接的最大时长,超过该时长还没有可用连接则发生超时异常(单位毫秒)
      connection-timeout: 5000
      #空闲连接的最大时长,空闲多久就被释放回收,设置为0不让连接回收
      idle-timeout: 0
      #一个连接的最大生命时间,超过该时间还没有使用就回收掉(单位毫秒),最好不要超过8小时
      max-lifetime: 18000000


  #配置redis的连接信息
  data:
    redis:
      host: 192.168.37.130
      port: 6379
      password: 123456
      database: 1

  #设置jackson返回json数据时,时区和格式
  jackson:
    time-zone: GMT+8
    date-format: yyyy-MM-dd HH:mm:ss

# mybatis-plus的配置
mybatis-plus:
  type-aliases-package: com.peng.model
  global-config:
    db-config:
      camel-case: false
  configuration:
    map-underscore-to-camel-case: false #plus自动开启驼峰,关闭驼峰
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

#定义定时任务的调度时间
project:
  task:
    cron: '* */5 * * * *'
    delay: 180000

#minio配置
minio:
  endpoint: http://192.168.37.130:9000
  accessKey: minioadmin
  secretKey: minioadmin
  bucket: myfile

注意关注驼峰映射 plus自带驼峰,但是X生成的不一定是驼峰式!!!

(一) 多个工具类+常量类+返回类

1. 前端

import axios from 'axios'
import {getTokenName, messageConfirm, messageTip, removeToken} from "../util/util.js";
const instance = axios.create({
    baseURL: 'http://localhost:8080',
    timeout: 1000,
    headers: {'X-Requested-With': 'XMLHttpRequest'}
});

export function doGet(url, params) {
    return instance({
        method: "get",
        url: url,
        params: params, //{name: "对的", age: 22},
        dataType:"json"
    })
}

export function doPost(url, data) {
    return instance({
        method: "post",
        url: url,
        data: data, //{name: "好的呢", age: 22},
        dataType: "json"
    })
}

export function doPut(url, data) {
    return instance({
        method: "put",
        url: url,
        data: data, //{name:"好的呢", age: 22},
        dataType: "json"
    })
}

export function doDelete(url, params) {
    return instance({
        method: "delete",
        url: url,
        params: params, //{name: "对的", age: 22},
        dataType:"json"
    })
}

// 添加请求拦截器 配置 token 请求头
instance.interceptors.request.use( (config) => {
    console.log("触发了请求拦截器")
    // 在发送请求之前做些什么,在请求头中放一个token(jwt),传给后端接口
    let token = window.sessionStorage.getItem(getTokenName());
    if (!token) { //当 会话储存空间 中没取到值 (没有点击 记住我)
        token = window.localStorage.getItem(getTokenName());
        if (token) {
            config.headers['rememberMe'] = true;
        }
    }
    if (token) { //token是存在的
        config.headers['Authorization'] = token;
    }
    return config;
},  (error) => {
    // 对请求错误做些什么
    return Promise.reject(error);
});


// 添加响应拦截器 反应后端返回来的token验证数据
instance.interceptors.response.use( (response) => {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么,拦截token验证的结果,进行相应的提示和页面跳转
    if (response.data.code > 900) { //code大于900说明是token验证未通过,900是后端自定义的
        //给前端用户提示,并且跳转页面
        messageConfirm(response.data.msg + ",是否重新去登录?").then(() => { //用户点击“确定”按钮就会触发then函数
            //既然后端验证token未通过,那么前端的token肯定是有问题的,那没必要存储在浏览器中,直接删除一下
            removeToken();
            //跳到登录页
            window.location.href = "/";
        }).catch(() => { //用户点击“取消”按钮就会触发catch函数
            messageTip("取消去登录", "warning");
        })
        return ;
    }
    return response;
},  (error) => {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error);
});

export default instance;
import {ElMessage, ElMessageBox} from "element-plus";

/**
 * 消息提示
 *
 * @param msg
 * @param type
 */
export function messageTip(msg, type) {
    ElMessage({
        showClose: true, //是否显示关闭按钮
        center: true, //文字是否居中
        duration: 3000, //显示时间,单位为毫秒
        message: msg, //提示的消息内容
        type: type, //消息类型:'success' | 'warning' | 'info' | 'error'
    })
}

/**
 * 获取存储在sessionStorage或者localStorage中的token(jwt)的名字
 *
 * @returns {string}
 */
export function getTokenName() {
    return "dlyk_token";
}

/**
 * 删除localStorage和sessionStorage中存储的token
 *
 */
export function removeToken() {
    window.sessionStorage.removeItem(getTokenName());
    window.localStorage.removeItem(getTokenName());
}

/**
 * 消息确认提示
 *
 * @param msg
 * @returns {Promise<MessageBoxData>}
 */
export function messageConfirm(msg) {
    return ElMessageBox.confirm(
        msg, //提示语
        '系统提醒', //提示的标题
        {
            confirmButtonText: '确定',
            cancelButtonText: '取消',
            type: 'warning',
        }
    )
}

/**
 * 封装返回函数
 *
 */
export function goBack(router) {
    router.go(-1);
}

/**
 * 获取token
 *
 * @returns {string}
 */
export function getToken() {
    let token = window.sessionStorage.getItem(getTokenName());
    if (!token) { //前面加了一个!,表示token不存在,token是空的,token没有值,这个意思
        token = window.localStorage.getItem(getTokenName());
    }
    if (token) { //表示token存在,token不是空的,token有值,这个意思
        return token;
    } else {
        messageConfirm("请求token为空,是否重新去登录?").then(() => { //用户点击“确定”按钮就会触发then函数
            //既然后端验证token未通过,那么前端的token肯定是有问题的,那没必要存储在浏览器中,直接删除一下
            removeToken();
            //跳到登录页
            window.location.href = "/";
        }).catch(() => { //用户点击“取消”按钮就会触发catch函数
            messageTip("取消去登录", "warning");
        })
    }
}

2. 后端

public class CacheUtils {
    /**
     * 带有缓存的查询工具方法
     *
     * @param cacheSelector
     * @param databaseSelector
     * @param cacheSave
     * @return
     * @param <T>
     */
    public static <T> T getCacheData(Supplier<T> cacheSelector, Supplier<T> databaseSelector, Consumer<T> cacheSave) {
        //从redis查询
        T data = cacheSelector.get();
        //如果redis没查到
        if (ObjectUtils.isEmpty(data)) {
            //从数据库查
            data = databaseSelector.get();
            //数据库查到了数据
            if (!ObjectUtils.isEmpty(data)) {
                //把数据放入redis
                cacheSave.accept(data);
            }
        }
        //返回数据
        return data;
    }
}
public class JSONUtils {
    //jackson这个jar包转json
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    
    /**
     * 把java对象转成json
     *
     * @param object
     * @return
     */
    public static String toJSON(Object object) {
        try {
             //把java对象转成json
            return OBJECT_MAPPER.writeValueAsString(object);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
    
    /**
     * 把json字符串转java对象
     *
     * @param json
     * @param clazz
     * @return
     * @param <T>
     */
    public static <T> T toBean(String json, Class<T> clazz) {
        try {
            return OBJECT_MAPPER.readValue(json, clazz);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}
public class JWTUtils {

    public static final String SECRET = "dY8300olWQ3345;1d<3w48";

    /**
     * 生成JWT (token)
     *
     */
    public static String createJWT(String userJSON) {
        //组装头数据
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "HS256");
        header.put("typ", "JWT");

        return JWT.create()
                //头部
                .withHeader(header)

                //负载
                .withClaim("user", userJSON)

                //签名
                .sign(Algorithm.HMAC256(SECRET));
    }

    /**
     * 验证JWT
     *
     * @param jwt 要验证的jwt的字符串
     */
    public static Boolean verifyJWT(String jwt) {
        try {
            // 使用秘钥创建一个JWT验证器对象
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();

            //验证JWT,如果没有抛出异常,说明验证通过,否则验证不通过
            jwtVerifier.verify(jwt);

            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }

    /**
     * 解析JWT的数据
     *
     */
    public static void parseJWT(String jwt) {
        try {
            // 使用秘钥创建一个JWT验证器对象
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();

            //验证JWT,得到一个解码后的jwt对象
            DecodedJWT decodedJWT = jwtVerifier.verify(jwt);

            //通过解码后的jwt对象,就可以获取里面的负载数据
            Claim nickClaim = decodedJWT.getClaim("nick");
            Claim ageClaim = decodedJWT.getClaim("age");
            Claim phoneClaim = decodedJWT.getClaim("phone");
            Claim birthDayClaim = decodedJWT.getClaim("birthDay");


            String nick = nickClaim.asString();
            int age = ageClaim.asInt();
            String phone = phoneClaim.asString();
            Date birthDay = birthDayClaim.asDate();
            
            System.out.println(nick + " -- " + age + " -- " + phone + " -- " + birthDay);
        } catch (TokenExpiredException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    public static TUser parseUserFromJWT(String jwt) {
        try {
            // 使用秘钥创建一个JWT验证器对象
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET)).build();

            //验证JWT,得到一个解码后的jwt对象
            DecodedJWT decodedJWT = jwtVerifier.verify(jwt);

            //通过解码后的jwt对象,就可以获取里面的负载数据
            Claim userClaim = decodedJWT.getClaim("user");

            String userJSON = userClaim.asString();

            return JSONUtils.toBean(userJSON, TUserDetailsImpl.class);
        } catch (TokenExpiredException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
}
public class ResponseUtils {

    /**
     * 使用response,把结果写出到前端
     *
     * @param response
     * @param result
     */
    public static void write(HttpServletResponse response, String result) {
        response.setContentType("application/json;charset=UTF-8");
        PrintWriter writer = null;
        try {
            writer = response.getWriter();
            writer.write(result);
            writer.flush();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if (writer != null) {
                writer.close();
            }
        }
    }
}
public class Constants {

    public static final String LOGIN_URI = "/api/login";

    //redis的key的命名规范: 项目名:模块名:功能名:唯一业务参数(比如用户id)
    public static final String REDIS_JWT_KEY = "dlyk:user:login:";

    //redis中负责人的key
    public static final String REDIS_OWNER_KEY = "dlyk:user:owner";

    //jwt过期时间7天
    public static final Long EXPIRE_TIME = 7 * 24 * 60 * 60L;

    //jwt过期时间30分钟
    public static final Long DEFAULT_EXPIRE_TIME = 30 * 60L;

    //分页时每页显示10条数据
    public static final int PAGE_SIZE = 10;

    //请求token的名称
    public static final String TOKEN_NAME = "Authorization";

    public static final String EMPTY = "";

    //导出Excel的接口路径
    public static final String EXPORT_EXCEL_URI = "/api/exportExcel";

    public static final String EXCEL_FILE_NAME = "客户信息数据";

}
@Getter
@RequiredArgsConstructor
@NoArgsConstructor
@AllArgsConstructor
public enum CodeEnum {

    OK(200, "成功"),

    FAIL(500, "失败"),

    TOKEN_IS_EMPTY(901, "请求Token参数为空"),

    TOKEN_IS_ERROR(902, "请求Token有误"),

    TOKEN_IS_EXPIRED(903, "请求Token已过期"),

    TOKEN_IS_NONE_MATCH(904, "请求Token不匹配"),

    USER_LOGOUT(200, "退出成功"),

    DATA_ACCESS_EXCEPTION(500,"数据库操作失败"),

    ACCESS_DENIED(500, "权限不足");

    //结果码
    private int code;

    //结果信息
    @NonNull
    private String msg;

}

3. Redis Server Impl配置

public interface RedisService {
    void setValue(String key, Object value);

    Object getValue(String key);

    Boolean removeValue(String key);

    Boolean expire(String key, Long timeOut, TimeUnit timeUnit);
}
@Service
public class RedisServiceImpl implements RedisService {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void setValue(String key, Object value) {
        System.out.println("redis设置了一个Value "+"key="+key+"  value="+value);
        redisTemplate.opsForValue().set(key, value);
    }

    @Override
    public Object getValue(String key) {
        System.out.println("redis请求得到 "+key+" 的value");
        return redisTemplate.opsForValue().get(key);
    }

    @Override
    public Boolean removeValue(String key) {
        System.out.println("redis请求删除 "+key);
        return redisTemplate.delete(key);
    }

    @Override
    public Boolean expire(String key, Long timeOut, TimeUnit timeUnit) {
        System.out.println("redis设置 "+key+" 的过期时间");
        return redisTemplate.expire(key, timeOut, timeUnit);
    }
}

4. Main Spring启动器配置

@MapperScan("com.peng.mapper")
@SpringBootApplication//CommandLineRunner:实现 run() 方法,在 Spring Boot 启动后执行一些初始化操作(如修改 Redis 配置)
public class Main implements CommandLineRunner {

    //定义一个静态 HashMap 作为缓存,可能用于存储临时数据,避免频繁访问数据库或 Redis
    public static final Map<String, Object> cacheMap = new HashMap<>();

    //RedisTemplate 是 Spring 提供的 Redis 访问工具,用于操作 Redis 数据库
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    public static void main(String[] args) {
        ConfigurableApplicationContext context = SpringApplication.run(Main.class, args);

        String[] beanNamesForType = context.getBeanNamesForType(Executor.class);

        //遍历 Spring 容器中所有 Executor(线程池) 的 Bean 名称
        for (String s : beanNamesForType) {
            System.out.println(s);
        }

        //applicationTaskExecutor 是 Spring 默认的异步任务执行器,通常是 线程池,用于异步任务执行
        Object obj = context.getBean("applicationTaskExecutor");
        System.out.println(obj);

    }

    @Override
    public void run(String... args) throws Exception {
        //springboot项目启动后,把redisTemplate这个Bean修改一下,修改一下key和value的序列化方式

        //设置key序列化 将 key 序列化为字符串
        redisTemplate.setKeySerializer(new StringRedisSerializer());

        //对象映射工具,Java对象 和 json对象进行相互转化
        ObjectMapper mapper = new ObjectMapper();

        //设置可见性
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

        //激活类型
        mapper.activateDefaultTyping(mapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.EVERYTHING);

        //设置value序列化
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<Object>(mapper, Object.class));

        //设置hashKey序列化
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());

        //设置haskValue序列化
        redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<Object>(mapper, Object.class));
    }

    //配置mybatis-plus插件
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); //分页
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());  //乐观锁
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());  //防全局修改和删除
        return interceptor;
    }
}

5. 配置异常处理

@RestControllerAdvice //aop。拦截标注了@RestController的controller中的所有方法
//@ControllerAdvice //aop。拦截标注了@Controller的controller中的所有方法
public class GlobalExceptionHandler {

    /**
     * 异常处理的方法(controller方法发生了异常,那么就使用该方法来处理)
     *
     * @return
     */
    @ExceptionHandler(value = Exception.class)
    public R handException(Exception e) {
        e.printStackTrace(); //在控制台打印异常信息
        return R.FAIL(e.getMessage());
    }

    /**
     * 异常的精确匹配,先精确匹配,匹配不到了,就找父类的异常处理
     *
     * @param e
     * @return
     */
    @ExceptionHandler(value = DataAccessException.class)
    public R handException(DataAccessException e) {
        e.printStackTrace(); //在控制台打印异常信息
        return R.FAIL(CodeEnum.DATA_ACCESS_EXCEPTION);
    }

    /**
     * 权限不足的异常处理
     *
     * @param e
     * @return
     */
    @ExceptionHandler(value = AccessDeniedException.class)
    public R handException(AccessDeniedException e) {
        e.printStackTrace(); //在控制台打印异常信息
        return R.FAIL(CodeEnum.ACCESS_DENIED);
    }
}

(二) 事务回滚(执行sql时会出现异常)

//凡是执行sql操作都要添加事务回滚
//添加事务注解 一般在Service层
//用mybatis-plus 有时在controller
@Transactional(rollbackFor = Exception.class)

四、Spring Security 实现 带有保护性的连接(不认证成功则无法调用后端api)

(一) 配置User父类以及UserDetailsImpl子类(UserDetails验证类)

@Getter
@Setter
public class TUserDetailsImpl extends TUser implements UserDetails, Serializable {
    private static final long serialVersionUID = 1L;

    // 由于JSON转换需要无参构造器,所以必须调用父类无参构造函数(在无@Data注解下)
    public TUserDetailsImpl() {
        super();
    }

    public TUserDetailsImpl(TUser user) {
        super(user);
    }

    @JsonIgnore
    public TUser getUser() {
        return new TUser(
                super.getId(),
                super.getLogin_act(),
                super.getLogin_pwd(),
                super.getName(),
                super.getPhone(),
                super.getEmail(),
                super.getAccount_no_expired(),
                super.getCredentials_no_expired(),
                super.getAccount_no_locked(),
                super.getAccount_enabled(),
                super.getCreate_time(),
                super.getCreate_by(),
                super.getEdit_time(),
                super.getEdit_by(),
                super.getLast_login_time()
        );
    }
    //-------------------------实现Spring Security中UserDetails接口的7个方法------------------------------
    private List<String> roleList;
    private List<String> permissionList;
    private List<TPermission> menuPermissionList;

    @JsonIgnore
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> list = new ArrayList<>();
        //角色
        if (!ObjectUtils.isEmpty(this.getRoleList()))  {
            this.getRoleList().forEach(role -> {
                list.add(new SimpleGrantedAuthority(role));
            });
        }
        if (!ObjectUtils.isEmpty(this.getPermissionList()))  {
            //权限标识符
            this.getPermissionList().forEach(permission -> {
                list.add(new SimpleGrantedAuthority(permission));
            });
        }
        return list;
    }

    @JsonIgnore
    @Override
    public String getPassword() {
        return super.getLogin_pwd();
    }

    @JsonIgnore
    @Override
    public String getUsername() {
        return super.getLogin_act();
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return super.getAccount_no_expired() == 1;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return super.getAccount_no_locked() == 1;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return super.getCredentials_no_expired() == 1;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return super.getAccount_enabled() == 1;
    }

}

(二) 登录者进行登录时调用数据库认证(无token认证)

1. 数据库验证

// @CrossOrigin Spring Security中可以配置跨域,无需@CrossOrigin注解
@RestController
public class UserController implements UserDetailsService {

    @Autowired
    private TUserService tUserService;

    //spring security 登录验证
    @Override
    @Transactional(rollbackFor = Exception.class)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<TUser> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(TUser::getLogin_act, username);
        TUser tUser = tUserService.getOne(wrapper);
        if (tUser == null) {
            throw new UsernameNotFoundException("登录账号不存在");
        }
        TUserDetailsImpl tUserDetailsImpl = new TUserDetailsImpl(tUser);

        //查询一下当前用户的角色
        List<TRole> tRoleList = tRoleMapper.selectByUserId(tUser.getId());
        //字符串的角色列表
        List<String> stringRoleList = new ArrayList<>();
        tRoleList.forEach(tRole -> {
            stringRoleList.add(tRole.getRole());
        });
        tUserDetailsImpl.setRoleList(stringRoleList); //设置用户的角色

        //查询一下该用户有哪些菜单权限
        List<TPermission> menuPermissionList = tPermissionMapper.selectMenuPermissionByUserId(tUserDetailsImpl.getId());
        tUserDetailsImpl.setMenuPermissionList(menuPermissionList);

        //查询一下该用户有哪些功能权限
        List<TPermission> buttonPermissionList = tPermissionMapper.selectButtonPermissionByUserId(tUserDetailsImpl.getId());
        List<String> stringPermissionList = new ArrayList<>();
        buttonPermissionList.forEach(tPermission -> {
            stringPermissionList.add(tPermission.getCode());//权限标识符
        });
        tUserDetailsImpl.setPermissionList(stringPermissionList);//设置用户的权限标识符

        return tUserDetailsImpl;
    }
}

进行自定义验证(MyAuthenticationSuccessHandler,MyAuthenticationFailureHandler)

Spring Security会在内部检查 返回的 TUserDetailsImpl 中的账号密码是否正确

@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private RedisService redisService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        //登录成功,执行该方法,在该方法中返回json给前端,就行了
        TUserDetailsImpl tUserDetailsImpl = (TUserDetailsImpl) authentication.getPrincipal();

        //1、生成jwt
        //把 对象 转成json作为负载数据放入jwt
        String userJSON = JSONUtils.toJSON(tUserDetailsImpl);
        String jwt = JWTUtils.createJWT(userJSON);

        //2、写入redis
        redisService.setValue(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId(), jwt);

        //3、设置jwt的过期时间(如果选择了记住我,过期时间是7天,否则是30分钟)
        String rememberMe = request.getParameter("rememberMe");
        if (Boolean.parseBoolean(rememberMe)) {
            redisService.expire(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId(), Constants.EXPIRE_TIME, TimeUnit.SECONDS);
        } else {
            redisService.expire(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId(), Constants.DEFAULT_EXPIRE_TIME, TimeUnit.SECONDS);
        }

        //登录成功的统一结果
        R result = R.OK(jwt);

        //把R对象转成json
        String resultJSON = JSONUtils.toJSON(result);

        //把R以json返回给前端
        ResponseUtils.write(response, resultJSON);
    }
}
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        //登录失败,执行该方法,在该方法中返回json给前端,就行了
        //登录失败的统一结果
        R result = R.FAIL(exception.getMessage());

        //把R对象转成json
        String resultJSON = JSONUtils.toJSON(result);

        //把R以json返回给前端
        ResponseUtils.write(response, resultJSON);
    }
}
@EnableMethodSecurity //开启方法级别的权限检查
@Configuration
public class SecurityConfig {
    @Resource
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
    @Resource
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
    @Resource
    private MyLogoutSuccessHandler myLogoutSuccessHandler;
    @Resource
    private MyAccessDeniedHandler myAccessDeniedHandler;
    
    @Bean //There is no PasswordEncoder mapped for the id "null"
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, CorsConfigurationSource configurationSource) throws Exception {
        //禁用跨站请求伪造
        return httpSecurity
                .formLogin( (formLogin) -> {
                    formLogin.loginProcessingUrl(Constants.LOGIN_URI) //登录处理地址,不需要写Controller
                            .usernameParameter("loginAct")//根据前端变量名
                            .passwordParameter("loginPwd")
                            //进行账号密码验证
                            .successHandler(myAuthenticationSuccessHandler)
                            .failureHandler(myAuthenticationFailureHandler);
                })

                .authorizeHttpRequests( (authorize) -> {
                    authorize.requestMatchers("/api/login").permitAll()
                            .anyRequest().authenticated(); //除/api/login外其它任何请求都需要登录后才能访问
                })

                .csrf(AbstractHttpConfigurer :: disable) //方法引用,禁用跨站请求伪造

                //支持跨域请求
                .cors( (cors) -> {
                    cors.configurationSource(configurationSource);
                })

                .sessionManagement( (session) -> {
                    //session创建策略
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 无session状态,也就是禁用session
                })

                //添加自定义的Filter
                .addFilterBefore(tokenVerifyFilter, LogoutFilter.class)

                //退出登录
                .logout((logout) -> {
                    logout.logoutUrl("/api/logout") //退出提交到该地址,该地址不需要我们写controller的,是框架处理
                    .logoutSuccessHandler(myLogoutSuccessHandler);
                })

                //这个是没有权限访问时触发
                .exceptionHandling((exceptionHandling) -> {
                    exceptionHandling.accessDeniedHandler(myAccessDeniedHandler);
                })

                .build();
    }

    //配置跨域
    @Bean
    public CorsConfigurationSource configurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*")); //允许任何来源,http://localhost:8081
        configuration.setAllowedMethods(Arrays.asList("*")); //允许任何请求方法,post、get、put、delete
        configuration.setAllowedHeaders(Arrays.asList("*")); //允许任何的请求头

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

(三) 调用除/api/login接口外时进行身份认证(验证token是否存在)

利用 token 来进行用户的认证

前端:token存储到 sessionStorage(会话存储空间)或localStorage(本地存储空间)

后端:利用 Redis 为token 进行缓存

1. token存储思路

  1. 后端在登陆成功时将 TUserDetailsImpl(验证信息类) 转换为 JSON 后再用 JWT 转换为 token
  2. 在 Redis 中添加 key:项目名:模块名:功能名:唯一业务参数(比如用户id) dlyk:user:login:1

value:token

  1. 根据 "rememberMe" 进行 Redis 的过期设定
  2. 前端接受 token 并根据 "rememberMe" 选择储存到sessionStorage还是localStorage当中

2. token验证思路

  1. 前端启动钩子(/api/login/info)渲染页面时发送信息,并利用 Axios 的请求拦截器进行请求头(headers)的配置,添加 'Authorization' :token
  2. SecurityConfig 中的 SecurityFilterChain 添加 检查token工具包(TokenVerifyFilter)
  3. 验证请求头中 'Authorization' 对应的 token 是否存在于 Redis 中
  4. 后端构建相对应的接口(/api/login/info)为前端进行返回信息
  5. 前端开启 Axios 的响应拦截器 响应后端返回的JSON数据,如果是验证失败 进行跳转登录页操作,

验证成功则放行

3. token工具包(TokenVerifyFilter)实现

    @Resource
    private TokenVerifyFilter tokenVerifyFilter;

    //添加自定义的Filter
    .addFilterBefore(tokenVerifyFilter, LogoutFilter.class)
//验证 token 的工具包
@Component
public class TokenVerifyFilter extends OncePerRequestFilter {

    @Resource
    private RedisService redisService;

    //spring boot框架的ioc容器中已经创建好了该线程池,可以注入直接使用
    @Resource
    private ThreadPoolTaskExecutor threadPoolTaskExecutor;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request.getRequestURI().equals(Constants.LOGIN_URI)) { //如果是登录请求,此时还没有生成jwt,那不需要对登录请求进行jwt验证
            //验证jwt通过了 ,让Filter链继续执行,也就是继续执行下一个Filter
            filterChain.doFilter(request, response);

        } else {
            String token = null;
            if (request.getRequestURI().equals(Constants.EXPORT_EXCEL_URI)) {
                //从请求路径的参数中获取token
                token = request.getParameter("Authorization");
            } else {
                //其他请求都是从请求头中获取token
                token = request.getHeader("Authorization");
            }

            if (!StringUtils.hasText(token)) {
                //token验证未通过的统一结果
                R result = R.FAIL(CodeEnum.TOKEN_IS_EMPTY);
                //把R对象转成json
                String resultJSON = JSONUtils.toJSON(result);
                //把R以json返回给前端
                ResponseUtils.write(response, resultJSON);
                return;
            }

            //验证token有没有被篡改过
            if (!JWTUtils.verifyJWT(token)) {
                //token验证未通过统一结果
                R result = R.FAIL(CodeEnum.TOKEN_IS_ERROR);
                //把R对象转成json
                String resultJSON = JSONUtils.toJSON(result);
                //把R以json返回给前端
                ResponseUtils.write(response, resultJSON);
                return;
            }

            //将前端传过来的 token 转换为 java
            TUserDetailsImpl tUserDetailsImpl = (TUserDetailsImpl) JWTUtils.parseUserFromJWT(token);

            String redisToken = (String) redisService.getValue(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId());

            //验证返回的 redis value 是否为空
            if (!StringUtils.hasText(redisToken)) {
                //token验证未通过统一结果
                R result = R.FAIL(CodeEnum.TOKEN_IS_EXPIRED);
                //把R对象转成json
                String resultJSON = JSONUtils.toJSON(result);
                //把R以json返回给前端
                ResponseUtils.write(response, resultJSON);
                return;
            }

            //验证 token 和 value 是否一致
            if (!token.equals(redisToken)) {
                //token验证未通过的统一结果
                R result = R.FAIL(CodeEnum.TOKEN_IS_NONE_MATCH);
                //把R对象转成json
                String resultJSON = JSONUtils.toJSON(result);
                //把R以json返回给前端
                ResponseUtils.write(response, resultJSON);
                return;
            }

            //jwt验证通过了,那么在spring security的上下文环境中要设置一下,设置当前这个人是登录过的,你后续不要再拦截他了
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(tUserDetailsImpl, tUserDetailsImpl.getLogin_pwd(), tUserDetailsImpl.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

            //刷新一下token(异步处理,new一个线程去执行)
            /*new Thread(() -> {
                //刷新token
                String rememberMe = request.getHeader("rememberMe");
                if (Boolean.parseBoolean(rememberMe)) {
                    redisService.expire(Constants.REDIS_JWT_KEY + tUser.getId(), Constants.EXPIRE_TIME, TimeUnit.SECONDS);
                } else {
                    redisService.expire(Constants.REDIS_JWT_KEY + tUser.getId(), Constants.DEFAULT_EXPIRE_TIME, TimeUnit.SECONDS);
                }
            }).start();*/

            //异步处理(更好的方式,使用线程池去执行)
            threadPoolTaskExecutor.execute(() -> {
                //刷新token
                String rememberMe = request.getHeader("rememberMe");
                if (Boolean.parseBoolean(rememberMe)) {
                    redisService.expire(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId(), Constants.EXPIRE_TIME, TimeUnit.SECONDS);
                } else {
                    redisService.expire(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId(), Constants.DEFAULT_EXPIRE_TIME, TimeUnit.SECONDS);
                }
            });

            //验证jwt通过了 ,让Filter链继续执行,也就是继续执行下一个Filter
            filterChain.doFilter(request, response);
        }
    }
}

(四) 设计用户免登录

//用户免登录
freeLogin(){
  let token = window.localStorage.getItem(getTokenName());
  if(token){
    doGet("/api/freeLogin",{}).then(resp => {
      if(resp.data.code === 200){
        //token 验证通过了,可以进行免登录
        window.location.href = "/dashboard";
      }
    })
  }
}

//后端因为 filter 工具包自动验证token,所以当进入这个接口时肯定是 验证成功的
@GetMapping("api/freeLogin")
    public R freeLogin() {
        return R.OK();
}

(五) 退出登录(删除前后端中的token并退出)

配置 MyLogoutSuccessHandler 直接在Spring Security中调用

//退出登录 退出成功时需要删除token
function logout(){
  doGet("/api/logout",{}).then( (resp) => {
    if(resp.data.code === 200){
      messageTip("退出成功","success");
      removeToken();
      window.location.href = "/";
    } else{
      messageConfirm("退出异常,是否要强制退出").then(()=>{
        //既然后端验证token未通过,那么前端的token肯定是有问题的,那没必要存储在浏览器中,直接删除一下
        removeToken();
        //跳到登录页
        window.location.href = "/";
      }).catch(()=>{
        messageTip("取消强制退出", "warning");
      })
    }
  })
}
  @Resource
  private MyLogoutSuccessHandler myLogoutSuccessHandler;

  //退出登录
  .logout((logout) -> {
      logout.logoutUrl("/api/logout") //退出提交到该地址,该地址不需要我们写controller的,是框架处理
      .logoutSuccessHandler(myLogoutSuccessHandler);
  })
@Component
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

    @Resource
    private RedisService redisService;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        //退出成功,执行该方法,在该方法中返回json给前端,就行了
        TUserDetailsImpl tUserDetailsImpl = (TUserDetailsImpl)authentication.getPrincipal();

        //删除 Redis 中的token
        redisService.removeValue(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId());

        //退出成功的统一结果
        R result = R.OK(CodeEnum.USER_LOGOUT);

        //把R对象转成json
        String resultJSON = JSONUtils.toJSON(result);

        //把R以json返回给前端
        ResponseUtils.write(response, resultJSON);
    }
}

(六) 进行token的续约

当5分钟我对页面不做任何操作,token的过期时间会减少5分钟

但是当我对页面进行点击后,token的过期时间会还原成初始值

//同样利用Spring Security的Filter(工具包)
//进行一个异步线程来更新token,当调用任何接口时,忽略前面的验证刷新token
    //异步处理(更好的方式,使用线程池去执行)
    threadPoolTaskExecutor.execute(() -> {
        //刷新token
        String rememberMe = request.getHeader("rememberMe");
        if (Boolean.parseBoolean(rememberMe)) {
            redisService.expire(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId(),
                                Constants.EXPIRE_TIME, TimeUnit.SECONDS);
        } else {
            redisService.expire(Constants.REDIS_JWT_KEY + tUserDetailsImpl.getId(),
                                Constants.DEFAULT_EXPIRE_TIME, TimeUnit.SECONDS);
        }
    });

五、账号管理功能开发(用户管理)

(一) 当点击用户管理时,以分页模式查找多个用户的信息

//获取用户信息
//进行分页查询,传入当前页数的值 current : current 是一种 k :v 的简写方式
//但ES6 语法过后 可以直接 {current} 进行键值对映射,除非kv不相同才会用以前的方法
function getData(current){
  doGet("/api/users", {current:current}).then((resp)=>{
    console.log(resp);
    if(resp.data.code === 200){
      userList.value = resp.data.data.records;
      pageSize.value = resp.data.data.size;
      total.value = resp.data.data.total;
    }
  })
}
//Mapper层
public interface TUserMapper extends BaseMapper<TUser> {
    @Select("SELECT * FROM t_user")
    IPage<TUser> selectUserPage(Page<TUser> page);
}

//Controller直接调用Mapper(懒)
//以分页形式进行多个用户查询
@GetMapping("/api/users")   //required = false 表示参数可传不穿,反之必须穿
public R userPage(@RequestParam(value="current",required = false) Integer current) {
    //当没传入参数时,初始化为1
    if(current == null) {current = 1;}
    IPage<TUser> tUserIPage = tUserMapper.selectUserPage(new Page<TUser>(current, Constants.PAGE_SIZE));
    return R.OK(tUserIPage);
}

(二) 实现分页

// 使用 v-model 表单绑定 当前页current-page
<el-pagination
      background
      layout="prev, pager, next"
      :page-size="pageSize"
      :total="total"
      v-model:current-page="currentPage"
/>

//监视器 精准监视 currentPage 是否发生了改变      
watch(currentPage,(newPage)=>{
  console.log("当前页变化:",newPage);
  toPage(newPage);
})

//这个 watch 监视器传来的新值
function toPage(current){
  getData(current);
}

(三) 查看账号详情

//详情按钮
function view(id){
  console.log("跳转",id,"的详情页面");
  router.push("/dashboard/user/" + id);
}
//子路由 路径前面不能以 / 开头
children:[
    {
        path:'user/:id',
        component:()=>import('../view/UserDetailView.vue'),
    }
]
//获取指定 id 用户的信息
@GetMapping("/api/user/{id}")
public R userDetail(@PathVariable(value = "id") Integer id) {
    TUser tUser = tUserService.getById(id);
    return R.OK(tUser);
}

(四) 新增 | 修改一个用户

<el-dialog v-model="userDialogVisible" :title="userQuery.id > 0 ? '编辑用户' : '新增用户'" width="55%" center draggable>
  <el-form ref="userRefForm" :model="userQuery" label-width="110px" :rules="userRules">
    <el-form-item label="账号" prop="login_act">
      <el-input v-model="userQuery.login_act" />
    </el-form-item>

    <el-form-item label="密码" v-if="userQuery.id > 0"><!--编辑-->
      <el-input type="password" v-model="userQuery.login_pwd" />
    </el-form-item>

    <el-form-item label="密码" prop="loginPwd" v-else><!--新增-->
      <el-input type="password" v-model="userQuery.login_pwd" />
    </el-form-item>

    <el-form-item label="姓名" prop="name">
      <el-input v-model="userQuery.name" />
    </el-form-item>

    <el-form-item label="手机" prop="phone">
      <el-input v-model="userQuery.phone" />
    </el-form-item>

    <el-form-item label="邮箱" prop="email">
      <el-input v-model="userQuery.email" />
    </el-form-item>

    <el-form-item label="账号未过期" prop="account_no_expired">
      <el-select v-model="userQuery.account_no_expired" placeholder="请选择">
        <el-option
            v-for="item in options"
            :key="item.value"
            :label="item.label"
            :value="item.value"/>
      </el-select>
    </el-form-item>

    <el-form-item label="密码未过期" prop="credentials_no_expired">
      <el-select v-model="userQuery.credentials_no_expired" placeholder="请选择">
        <el-option
            v-for="item in options"
            :key="item.value"
            :label="item.label"
            :value="item.value"/>
      </el-select>
    </el-form-item>

    <el-form-item label="账号未锁定" prop="account_no_locked">
      <el-select v-model="userQuery.account_no_locked" placeholder="请选择">
        <el-option
            v-for="item in options"
            :key="item.value"
            :label="item.label"
            :value="item.value"/>
      </el-select>
    </el-form-item>

    <el-form-item label="账号是否启用" prop="account_enabled">
      <el-select v-model="userQuery.account_enabled" placeholder="请选择">
        <el-option
            v-for="item in options"
            :key="item.value"
            :label="item.label"
            :value="item.value"/>
      </el-select>
    </el-form-item>

  </el-form>

  <template #footer>
    <span class="dialog-footer">
      <el-button @click="userDialogVisible = false">关闭</el-button>
      <el-button type="primary" @click="userSubmit">提 交</el-button>
    </span>
  </template>

</el-dialog>

//用户的弹窗是否弹出来,true就弹出来,false就不弹出来
let userDialogVisible = ref(false);
//添加用户中的值
let userQuery = ref({});
//指定添加用户的规则
let userRules = {
  login_act : [
    {required: true, message: '请输入登录账号', trigger: 'blur'}
  ],
  login_pwd : [
    {required: true, message: '请输入登录密码', trigger: 'blur'},
    {min: 6, max: 16, message: '登录密码长度为6-16位', trigger: 'blur'}
  ],
  name: [
    {required: true, message: '请输入姓名', trigger: 'blur'},
    {pattern: /^[\u4E00-\u9FA5]{1,5}$/, message: '姓名必须是中文', trigger: 'blur'}
  ],
  phone: [
    {required: true, message: '请输入手机号码', trigger: 'blur'},
    {pattern: /^1[3-9]\d{9}$/, message: '格式为 1:1 2:[3-9] 11位', trigger: 'blur'}
  ],
  email: [
    {required: true, message: '请输入邮箱', trigger: 'blur'},
    {pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/, message: '邮箱格式有误', trigger: 'blur'}
  ],
  account_no_expired: [
    {required: true, message: '请选择账号是否未过期', trigger: 'blur'},
  ],
  credentials_no_expired: [
    {required: true, message: '请选择密码是否未过期', trigger: 'blur'},
  ],
  account_no_locked: [
    {required: true, message: '请选择账号是否未未锁定', trigger: 'blur'},
  ],
  account_enabled: [
    {required: true, message: '请选择账号是否可用', trigger: 'blur'},
  ]
}
//下拉选项数组
let options = [
  {label : '是', value : 1},
  {label : '否', value : 0}
]
//绑定添加用户表单,用于后续验证规则的实现
let userRefForm = ref();
//实现子组件单独的渲染,为子组件添加一个隐藏或者展现的变量值
<el-main>
  <!--利用子路由,只改变中间页面的内容-->
  <router-view v-if="isRouterAlive"/>
</el-main>

//刷新页面
const reload = async () => {
  isRouterAlive.value = false; //右侧内容隐藏
  await nextTick();
  isRouterAlive.value = true;
}

//父组件推方法|变量
//注意 provide的方法需要在provide之前定义好
provide("reload",reload);

//获取父亲方法|或变量
const reload = inject("reload");
//添加用户
function add(){
  userDialogVisible.value = true;
  userQuery.value = {};
}
//编辑按钮
function edit(id){
  userDialogVisible.value = true;
  loadUser(id);
}
//编辑用户时,先获取用户信息
const loadUser = (id) => {
  doGet("/api/user/"+id,{}).then((resp)=>{
    if(resp.data.code === 200){
      userQuery.value = resp.data.data;
      userQuery.value.login_pwd = null;
    }
  })
}
//新用户 | 编辑用户 信息提交
function userSubmit(){
  let formData = new FormData();
  for(let field in userQuery.value){
    formData.append(field, userQuery.value[field]);
  }
  //实现 表单的验证规则
  userRefForm.value.validate( (isValid) => {
    if (isValid) {
      if(userQuery.value.id > 0){ //进入编辑
        doPut("/api/user", formData).then(resp => {/*新增*/
          if (resp.data.code === 200) {
            messageTip("提交成功", "success");
            reload();
          } else {
            messageTip("提交失败", "error");
          }
        })
      } else{ //进入新增
        doPost("/api/user", formData).then(resp => {/*新增*/
          if (resp.data.code === 200) {
            messageTip("提交成功", "success");
            reload();
          } else {
            messageTip("提交失败", "error");
          }
        })
      }
      userDialogVisible = false;
    }
  })
}
//新增用户
@PostMapping("api/user")
public R addUser(@ModelAttribute UserQuery userQuery, @RequestHeader(value = "Authorization") String token) {
    //token 是获取创建人的id
    userQuery.setToken(token);
    int save = tUserService.saveUser(userQuery);
    return save >= 1 ? R.OK() : R.FAIL();
}
@Override
public int saveUser(UserQuery userQuery) {
    //将TUser当中符合userQuery属性copy过来
    TUser tUser = new TUser();
    BeanUtils.copyProperties(userQuery, tUser);
    
    //将密码加密
    tUser.setLogin_pwd(passwordEncoder.encode(userQuery.getLogin_pwd()));
    //创建时间
    tUser.setCreate_time(new Date());
    
    //利用 token 获取创建人信息
    //登录人的id
    Integer loginUserId = JWTUtils.parseUserFromJWT(userQuery.getToken()).getId();
    tUser.setCreate_by(loginUserId); //创建人
    
    return tUserMapper.insert(tUser);
}

//编辑用户
@PutMapping("api/user")
public R updateUser(@ModelAttribute UserQuery userQuery, @RequestHeader(value = "Authorization") String token) {
    //token 是获取创建人的id
    userQuery.setToken(token);
    int update = tUserService.updateUser(userQuery);
    return update >= 1 ? R.OK() : R.FAIL();
}
@Override
public int updateUser(UserQuery userQuery) {
    //将TUser当中符合userQuery属性copy过来
    TUser tUser = new TUser();
    BeanUtils.copyProperties(userQuery, tUser);
    
    //有密码则将密码加密,否则不管
    //前端传回的是null
    if(tUser.getLogin_pwd() != null){
        tUser.setLogin_pwd(passwordEncoder.encode(userQuery.getLogin_pwd()));
    }
    
    //编辑时间
    tUser.setEdit_time(new Date());
    
    //利用 token 获取编辑人信息
    //登录人的id
    Integer loginUserId = JWTUtils.parseUserFromJWT(userQuery.getToken()).getId();
    tUser.setEdit_by(loginUserId); //创建人
    
    LambdaQueryWrapper<TUser> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(TUser::getId,tUser.getId());
    
    return tUserMapper.update(tUser,queryWrapper);
}

(五) 删除一个用户

//传递登录者
provide("loadUser",user);
//接受登录者
const loadUser = inject("loadUser")
//删除按钮
function del(id){
  if(id === loadUser.value.id) {
    messageTip("不能删除自身用户", "error");
  }else{
    //做出提示
    messageConfirm("您确定要删除该用户吗").then(() => {
      doDelete("/api/user/" + id, {}).then((resp) => {
        if (resp.data.code === 200) {
          messageTip("用户删除成功", "success");
          reload();
        } else {
          messageTip("删除失败,原因:" + resp.data.msg, "error");
        }
      })
    }).catch(() => {
      messageTip("取消删除该用户", "warning");
    })
  }
}
//删除用户
@Transactional(rollbackFor = Exception.class)
@DeleteMapping("api/user/{id}")
public R deleteUser(@PathVariable(value = "id") Integer id) {
    Boolean delete = tUserService.removeById(id);
    return delete ? R.OK() : R.FAIL();
}

(六) 批量删除

//删除按钮
function del(id){
  if(id === loadUser.value.id) {
    messageTip("不能删除自身用户", "error");
  }else{
    //做出提示
    messageConfirm("您确定要删除该用户吗").then(() => {
      doDelete("/api/user/" + id, {}).then((resp) => {
        if (resp.data.code === 200) {
          messageTip("用户删除成功", "success");
          reload();
        } else {
          messageTip("删除失败,原因:" + resp.data.msg, "error");
        }
      })
    }).catch(() => {
      messageTip("取消删除该用户", "warning");
    })
  }
}
//批量删除事件
const batchDel = () => {
  if(idList.value.length <= 0){
    //提示一下
    messageTip("请选择要删除的数据", "warning");
    return;
  }
  let ok = true;
  idList.value.forEach((data) => {
    if(loadUser.value.id === data) {
      ok = false;
      //forEach 用 return 跳出
      return;
    }
  })
  if(ok === false) {
    messageTip("存在自身用户,不能删除", "error");
  }else{
    //做出提示
    messageConfirm("您确定批量删除这些用户吗").then(() => {
      // idList 原本是一个数组,直接以params传过去如下
      //                   /api/user/?ids=ids[]1,ids[],2 错误的url格式,无法传输
      //        所以要转换为String  /api/user/?ids=1,2
      doDelete("/api/user", {ids : idList.value.join(",")}).then((resp) => {
        if (resp.data.code === 200) {
          messageTip("用户删除成功", "success");
          reload();
        } else {
          messageTip("删除失败,原因:" + resp.data.msg, "error");
        }
      })
    }).catch(() => {
      messageTip("取消批量删除用户", "warning");
    })
  }
}
//批量删除用户
@Transactional(rollbackFor = Exception.class)
@DeleteMapping(value = "/api/user")
public R batchDelUser(@RequestParam(value = "ids") String ids) {
    //ids = "1,3,5,6,7,11,15";
    //解析String 转换为 int 数组
    List<Integer> idList = Arrays.stream(ids.split(","))
    .map(Integer::parseInt)
    .collect(Collectors.toList());

    boolean batchDel = tUserService.removeByIds(idList);

    return batchDel ? R.OK() : R.FAIL();
}

六、添加权限设定展示

(一) 思路

当查询 sql 语句是,观察当前登录人是否为 ("admin管理者"),如果不是管理着,
则通过切面注解的方式为 sql 语句的尾部增加语句,类似与mybatis-plus的分页插件一样,
在尾部添加 limit 语句

(二) 首先获取登录人的角色 | 菜单权限 | 功能权限

//spring security 登录验证
@Override
@Transactional(rollbackFor = Exception.class)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    LambdaQueryWrapper<TUser> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(TUser::getLogin_act, username);
    TUser tUser = tUserService.getOne(wrapper);
    if (tUser == null) {
        throw new UsernameNotFoundException("登录账号不存在");
    }
    TUserDetailsImpl tUserDetailsImpl = new TUserDetailsImpl(tUser);

    //查询一下当前用户的角色
    List<TRole> tRoleList = tRoleMapper.selectByUserId(tUser.getId());
    //字符串的角色列表
    List<String> stringRoleList = new ArrayList<>();
    tRoleList.forEach(tRole -> {
        stringRoleList.add(tRole.getRole());
    });
    tUserDetailsImpl.setRoleList(stringRoleList); //设置用户的角色

    //查询一下该用户有哪些菜单权限
    List<TPermission> menuPermissionList = tPermissionMapper.selectMenuPermissionByUserId(tUserDetailsImpl.getId());
    tUserDetailsImpl.setMenuPermissionList(menuPermissionList);

    //查询一下该用户有哪些功能权限
    List<TPermission> buttonPermissionList = tPermissionMapper.selectButtonPermissionByUserId(tUserDetailsImpl.getId());
    List<String> stringPermissionList = new ArrayList<>();
    buttonPermissionList.forEach(tPermission -> {
        stringPermissionList.add(tPermission.getCode());//权限标识符
    });
    tUserDetailsImpl.setPermissionList(stringPermissionList);//设置用户的权限标识符

    return tUserDetailsImpl;
}
List<TRole> selectByUserId(@Param("userId") Integer userId);
<!--查询用户角色-->
<select id="selectByUserId" parameterType="java.lang.Integer" resultMap="BaseResultMap">
  SELECT
  tr.*
  FROM
  t_role tr
  LEFT JOIN t_user_role tur ON tr.id = tur.role_id
  where tur.user_id = #{userId, jdbcType=INTEGER}
</select>

List<TPermission> selectMenuPermissionByUserId(@Param("userId")Integer userId);
<!--查询菜单权限-->
<select id="selectMenuPermissionByUserId" parameterType="java.lang.Integer" resultMap="PermissionResultMap">
    SELECT
        tp.*,
        childTp.id cid, childTp.name cname, childTp.url curl, childTp.icon cicon
    FROM
        t_permission tp
            LEFT JOIN t_permission childTp ON tp.id = childTp.parent_id
            LEFT JOIN t_role_permission trp ON tp.id = trp.permission_id
            LEFT JOIN t_role tr ON trp.role_id = tr.id
            LEFT JOIN t_user_role tur ON tr.id = tur.role_id
    WHERE
        tur.user_id = #{userId, jdbcType=INTEGER}
      AND tp.type = 'menu'
      and childTp.type = 'menu'
</select>

List<TPermission> selectButtonPermissionByUserId(@Param("userId")Integer userId);
<!--功能权限-->
<select id="selectButtonPermissionByUserId" parameterType="java.lang.Integer" resultMap="PermissionResultMap">
    SELECT
        tp.*
    FROM
        t_permission tp
            LEFT JOIN t_role_permission trp ON tp.id = trp.permission_id
            LEFT JOIN t_role tr ON trp.role_id = tr.id
            LEFT JOIN t_user_role tur ON tr.id = tur.role_id
    WHERE
        tur.user_id = #{userId, jdbcType=INTEGER}
      AND tp.type = 'button'
</select>

(三) 利用aop切片注解方法

@Target(ElementType.METHOD)//这个注解只能用于方法上
@Retention(RetentionPolicy.RUNTIME)//该注解在运行时可用,适用于AOP拦截处理
@Documented//让 Javadoc 生成文档时包含该注解的信息
public @interface DataScope {

    //要在sql语句的末尾添加一个过滤条件
    //select * from t_user (管理员)
    //select * from t_user tu where tu.id = 2 (普通用户:于嫣)

    //select * from t_activity (管理员)
    //select * from t_activity ta where ta.owner_id = 2 (普通用户:于嫣)

    //表的别名
    public String tableAlias() default "";

    //表的字段名
    public String tableField() default "";
}
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class BaseQuery {
    //jwt
    private String token; 
    //数据权限的SQL过滤条件: tu.id = 2 或者 ta.owner_id = 2
    private String filterSQL; 
}
@Aspect
@Component
public class DataScopeAspect {
    //切入点
    @Pointcut(value = "@annotation(com.peng.commons.DataScope)")
        private void pointCut() {
    }

    //环绕
    @Around(value = "pointCut()")
    //连接点
    public Object process(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();

        //拿到方法上的注解
        DataScope dataScope = methodSignature.getMethod().getDeclaredAnnotation(DataScope.class);

        String tableAlias = dataScope.tableAlias();
        String tableField = dataScope.tableField();

        //在spring web容器中,可以拿到当前请求的request对象
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        String token = request.getHeader(Constants.TOKEN_NAME);
        //从token中解析出该用户是管理员还是普通用户
        TUserDetailsImpl tUserDetails = (TUserDetailsImpl) JWTUtils.parseUserFromJWT(token);

        //拿到用户的角色
        List<String> roleList = tUserDetails.getRoleList();

        if (!roleList.contains("admin")) {//不包含admin角色,只查当前用户自己的数据,否则查所有数据
            //注意规范!!! 拿方法的第一个参数
            Object params = joinPoint.getArgs()[0]; 
            if (params instanceof BaseQuery) {//判断拿到的第一个参数是不是BaseQuery类型
                BaseQuery query = (BaseQuery)params;
                //编辑 sql 语句
                //select * from t_user tu where tu.id = 2 (普通用户:于嫣)
                query.setFilterSQL(" and " + tableAlias + "." + tableField + " = " + tUserDetails.getId());
            }
        }

        System.out.println("目标方法执行之前....");
        Object result = joinPoint.proceed();
        System.out.println("目标方法执行之后....");
        return result;
    }
}

(四) 实现管理员 | 非管理员的分页查询

//以分页形式进行多个用户查询
@Transactional(rollbackFor = Exception.class)
@GetMapping("/api/users")   //required = false 表示参数可传不穿,反之必须穿
public R userPage(@RequestParam(value="current",required = false) Integer current) {
    //当没传入参数时,初始化为1
    if(current == null) {current = 1;}
    IPage<TUser> tUserIPage = tUserMapper
            .selectUserPage(BaseQuery.builder().build(),
                            new Page<TUser>(current, Constants.PAGE_SIZE));
    return R.OK(tUserIPage);
}

@Mapper
public interface TUserMapper extends BaseMapper<TUser> {
    @DataScope(tableAlias = "tu",tableField = "id")
    IPage<TUser> selectUserPage(@Param("query") BaseQuery query, Page<TUser> page);
}
<!--@DataScope(tableAlias = "tu",tableField = "id")-->
<!--
  当不是管理员时sql 语法 变为
  select * from t_user tu where tu.id = ${userId} + 分页插件
-->
<!-- 使用 <where> 可以避免 "and" 的问题-->
<select id="selectUserPage" resultMap="BaseResultMap">
    select * from t_user tu
    <where>
        ${query.filterSQL}
    </where>
</select>

七、实现带Redis缓存的查询

//获取负责人
@GetMapping(value = "/api/owner")
public R owner() {
    List<TUser> ownerList = tUserService.getOwnerList();
    return R.OK(ownerList);
}
public class CacheUtils {
    //cacheSelector     缓存生产者  (从中间件中获取缓存)
    //databaseSelector  数据库生产者(从数据库中获取信息)
    //cacheSave			缓存消费者(如何处理缓存)
    public static <T> T getCacheData(Supplier<T> cacheSelector,
                                     Supplier<T> databaseSelector,
                                     Consumer<T> cacheSave) {
        //从redis查询
        T data = cacheSelector.get();
        //如果redis没查到
        if (ObjectUtils.isEmpty(data)) {
            //从数据库查
            data = databaseSelector.get();
            //数据库查到了数据
            if (!ObjectUtils.isEmpty(data)) {
                //把数据放入redis
                cacheSave.accept(data);
            }
        }
        //返回数据
        return data;
    }
}
//有多种类型,这里只管理list类型
@Component
public class RedisManager {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

//  从 Redis List 数据结构中取出 key 对应的所有值。
//  opsForList().range(key, 0, -1):
//  opsForList():操作 Redis 的 List 数据结构。
//  range(key, 0, -1):获取该 List 所有元素(从索引 0 到 -1 代表最后一个元素)。
    public Object getValue(String key) {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

//  将 Collection<T> 里的数据全部存入 Redis List,存储在 key 对应的列表中。
//  data.toArray(t):将 Collection<T> 转换为 Object[] 数组。
//  opsForList().leftPushAll(key, t):批量将数据从 左侧 推入 Redis List,新数据排在最前面。
    public <T> Object setValue(String key,  Collection<T> data) {
        Object[] t  = new Object[data.size()];
        data.toArray(t);
        return redisTemplate.opsForList().leftPushAll(key, t);
    }
}
@Override
    public List<TUser> getOwnerList() {
        //1、从redis查询
        //2、redis查不到,就从数据库查询,并且把数据放入redis(5分钟过期)
        return CacheUtils.getCacheData(() -> {
            //生产,从缓存redis查询数据
            return (List<TUser>)redisManager.getValue(Constants.REDIS_OWNER_KEY);
            },
            () -> {
                //生产,从mysql查询数据
                return tUserMapper.selectByOwner();
            },
            (t) -> {
                //消费,把数据放入缓存redis
                redisManager.setValue(Constants.REDIS_OWNER_KEY, t);
            }
        );
    }

八、为静态缓存添加信息(cacheMap)

@EnableScheduling //开启定时任务
@Component
public class DataTask {

    @Resource
    private TDicTypeService tDicTypeService;

    @Resource
    private TProductService tProductService;

    @Resource
    private TActivityService tActivityService;

//调度的意思
// project.task.delay 是一个 配置文件中的值
// (通常在 application.yml 或 application.properties 中定义)
    
// 设置任务的时区,确保任务按照中国标准时间(CST)执行,避免跨时区问题。
    
// timeUnit = TimeUnit.MILLISECONDS
// 指定 fixedDelayString 和 initialDelay 的单位是 毫秒(MS)。

// initialDelay = 1000
// 任务在应用启动后,延迟 1 秒(1000 毫秒)再开始执行第一
@Scheduled(fixedDelayString = "${project.task.delay}", zone = "Asia/Shanghai", timeUnit = TimeUnit.MILLISECONDS, initialDelay  = 1000)
    public void task() {
        //定时任务要执行的业务逻辑代码
        System.out.println("定时任务业务逻辑执行......" + new Date());
    
        List<TDicType> tDicTypeList = tDicTypeService.loadAllDicData();
    
        tDicTypeList.forEach(tDicType -> {
            String typeCode = tDicType.getType_code();
            List<TDicValue> tDicValueList = tDicType.getDicValueList();
            Main.cacheMap.put(typeCode, tDicValueList);
        });
    
        //查询所有在售产品
        List<TProduct> tProductList = tProductService.getAllOnSaleProduct();
        Main.cacheMap.put(DicEnum.PRODUCT.getCode(), tProductList);
    
        //查询所有正在进行的市场活动
        List<TActivity> tActivityList = tActivityService.getOngoingActivity();
        Main.cacheMap.put(DicEnum.ACTIVITY.getCode(), tActivityList);
    }
}
//获得 (List<TDicValue>)
List<TDicValue> tDicValueList = 
            (List<TDicValue>) Main.cacheMap.get(DicEnum.APPELLATION.getCode());
//获得 (List<TProduct>)
List<TProduct> tProduct = 
             (List<TProduct>) Main.cacheMap.get(DicEnum.PRODUCT.getCode());

九、使用EasyExcel进行Excel文件导入

(一) 将Excel中的中文字段转换为序号字段(查找数据库)

数据库将这些字段都通过外键约束绑定了其他的表
当字段数据无法从其他表中查到时,就无法添加
查找成功:将中文属性返回为对应表中的 id 字段
查找失败:将字段 赋值 为 -1 ,表示不符合外键约束

1. 实体类配置Excel转换器对象

@TableName(value ="t_clue")
@Data
public class TClue implements Serializable {
    private static final long serialVersionUID = 1L;
    //无需转换器的字段已省略

    @ExcelProperty(value = "称呼", converter = AppellationConverter.class)
    @TableField(value = "appellation")
    private Integer appellation;

    @ExcelProperty(value = "是否贷款", converter = NeedLoanConverter.class)
    @TableField(value = "need_loan")
    private Integer need_loan;

    @ExcelProperty(value = "意向状态", converter = IntentionStateConverter.class)
    @TableField(value = "intention_state")
    private Integer intention_state;

    @ExcelProperty(value = "意向产品", converter = IntentionProductConverter.class)
    @TableField(value = "intention_product")
    private Integer intention_product;

    @ExcelProperty(value = "线索状态", converter = StateConverter.class)
    @TableField(value = "state")
    private Integer state;

    @ExcelProperty(value = "线索来源", converter = SourceConverter.class)
    @TableField(value = "source")
    private Integer source;
}

2. Converter转换器配置

/**
 * 称呼的转换器
 *
 * Excel中的 “先生”  ----> Java类中是 18
 * Excel中的 “女士”  ----> Java类中是 41
 */
public class AppellationConverter implements Converter<Integer> {

    /**
     * 把Excel中的数据转换为Java中的数据
     * 也就是Excel中的 “先生”  ----> Java类中是 18
     *
     * @param cellData
     * @param contentProperty
     * @param globalConfiguration
     * @return
     * @throws Exception
     */
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        //cellData是Excel中读取到的数据,是“先生”、“女士”
        String cellAppellationName = cellData.getStringValue();

        List<TDicValue> tDicValueList = (List<TDicValue>) Main.cacheMap.get(DicEnum.APPELLATION.getCode());
        for (TDicValue tDicValue : tDicValueList) {
            Integer id  = tDicValue.getId();
            String name = tDicValue.getType_value();

            if (cellAppellationName.equals(name)) {
                return id;
            }
        }
        return -1;
    }
}
/**
 * 意向产品的转换器
 *
 * Excel中的比亚迪e2   -->  java中的 2
 * 秦PLUS EV  -->  7
 */
public class IntentionProductConverter implements Converter<Integer> {

    /**
     * 把Excel中的数据转换为Java中的数据
     * 也就是Excel中的 “比亚迪e2”  ----> Java类中是 2
     *
     * @param cellData
     * @param contentProperty
     * @param globalConfiguration
     * @return
     * @throws Exception
     */
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        //cellData是Excel中读取到的数据,是“比亚迪e2”、“秦PLUS EV”
        String cellIntentionProductName = cellData.getStringValue();

        List<TProduct> tDicValueList = (List<TProduct>) Main.cacheMap.get(DicEnum.PRODUCT.getCode());
        for (TProduct tProduct : tDicValueList) {
            Integer id  = tProduct.getId();
            String name = tProduct.getName();

            if (cellIntentionProductName.equals(name)) {
                return id;
            }
        }
        return -1;
    }
}
/**
 * 意向状态转换器
 */
public class IntentionStateConverter implements Converter<Integer> {

    /**
     * 把Excel中的数据转换为Java中的数据
     * 也就是Excel中的 “意向不明”  ----> Java类中是 48
     *
     * @param cellData
     * @param contentProperty
     * @param globalConfiguration
     * @return
     * @throws Exception
     */
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        //cellData是Excel中读取到的数据,是“意向不明”、“有意向”
        String cellIntentionStateName = cellData.getStringValue();

        List<TDicValue> tDicValueList = (List<TDicValue>) Main.cacheMap.get(DicEnum.INTENTIONSTATE.getCode());
        for (TDicValue tDicValue : tDicValueList) {
            Integer id  = tDicValue.getId();
            String name = tDicValue.getType_value();

            if (cellIntentionStateName.equals(name)) {
                return id;
            }
        }
        return -1;
    }
}
/**
 * 是否需要贷款的转换器
 *
 */
public class NeedLoanConverter implements Converter<Integer> {

    /**
     * 把Excel中的数据转换为Java中的数据
     * 也就是Excel中的 “需要”  ----> Java类中是 49
     *
     * @param cellData
     * @param contentProperty
     * @param globalConfiguration
     * @return
     * @throws Exception
     */
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        //cellData是Excel中读取到的数据,是“需要”、“不需要”
        String cellNeedLoanName = cellData.getStringValue();

        List<TDicValue> tDicValueList = (List<TDicValue>) Main.cacheMap.get(DicEnum.NEEDLOAN.getCode());
        for (TDicValue tDicValue : tDicValueList) {
            Integer id  = tDicValue.getId();
            String name = tDicValue.getType_value();

            if (cellNeedLoanName.equals(name)) {
                return id;
            }
        }
        return -1;
    }
}
/**
 * 线索来源的转换器
 *
 * Excel中的 “车展会”  ----> Java类中是 3
 * Excel中的 “网络广告”  ----> Java类中是 16
 */
public class SourceConverter implements Converter<Integer> {

    /**
     * 把Excel中的数据转换为Java中的数据
     * 也就是Excel中的 “车展会”  ----> Java类中是 3
     *
     * @param cellData
     * @param contentProperty
     * @param globalConfiguration
     * @return
     * @throws Exception
     */
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        //cellData是Excel中读取到的数据,是“车展会”、“网络广告”
        String cellSourceName = cellData.getStringValue();

        List<TDicValue> tDicValueList = (List<TDicValue>) Main.cacheMap.get(DicEnum.SOURCE.getCode());
        for (TDicValue tDicValue : tDicValueList) {
            Integer id  = tDicValue.getId();
            String name = tDicValue.getType_value();

            if (cellSourceName.equals(name)) {
                return id;
            }
        }
        return -1;
    }
}
/**
 * 线索状态转换器
 *
 */
public class StateConverter implements Converter<Integer> {

    /**
     * 把Excel中的数据转换为Java中的数据
     * 也就是Excel中的 “已联系”  ----> Java类中是 27
     *
     * @param cellData
     * @param contentProperty
     * @param globalConfiguration
     * @return
     * @throws Exception
     */
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        //cellData是Excel中读取到的数据,是“已联系”、“未联系”
        String cellStateName = cellData.getStringValue();

        List<TDicValue> tDicValueList = (List<TDicValue>) Main.cacheMap.get(DicEnum.STATE.getCode());
        for (TDicValue tDicValue : tDicValueList) {
            Integer id  = tDicValue.getId();
            String name = tDicValue.getType_value();

            if (cellStateName.equals(name)) {
                return id;
            }
        }
        return -1;
    }
}

(二) 前端

<!--活动备注记录的弹窗-->
<el-dialog v-model="importExcelDialogVisible" title="导入线索Excel" width="55%" center draggable>
  <el-upload
      ref="uploadRef"
      method="post"
      :http-request="uploadFile"
      :auto-upload="false">

    <template #trigger>
      <el-button type="primary">选择Excel文件</el-button>
    </template>
    仅支持后缀名为.xls或.xlsx的文件

    <template #tip>
      <div class="fileTip">
        重要提示:
        <ul>
          <li>上传仅支持后缀名为.xls或.xlsx的文件;</li>
          <li>给定Excel文件的第一行将视为字段名;</li>
          <li>请确认您的文件大小不超过50MB;</li>
          <li>日期值以文本形式保存,必须符合yyyy-MM-dd格式;</li>
          <li>日期时间以文本形式保存,必须符合yyyy-MM-dd HH:mm:ss的格式;</li>
        </ul>
      </div>
    </template>
  </el-upload>

  <template #footer>
    <span class="dialog-footer">
      <el-button @click="importExcelDialogVisible = false">关 闭</el-button>
      <el-button type="primary" @click="submitExcel">导 入</el-button>
    </span>
  </template>

</el-dialog>

//提交
const submitExcel = () => {
  uploadRef.value.submit() //submit执行:http-request绑定的uploadFile方法
}
const uploadFile = (param) => {
  console.log(param);
  let fileObj = param.file // 相当于input里取得的files
  let formData = new FormData() // new一个FormData对象
  formData.append('file', fileObj)//文件对象,前面file是参数名,后面fileObj是参数值
  doPost("/api/importExcel", formData).then(resp => {
    console.log(resp);
    if (resp.data.code === 200) {
      //Excel导入成功,提示一下
      messageTip("导入成功", "success");
      //清理一下上传的文件
      uploadRef.value.clearFiles();
      //页面刷新
      reload();
    } else {
      //Excel导入失败
      messageTip("导入失败", "error");
    }
  })
}

(三) 后端api接口实现

//file的名字要和前端formData里面的名字相同,否则接收不到
@PostMapping(value = "/api/importExcel")
public R importExcel(MultipartFile file, 
                     @RequestHeader(value = "Authorization") String token)
                     throws IOException { 
    tClueService.importExcel(file.getInputStream(), token);
    return R.OK();
}

@Override
public void importExcel(InputStream inputStream, String token) {
//链式编程,3个参数, 第一个参数是要读取的Excel文件
//					第二个参数是Excel模板类
//					第三个参数是文件读取的监听器
    EasyExcel.read(inputStream, 
                   TClue.class, 
                   new UploadDataListener(tClueMapper, token))
            .sheet()
            .doRead();
}

(四) 监听器UploadDataListener的实现

@Slf4j
public class UploadDataListener implements ReadListener<TClue> {

    /**
     * 每隔100条存储数据库,实际使用中可以100条,然后清理list ,方便内存回收
     */
    private static final int BATCH_COUNT = 100;

    //缓存List
    private List<TClue> cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);

    /**
     * 假设这个是一个DAO,当然有业务逻辑这个也可以是一个service。当然如果不用存储这个对象没用。
     */
    private TClueMapper tClueMapper;

    private String token;

    /**
     * 如果使用了spring,请使用这个构造方法。每次创建Listener的时候需要把spring管理的类传进来
     *
     * @param tClueMapper
     */
    public UploadDataListener(TClueMapper tClueMapper, String token) {
        this.tClueMapper = tClueMapper;
        this.token = token;
    }

    /**
     * 这个每一条数据解析都会来调用
     *
     * @param tClue    one row value. It is same as {@link AnalysisContext#readRowHolder()}
     * @param context
     */
    @Override
    public void invoke(TClue tClue, AnalysisContext context) {
        log.info("读取到的每一条数据:{}", JSONUtils.toJSON(tClue));

        //给读到的clue对象设置创建时间(导入时间)和创建人(导入人)
        tClue.setCreate_time(new Date());
        tClue.setCreate_by(JWTUtils.parseUserFromJWT(token).getId());

        //每读取一行,就把该数据放入到一个缓存List中
        cachedDataList.add(tClue);

        // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
        if (cachedDataList.size() >= BATCH_COUNT) {
            //把缓存list中的数据写入到数据库
            saveData();

            //存储完成清空list
            cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
        }
    }

    /**
     * 所有数据解析完成了 都会来调用
     *
     * @param context
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 这里也要保存数据,确保最后遗留的数据也存储到数据库
        saveData();
        log.info("所有数据解析完成!");
    }

    /**
     * 加上存储数据库
     */
    private void saveData() {
        log.info("{}条数据,开始存储数据库!", cachedDataList.size());
        tClueMapper.saveClue(cachedDataList);
        log.info("存储数据库成功!");
    }
}

(五) 保存至数据库功能实现

<!--void saveClue(List<TClue> cachedDataList);-->
<insert id="saveClue" keyColumn="id" keyProperty="id" parameterType="com.peng.model.TClue" useGeneratedKeys="true">
  insert into t_clue (owner_id, activity_id, full_name,
  appellation, phone, weixin,
  qq, email, age, job,
  year_income, address, need_loan,
  intention_state, intention_product, `state`,
  `source`, description, next_contact_time,
  create_time, create_by, edit_time,
  edit_by)
  values
  <foreach collection="cachedDataList" item="tClue"  separator="," >
    (#{tClue.owner_id,jdbcType=INTEGER}, #{tClue.activity_id,jdbcType=INTEGER}, #{tClue.full_name,jdbcType=VARCHAR},
    #{tClue.appellation,jdbcType=INTEGER}, #{tClue.phone,jdbcType=VARCHAR}, #{tClue.weixin,jdbcType=VARCHAR},
    #{tClue.qq,jdbcType=VARCHAR}, #{tClue.email,jdbcType=VARCHAR}, #{tClue.age,jdbcType=INTEGER}, #{tClue.job,jdbcType=VARCHAR},
    #{tClue.year_income,jdbcType=DECIMAL}, #{tClue.address,jdbcType=VARCHAR}, #{tClue.need_loan,jdbcType=INTEGER},
    #{tClue.intention_state,jdbcType=INTEGER}, #{tClue.intention_product,jdbcType=INTEGER}, #{tClue.state,jdbcType=INTEGER},
    #{tClue.source,jdbcType=INTEGER}, #{tClue.description,jdbcType=VARCHAR}, #{tClue.next_contact_time,jdbcType=TIMESTAMP},
    #{tClue.create_time,jdbcType=TIMESTAMP}, #{tClue.create_by,jdbcType=INTEGER}, #{tClue.edit_time,jdbcType=TIMESTAMP},
    #{tClue.edit_by,jdbcType=INTEGER})
  </foreach>
</insert>

解释<insert id="saveClue" keyColumn="id" keyProperty="id" 
    parameterType="com.peng.model.TClue" useGeneratedKeys="true">
keyColumn:指定数据库中自增主键 keyProperty:指定java对象中的主键属性名
useGeneratedKeys:启动数据库的主键自增
  
解释<foreach collection="cachedDataList" item="tClue"  separator="," >
以tClue为对象遍历参数cachedDataList,values中的参数以 “,” 进行分隔

十、使用EasyExcel进行Excel文件导入

(一) 前端

//导出操作 ids 是customer id序号列表
const exportExcel = (ids) => {
  let token = getToken();
  //1、向后端发送一个请求 (我们来实现)
  let iframe = document.createElement("iframe")
  if (ids) {
    iframe.src = instance.defaults.baseURL + "/api/exportExcel?Authorization="+token + "&ids="+ ids;
  } else {
    iframe.src = instance.defaults.baseURL + "/api/exportExcel?Authorization="+token
  }
  iframe.style.display = "none"; //iframe隐藏,页面上不要显示出来
  document.body.appendChild(iframe);
  //2、后端查询数据库的数据,把数据写入Excel,然后把Excel以IO流的方式输出到前端浏览器(我们来实现)
  //3、前端浏览器弹出一个下载框进行文件下载(浏览器本身实现的,不需要我们去实现)
}

(二) 后端验证请求是否正确

String token = null;
if (request.getRequestURI().equals(Constants.EXPORT_EXCEL_URI)) {
    //从请求路径的参数中获取token
    token = request.getParameter("Authorization");
} else {
    //其他请求都是从请求头中获取token
    token = request.getHeader("Authorization");
}

(三) 后端api实现

@GetMapping(value = "/api/exportExcel")
    public void exportExcel(HttpServletResponse response, @RequestParam(value = "ids", required = false) String ids) throws IOException {

        //要想让浏览器弹出下载框,你后端要设置一下响应头信息
        response.setContentType("application/octet-stream");
        response.setCharacterEncoding("utf-8");
        response.setHeader("Content-disposition", "attachment;filename=" + URLEncoder.encode(Constants.EXCEL_FILE_NAME+System.currentTimeMillis(), StandardCharsets.UTF_8) + ".xlsx");

        //2、后端查询数据库的数据,把数据写入Excel,然后把Excel以IO流的方式输出到前端浏览器(我们来实现)

        List<Integer> idList = StringUtils.hasText(ids)
                                ? Arrays.stream(ids.split(",")).map(Integer::parseInt).collect(Collectors.toList())
                                : new ArrayList<>();

        List<CustomerExcel> dataList = tCustomerService.getCustomerByExcel(idList);

        EasyExcel.write(response.getOutputStream(), CustomerExcel.class)
                .sheet()
                .doWrite(dataList);
    }

(四) 构建CustomerExcel实体类表明Excel表头

@Data
public class CustomerExcel {
    /**
     * Excel表头的字段如下:
     *
     * 所属人	所属活动	客户姓名	客户称呼	客户手机	客户微信	客户QQ
     * 客户邮箱	客户年龄	客户职业	客户年收入
     * 客户住址	是否贷款	客户产品	客户来源	客户描述	下次联系时间
     */
    @ExcelProperty(value = "所属人")
    private String owner_name;
    @ExcelProperty(value = "所属活动")
    private String activity_name;
    @ExcelProperty(value = "客户姓名")
    private String full_name;
    @ExcelProperty(value = "客户称呼")
    private String appellation_name;
    @ExcelProperty(value = "客户手机")
    private String phone;
    @ExcelProperty(value = "客户微信")
    private String weixin;
    @ExcelProperty(value = "客户QQ")
    private String qq;
    @ExcelProperty(value = "客户邮箱")
    private String email;
    @ExcelProperty(value = "客户年龄")
    private int age;
    @ExcelProperty(value = "客户职业")
    private String job;
    @ExcelProperty(value = "客户年收入")
    private BigDecimal year_income;
    @ExcelProperty(value = "客户住址")
    private String address;
    @ExcelProperty(value = "是否贷款")
    private String need_load_name;
    @ExcelProperty(value = "客户产品")
    private String product_name;
    @ExcelProperty(value = "客户来源")
    private String source_name;
    @ExcelProperty(value = "客户描述")
    private String description;
    @ExcelProperty(value = "下次联系时间")
    private Date next_contact_time;
}

(五) Service方法实现

@Override
public List<CustomerExcel> getCustomerByExcel(List<Integer> idList) {
    List<CustomerExcel> customerExcelList = new ArrayList<>();

    List<TCustomer> tCustomerList = tCustomerMapper.selectCustomerByExcel(idList);

    //把从数据库查询出来的List<TCustomer>数据,转换为 List<CustomerExcel>数据
    tCustomerList.forEach(tCustomer -> {
        CustomerExcel customerExcel = new CustomerExcel();

        //需要一个一个设置,没有办法,因为没法使用BeanUtils复制
        customerExcel.setOwner_name(ObjectUtils.isEmpty(tCustomer.getOwnerDO()) ? Constants.EMPTY : tCustomer.getOwnerDO().getName());
        customerExcel.setActivity_name(ObjectUtils.isEmpty(tCustomer.getActivityDO()) ? Constants.EMPTY : tCustomer.getActivityDO().getName());
        customerExcel.setFull_name(tCustomer.getClueDO().getFull_name());
        customerExcel.setAppellation_name(ObjectUtils.isEmpty(tCustomer.getAppellationDO()) ? Constants.EMPTY : tCustomer.getAppellationDO().getType_value());
        customerExcel.setPhone(tCustomer.getClueDO().getPhone());
        customerExcel.setWeixin(tCustomer.getClueDO().getWeixin());
        customerExcel.setQq(tCustomer.getClueDO().getQq());
        customerExcel.setEmail(tCustomer.getClueDO().getEmail());
        customerExcel.setAge(tCustomer.getClueDO().getAge());
        customerExcel.setJob(tCustomer.getClueDO().getJob());
        customerExcel.setYear_income(tCustomer.getClueDO().getYear_income());
        customerExcel.setAddress(tCustomer.getClueDO().getAddress());
        customerExcel.setNeed_load_name(ObjectUtils.isEmpty(tCustomer.getNeedLoanDO()) ? Constants.EMPTY : tCustomer.getNeedLoanDO().getType_value());
        customerExcel.setProduct_name(ObjectUtils.isEmpty(tCustomer.getIntentionProductDO()) ? Constants.EMPTY : tCustomer.getIntentionProductDO().getName());
        customerExcel.setSource_name(ObjectUtils.isEmpty(tCustomer.getSourceDO()) ? Constants.EMPTY : tCustomer.getSourceDO().getType_value());
        customerExcel.setDescription(tCustomer.getDescription());
        customerExcel.setNext_contact_time(tCustomer.getNext_contact_time());

        customerExcelList.add(customerExcel);
    });

    return customerExcelList;
}

(六) Mapper接口实现

<!--    List<TCustomer> selectCustomerByExcel(@Param(value="idList")List<Integer> idList);-->
<select id="selectCustomerByExcel" parameterType="java.util.List" resultMap="CustomerResultMap">
  select
  tct.*,
  tc.id clueId, tc.full_name, tc.phone, tc.weixin, tc.qq, tc.email, tc.age, tc.job, tc.year_income, tc.address,
  tu1.id ownerId, tu1.name ownerName,
  ta.id activityId, ta.name activityName,
  tdv.id appellationId, tdv.type_value appellationName,
  tdv2.id needLoanId, tdv2.type_value needLoanName,
  tdv3.id intentionStateId, tdv3.type_value intentionStateName,
  tdv4.id stateId, tdv4.type_value stateName,
  tdv5.id sourceId, tdv5.type_value sourceName,
  tp.id intentionProductId, tp.name intentionProductName
  from t_customer tct left join t_clue tc on tct.clue_id = tc.id
  left join t_user tu1 on tc.owner_id = tu1.id
  left join t_activity ta on tc.activity_id = ta.id
  left join t_dic_value tdv on tc.appellation = tdv.id
  left join t_dic_value tdv2 on tc.need_loan = tdv2.id
  left join t_dic_value tdv3 on tc.intention_state = tdv3.id
  left join t_dic_value tdv4 on tc.state = tdv4.id
  left join t_dic_value tdv5 on tc.source = tdv5.id
  left join t_product tp on tct.product = tp.id
  <where>
    <if test="idList != null and idList.size() > 0">
      and tct.id in

      <foreach collection="idList" item="id" open="(" close=")" separator=",">
        #{id}
      </foreach>
    </if>
  </where>
</select>

十一、设置权限功能

(一) UserDetails读取菜单权限 | 功能权限

//查询一下当前用户的角色
List<TRole> tRoleList = tRoleMapper.selectByUserId(tUser.getId());
//字符串的角色列表
List<String> stringRoleList = new ArrayList<>();
tRoleList.forEach(tRole -> {
    stringRoleList.add(tRole.getRole());
});
tUserDetailsImpl.setRoleList(stringRoleList); //设置用户的角色

//查询一下该用户有哪些菜单权限
List<TPermission> menuPermissionList = tPermissionMapper.selectMenuPermissionByUserId(tUserDetailsImpl.getId());
tUserDetailsImpl.setMenuPermissionList(menuPermissionList);

//查询一下该用户有哪些功能权限
List<TPermission> buttonPermissionList = tPermissionMapper.selectButtonPermissionByUserId(tUserDetailsImpl.getId());
List<String> stringPermissionList = new ArrayList<>();
buttonPermissionList.forEach(tPermission -> {
    stringPermissionList.add(tPermission.getCode());//权限标识符
});
tUserDetailsImpl.setPermissionList(stringPermissionList);//设置用户的权限标识符

(二) 实体类TUserDetailsImpl导入权限

//角色List
private List<String> roleList;
//权限标识符List
private List<String> permissionList;
//菜单的List
private List<TPermission> menuPermissionList;

@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> list = new ArrayList<>();
    //角色
    if (!ObjectUtils.isEmpty(this.getRoleList()))  {
        this.getRoleList().forEach(role -> {
            list.add(new SimpleGrantedAuthority(role));
        });
    }
    if (!ObjectUtils.isEmpty(this.getPermissionList()))  {
        //权限标识符
        this.getPermissionList().forEach(permission -> {
            list.add(new SimpleGrantedAuthority(permission));
        });
    }
    return list;
}

(三) 前端以菜单权限进行展示

TUserDetailsImpl tUserDetailsimpl = (TUserDetailsImpl) authentication.getPrincipal();
user.value =  后端返回的(TUserDetailsImpl)

<el-sub-menu :index="index" v-for="(menuPermission, index) in user.menuPermissionList" :key="menuPermission.id">
  <template #title>
    <el-icon><component :is="menuPermission.icon"></component></el-icon>
    <span> {{menuPermission.name}} </span>
  </template>
  <el-menu-item v-for="subPermission in menuPermission.subPermissionList" :key="subPermission.id" :index="subPermission.url">
    <el-icon><component :is="subPermission.icon"></component></el-icon>
    {{subPermission.name}}
  </el-menu-item>
</el-sub-menu>

(四) 为后端api方法设置 权限限制

//删除用户
@PreAuthorize(value = "hasAuthority('user:delete')")
@Transactional(rollbackFor = Exception.class)
@DeleteMapping("api/user/{id}")
public R deleteUser(@PathVariable(value = "id") Integer id) {
    Boolean delete = tUserService.removeById(id);
    return delete ? R.OK() : R.FAIL();
}

十二、利用ECharts(前端图表库)建图

官网:echarts.apache.org/zh/index.ht…

import * as echarts from 'echarts';

(一) 后端将需要的值都返回值前端

(二) 前端利用ECharts建表

1. 为图表提供 id 以及 大小设定

<!-- 销售漏斗图,为 ECharts 准备一个定义了宽高的 DOM -->
<div id="saleFunnelChart" style="width: 48%; height:350px; margin:10px; float: left;"> 
  图渲染在此处 
</div>

<!-- 线索来源饼图,为 ECharts 准备一个定义了宽高的 DOM -->
<div id="sourcePieChart" style="width: 48%; height:350px; margin:10px; float: left;"> 
  图渲染在此处 
</div>

2. 构建销售漏斗图

const loadSaleFunnelChart = () => {
  doGet("/api/saleFunnel/data",{}).then((resp)=>{
    console.log(resp);
    if(resp.data.code === 200){
      let saleFunelData = resp.data.data;

      //1、拿到页面上渲染图表的dom元素
      var chartDom = document.getElementById('saleFunnelChart');
      //2、使用echarts组件对dom进行初始化,得到一个空白的图表对象
      var myChart = echarts.init(chartDom);
      //3、配置可选项(查看文档-->配置项手册)
      var option = {
        //标题组件,包含主标题和副标题。
        title: {
          //主标题文本,支持使用 \n 换行。
          text: '销售漏斗图',
          //title 组件离容器左侧的距离。
          left: 'center',
          //title 组件离容器上侧的距离。
          top: 'bottom'
        },
        //提示框组件。
        tooltip: {
          //触发类型。item:数据项图形触发,主要在散点图,饼图等无类目轴的图表中使用。
          trigger: 'item',
          //提示框浮层内容格式器,漏斗图: {a}(系列名称),{b}(数据项名称),{c}(数值), {d}(百分比)
          formatter: '{a} <br/>{b} : {c}'
        },
        //工具栏
        toolbox: {
          //各工具配置项
          feature: {
            //数据视图工具,可以展现当前图表所用的数据,编辑后可以动态更新。
            dataView: {
              //是否不可编辑(只读)。
              readOnly: false
            },
            //配置项还原。
            restore: {},
            //保存为图片。
            saveAsImage: {}
          }
        },
        //图例组件
        legend: {
          data: ['线索', '客户', '交易', '成交']
        },
        //系列
        series: [
          {
            //系列名称
            name: '销售漏斗数据统计',
            //图表的类型,funnel代表漏斗图
            type: 'funnel',
            //漏斗图组件离容器左侧的距离。
            left: '10%',
            //漏斗图组件离容器上侧的距离。
            top: 60,
            //漏斗图组件离容器下侧的距离。
            bottom: 60,
            width: '80%',
            min: 0,
            max: 100,
            minSize: '0%',
            maxSize: '100%',
            sort: 'descending',
            gap: 2,
            label: {
              show: true,
              position: 'inside'
            },
            labelLine: {
              length: 10,
              lineStyle: {
                width: 1,
                type: 'solid'
              }
            },
            itemStyle: {
              borderColor: '#fff',
              borderWidth: 1
            },
            emphasis: {
              label: {
                fontSize: 50
              }
            },
            //数据项(系列中的数据内容数组, 数组项可以为单个数值,也可以是对象值)
            /*data: [
              { value: 20, name: '成交' },
              { value: 60, name: '交易' },
              { value: 80, name: '客户' },
              { value: 100, name: '线索' }
            ]*/
            data : saleFunelData
          }
        ]
      };
      //4、如果配置了可选项,就把可选项设置到空白的图表对象中
      option && myChart.setOption(option);
    }
  })
}

3. 加载线索来源饼图

const loadSourcePieChart = () => {
  doGet("/api/sourcePie/data",{}).then((resp)=>{
    console.log(resp);
    if(resp.data.code === 200){
      let sourcePieData = resp.data.data;

      //1、拿到页面上渲染图表的dom元素
      var chartDom = document.getElementById('sourcePieChart');
      //2、使用echarts组件对dom进行初始化,得到一个空白的图表对象
      var myChart = echarts.init(chartDom);
      //3、配置可选项(查看文档-->配置项手册)
      var option = {
        title: {
          text: '线索来源统计',
          //title 组件离容器左侧的距离。
          left: 'center',
          //title 组件离容器上侧的距离。
          top: 'bottom'
        },
        tooltip: {
          trigger: 'item'
        },
        //图例
        legend: {
          orient: 'horizontal',
          left: 'center'
        },
        //系列
        series: [
          {
            name: '线索来源统计',
            type: 'pie',
            //饼图的半径
            radius: '80%',
            //数据项
            /*data: [
              { value: 1048, name: 'Search Engine' },
              { value: 735, name: 'Direct' },
              { value: 580, name: 'Email' },
              { value: 484, name: 'Union Ads' },
              { value: 300, name: 'Video Ads' }
            ],*/
            data : sourcePieData,
            //高亮状态的扇区和标签样式。
            emphasis: {
              itemStyle: {
                //图形阴影的模糊大小
                shadowBlur: 10,
                //阴影水平方向上的偏移距离。
                shadowOffsetX: 0,
                shadowColor: 'rgba(0, 0, 0, 0.5)'
              }
            }
          }
        ]
      };
      //4、如果配置了可选项,就把可选项设置到空白的图表对象中
      option && myChart.setOption(option);
    }
  })

十三、使用Minio完成头像功能

(一) 用户提交头像

//由于该项目是 spring security 进行保护的
//所有由 element-plus 组件构建的url接口需要定义好请求头才能被后端接收
el-upload
    ref="uploadImageRef"
    class="avatar-uploader"
    :action="instance.getUri()+'/api/user/image?id='+id"
    // 定义好请求头
    :headers="uploadHeaders"
    :show-file-list="true"
    :auto-upload="false"
    name = "file"
>
  <img v-if="imageUrl" :src="imageUrl" class="avatar" />
  <el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>

const submitUpload = () => {
  if(id == loadUser.value.id) {
    uploadImageRef.value.submit();
  } else {
    messageTip("当前登录者只能为自己提交头像","error");
  }
}

(二) 用户获取头像

//显示头像
<el-avatar :src="imageUrl" />

//获取用户头像
const loadLoginImage = () => {
  doGet("/api/userImage",{}).then((resp)=>{
    console.log(resp);
    if(resp.data.code === 200){
      imageUrl.value = resp.data.data;
    }
  })
}

(三) 后端实现

//为用户添加头像
    @PostMapping(value="/api/user/image")
    public R image(@RequestParam(value = "file") MultipartFile file, @RequestParam(value="id")Integer id ,@RequestHeader(value = "Authorization") String token) throws Exception{

        Integer user_id = JWTUtils.parseUserFromJWT(token).getId();

        minioClient.putObject(PutObjectArgs.builder()
                .bucket(Constants.MINIO_BUCKET_NAME)
                .object("dlyk_image_user_id=" + user_id + ".jpg")
                .stream(file.getInputStream(),
                        file.getSize(),
                        -1)
                .build());

        TMinio tMinio = new TMinio();

        tMinio.setUser_id(user_id);
        tMinio.setBucket(Constants.MINIO_BUCKET_NAME);
        tMinio.setObject("dlyk_image_user_id=" + user_id + ".jpg");

        tMinioMapper.insert(tMinio);
        return R.OK();
    }



    //获得当前用户头像
    @GetMapping(value="/api/userImage")
    public R userImage(Authentication authentication) throws Exception {
        TUserDetailsImpl tUserDetailsImpl = (TUserDetailsImpl)authentication.getPrincipal();

        LambdaQueryWrapper<TMinio> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(TMinio::getUser_id,tUserDetailsImpl.getId());

        TMinio userMinio = tMinioMapper.selectOne(queryWrapper);
        String userBucket;
        String userObject;

        if(!Objects.isNull(userMinio)){
            userBucket = userMinio.getBucket();
            userObject = userMinio.getObject();
        } else {
            userBucket = Constants.MINIO_BUCKET_NAME;
            userObject = "baseImage.jpg";
        }
        String url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                .method(Method.GET)
                .bucket(userBucket)
                .object(userObject)
                .build());
        System.out.println(url);
        return R.OK(url);
    }

十四、通过Nginx部署

(一) 注意事项

当后端也部署到 linux 时,注意 前端 httpRequest 请求的URL记得更改

(二) 前端

1. 打包前端项目 dist 为打包后的文件

npm run build

2. 将 dist放到 /usr/local/nginx/conf/html 目录下 并更改为 dlyk 项目名

3. 配置nginx.conf文件 启动静态文件

server {
		listen 80;
		server_name  localhost;
		location / {

      //加载 html 下的 dlyk 文件
			root   html/dlyk;
			try_files $uri $uri/ /index.html; //这一行不配置可能会报错
      
      index  index.html index.htm;
		}
}

(三) 后端