EMS Developer Guide

Stone大约 63 分钟

EMS Developer Guide

后端

技术栈:

创建项目

创建 Spring Boot 项目:

image-20240728163703757

指定 Spring Boot 版本为 3.3.2,并添加基本依赖:

image-20240728181520867

删除项目下的 .mvnmvnwmvnw.cmdHELP.md,并删除 src/main/resources 下的 statictemplates,最终目录结构如下:

image-20240728194757497

启用 Build Project automatically,配置热部署:

image-20240728185203136

修改配置文件 src/main/resources/application.propertiessrc/main/resources/application.yml,并添加以下配置:

spring:
  application:
    name: ems
  devtools:
    restart:
      enabled: true
      additional-exclude: static/**
      additional-paths: src/main/java

通用包

src/main/java/net/stonecoding/ems 下创建通用包 common,用于存放全局的实体,工具及配置等。

常量类

src/main/java/net/stonecoding/ems/common/constant 包下创建常量类,包括 HTTP 状态码类:

package net.stonecoding.ems.common.constant;

/**
 * Http 状态码
 */
public class ResultCode {
    /**
     * 操作成功
     */
    public static final int OK = 200;

    /**
     * 对象创建成功
     */
    public static final int CREATED = 201;

    /**
     * 请求已经被接受
     */
    public static final int ACCEPTED = 202;

    /**
     * 操作已经执行成功,但是没有返回数据
     */
    public static final int NO_CONTENT = 204;

    /**
     * 资源已被移除
     */
    public static final int MOVED_PERMANENTLY = 301;

    /**
     * 重定向
     */
    public static final int SEE_OTHER = 303;

    /**
     * 资源没有被修改
     */
    public static final int NOT_MODIFIED = 304;

    /**
     * 参数列表错误(缺少,格式不匹配)
     */
    public static final int BAD_REQUEST = 400;

    /**
     * 未授权
     */
    public static final int UNAUTHORIZED = 401;

    /**
     * 访问受限,授权过期
     */
    public static final int FORBIDDEN = 403;

    /**
     * 资源,服务未找到
     */
    public static final int NOT_FOUND = 404;

    /**
     * 不允许的 Http 方法
     */
    public static final int METHOD_NOT_ALLOWED = 405;

    /**
     * 资源冲突,或者资源被锁
     */
    public static final int CONFLICT = 409;

    /**
     * 不支持的数据,媒体类型
     */
    public static final int UNSUPPORTED_MEDIA_TYPE = 415;

    /**
     * 系统内部错误
     */
    public static final int INTERNAL_SERVER_ERROR = 500;

    /**
     * 接口未实现
     */
    public static final int NOT_IMPLEMENTED = 501;
}

以及 HTTP 返回消息类:

package net.stonecoding.ems.common.constant;

/**
 * Http 返回消息
 */
public class ResultMessage {

    public static final String USERNAME_OR_PASSWORD_WRONG = "用户名或密码错误";

    public static final String AUTHENTICATION_SUCCESS = "认证成功";

    public static final String AUTHENTICATION_FAILED = "认证失败";

    public static final String PLEASE_LOGIN = "请登录";

    public static final String RUN_SUCCESS = "执行成功";

    public static final String RUN_FAILED = "执行失败";

    public static final String SELECT_SUCCESS = "查询成功";

    public static final String SELECT_FAILED = "查询失败";

    public static final String INSERT_SUCCESS = "添加成功";

    public static final String INSERT_FAILED = "添加失败";

    public static final String UPDATE_SUCCESS = "修改成功";

    public static final String UPDATE_FAILED = "修改失败";

    public static final String DELETE_SUCCESS = "删除成功";

    public static final String DELETE_FAILED = "删除失败";
}

响应类

src/main/java/net/stonecoding/ems/common/domain 包下创建响应数据的封装类,包括统一返回结果类:

package net.stonecoding.ems.common.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import net.stonecoding.ems.common.constant.ResultMessage;
import net.stonecoding.ems.common.constant.ResultCode;

import java.io.Serializable;

/**
 * 统一返回结果
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> implements Serializable {

    private Integer code;

    private String message;

    private T data;
    
    private String exception;

    public static <T> Result<T> success() {
        return new Result<>(ResultCode.OK, ResultMessage.RUN_SUCCESS, null, null);
    }

    public static <T> Result<T> success(T data) {
        return new Result<>(ResultCode.OK, ResultMessage.RUN_SUCCESS, data, null);
    }

    public static <T> Result<T> success(Integer code, String message, T data) {
        return new Result<>(code, message, data, null);
    }

    public static <T> Result<T> error(String exception) {
        return new Result<>(ResultCode.BAD_REQUEST, ResultMessage.RUN_FAILED, null, exception);
    }

    public static <T> Result<T> error(T data, String exception) {
        return new Result<>(ResultCode.BAD_REQUEST, ResultMessage.RUN_FAILED, data, exception);
    }

    public static <T> Result<T> error(Integer code, String message, T data, String exception) {
        return new Result<>(code, message, data, exception);
    }
}

异常类

src/main/java/net/stonecoding/ems/common/exception 包下创建全局异常处理类:

package net.stonecoding.ems.common.exception;

import net.stonecoding.ems.common.domain.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理
 */
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public Result<String> handleException(Exception e) {
        e.printStackTrace();
        return Result.error(e.getMessage());
    }
}

数据库

参考 MySQL Administrationopen in new window 安装 MySQL,然后创建数据库和用户,并授权,脚本如下:

CREATE DATABASE ems DEFAULT CHARACTER SET utf8mb4;

CREATE USER 'ems'@'%' IDENTIFIED BY 'Abcd@1234';

GRANT ALL PRIVILEGES ON ems.* TO 'ems'@'%';

在数据库中创建用户,角色和权限表,脚本如下:

use ems;

CREATE TABLE `user` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(255) NOT NULL COMMENT '员工工号',
  `password` varchar(255) DEFAULT NULL COMMENT '登录密码',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0为禁用,1为启用',
  `create_by` varchar(255) DEFAULT NULL COMMENT '创建者', 
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(255) DEFAULT NULL COMMENT '修改者', 
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

CREATE TABLE `role` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `name` varchar(255) NOT NULL COMMENT '角色名称',
  `description` varchar(255) DEFAULT NULL COMMENT '角色描述',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0为禁用,1为启用',
  `create_by` varchar(255) DEFAULT NULL COMMENT '创建者', 
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(255) DEFAULT NULL COMMENT '修改者', 
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

CREATE TABLE `permission` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '权限ID',
  `parent_id` int DEFAULT NULL COMMENT '父权限ID',
  `name` varchar(255) NOT NULL COMMENT '权限名称',
  `path` varchar(255) DEFAULT NULL COMMENT '访问路径',
  `type` char(1) DEFAULT NULL COMMENT '权限类型:D为目录 M为菜单 B为按钮',
  `permission` varchar(255) DEFAULT NULL COMMENT '具体权限',
  `icon` varchar(255) DEFAULT NULL COMMENT '菜单图标',
  `sort` int NOT NULL DEFAULT '999' COMMENT '排序值',
  `status`  tinyint NOT NULL DEFAULT '1' COMMENT '状态:0为禁用,1为启用',
  `create_by` varchar(255) DEFAULT NULL COMMENT '创建者', 
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(255) DEFAULT NULL COMMENT '修改者', 
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';

CREATE TABLE `user_role` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `user_id` int NOT NULL COMMENT '用户ID',
  `role_id` int NOT NULL COMMENT '角色ID',
  `create_by` varchar(255) DEFAULT NULL COMMENT '创建者', 
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(255) DEFAULT NULL COMMENT '修改者', 
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY (`user_id`,`role_id`),
  FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE,
  FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色表';

CREATE TABLE `role_permission` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `role_id` int NOT NULL COMMENT '角色ID',
  `permission_id` int NOT NULL COMMENT '权限ID',
  `create_by` varchar(255) DEFAULT NULL COMMENT '创建者', 
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_by` varchar(255) DEFAULT NULL COMMENT '修改者', 
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY (`role_id`,`permission_id`),
  FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE,
  FOREIGN KEY (`permission_id`) REFERENCES `permission` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限表';

生成代码

创建完成数据表后,可以使用代码生成工具,根据数据表生成相应的代码。这里使用插件 EasyCode。

先引入 MyBatis,PageHelper,Knife4j 等依赖:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
        </dependency>
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
            <version>4.5.0</version>
        </dependency>

增加配置:

spring:
  application:
    name: ems
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.92.128:3306/ems?characterEncoding=utf8mb&serverTimezone=Asia/Shanghai&useSSL=false&rewriteBatchedStatements=true
    username: ems
    password: Abcd@1234
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Shanghai
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB

mybatis:
  type-aliases-package: net.stonecoding.ems
  mapper-locations: classpath*:mapper/**/*Mapper.xml
  configuration:
    map-underscore-to-camel-case: true
    use-generated-keys: true
#    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl


logging:
  level:
    net.stonecoding.ems: debug

springdoc:
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha
  api-docs:
    path: /v3/api-docs
  group-configs:
    - group: 'default'
      paths-to-match: '/**'
      packages-to-scan: net.stonecoding.ems
  default-flat-param-object: true

knife4j:
  enable: true
  setting:
    language: zh_cn

pagehelper:
  reasonable: true

src/main/java/net/stonecoding/ems/config 下创建 Knife4j 配置类,指定接口文档基本信息:

@Configuration
public class Knife4jConfig {
    @Bean
    public OpenAPI swaggerOpenAPI(){
        return new OpenAPI()
                .info(new Info().title("EMS Doc")
                        .contact(new Contact().name("stonecoding.net"))
                        .version("1.0.0")
                        .description("EMS Doc"));
    }
}

安装 EasyCode 插件后,在 IDEA 设置中为 EasyCode 修改类型转换,将数据库中 datetimetimestamp 类型对应为 Java 的 java.time.LocalDataTime

image-20240804220718695

然后自定义模版:

image-20240804220404848

Entity 模版 entity.java.vm

##引入宏定义
$!{define.vm}

##使用宏定义设置回调(保存位置与文件后缀)
#save("/entity", ".java")

##使用宏定义设置包后缀
#setPackageSuffix("entity")

##使用全局变量实现默认包导入
$!{autoImport.vm}
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

##使用宏定义实现类注释信息
##tableComment("实体类")
/**
 * @author $!author
 */
@Schema(description = "$!{tableInfo.name}")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class $!{tableInfo.name} implements Serializable {
##    private static final long serialVersionUID = $!tool.serial();
#foreach($column in $tableInfo.fullColumn)
    ##if(${column.comment})/**
    ## * ${column.comment}
    ## */#end
    
	@Schema(description = "${column.comment}")
    private $!{tool.getClsNameByFullName($column.type)} $!{column.name};
#end
##foreach($column in $tableInfo.fullColumn)
###使用宏定义实现get,set方法
##getSetMethod($column)
##end
}

Mapper Java 模版 mapper.java.vm

##定义初始变量
#set($tableName = $tool.append($tableInfo.name, "Mapper"))
#set($firstLowerCaseTableNames = $tool.append($!tool.firstLowerCase($!{tableInfo.name}), "s"))
##设置回调
$!callback.setFileName($tool.append($tableName, ".java"))
$!callback.setSavePath($tool.append($tableInfo.savePath, "/mapper"))

##拿到主键
#if(!$tableInfo.pkColumn.isEmpty())
    #set($pk = $tableInfo.pkColumn.get(0))
#end

#if($tableInfo.savePackageName)package $!{tableInfo.savePackageName}.#{end}mapper;

import $!{tableInfo.savePackageName}.entity.$!{tableInfo.name};
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;

/**
 * @author $!author
 */
@Mapper
public interface $!{tableName} {
	
    int insert($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));

    int insertBatch(@Param("$!{firstLowerCaseTableNames}") List<$!{tableInfo.name}> $!{firstLowerCaseTableNames});

    int insertOrUpdateBatch(@Param("$!{firstLowerCaseTableNames}") List<$!{tableInfo.name}> $!{firstLowerCaseTableNames});
	
    int deleteById($!pk.shortType $!pk.name);
	
    int update($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));

    $!{tableInfo.name} selectById($!pk.shortType $!pk.name);

    List<$!{tableInfo.name}> selectByCondition($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));

    long count($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));
}

Mapper SQL 模版 mapper.xml.vm

##引入mybatis支持
$!{mybatisSupport.vm}

##定义初始变量
#set($firstLowerCaseTableName = $!tool.firstLowerCase($!tableInfo.name))
#set($firstLowerCaseTableNames = $tool.append($!tool.firstLowerCase($!{tableInfo.name}), "s"))

##设置保存名称与保存位置
$!callback.setFileName($tool.append($!{tableInfo.name}, "Mapper.xml"))
$!callback.setSavePath($tool.append($modulePath, "/src/main/resources/mapper"))

##拿到主键
#if(!$tableInfo.pkColumn.isEmpty())
    #set($pk = $tableInfo.pkColumn.get(0))
#end

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="$!{tableInfo.savePackageName}.mapper.$!{tableInfo.name}Mapper">

    <resultMap type="$!{tableInfo.savePackageName}.entity.$!{tableInfo.name}" id="$!{tableInfo.name}Map">
#foreach($column in $tableInfo.fullColumn)
        <result property="$!column.name" column="$!column.obj.name" jdbcType="$!column.ext.jdbcType"/>
#end
    </resultMap>
	
    <insert id="insert" keyProperty="$!pk.name" useGeneratedKeys="true">
        insert into $!{tableInfo.obj.name}(#foreach($column in $tableInfo.otherColumn)$!column.obj.name#if($velocityHasNext), #end#end)
        values (#foreach($column in $tableInfo.otherColumn)#{$!{column.name}}#if($velocityHasNext), #end#end)
    </insert>

    <insert id="insertBatch" keyProperty="$!pk.name" useGeneratedKeys="true">
        insert into $!{tableInfo.obj.name}(#foreach($column in $tableInfo.otherColumn)$!column.obj.name#if($velocityHasNext), #end#end)
        values
        <foreach collection="$!{firstLowerCaseTableNames}" item="$!{firstLowerCaseTableName}" separator=",">
        (#foreach($column in $tableInfo.otherColumn)#{$!{firstLowerCaseTableName}.$!{column.name}}#if($velocityHasNext), #end#end)
        </foreach>
    </insert>

    <insert id="insertOrUpdateBatch" keyProperty="$!pk.name" useGeneratedKeys="true">
        insert into $!{tableInfo.obj.name}(#foreach($column in $tableInfo.otherColumn)$!column.obj.name#if($velocityHasNext), #end#end)
        values
        <foreach collection="$!{firstLowerCaseTableNames}" item="$!{firstLowerCaseTableName}" separator=",">
            (#foreach($column in $tableInfo.otherColumn)#{$!{firstLowerCaseTableName}.$!{column.name}}#if($velocityHasNext), #end#end)
        </foreach>
        on duplicate key update
        #foreach($column in $tableInfo.otherColumn)$!column.obj.name = values($!column.obj.name)#if($velocityHasNext),
        #end#end

    </insert>
	
    <delete id="deleteById">
        delete from $!{tableInfo.obj.name} where $!pk.obj.name = #{$!pk.name}
    </delete>
	
    <update id="update">
        update $!{tableInfo.obj.name}
        <set>
#foreach($column in $tableInfo.otherColumn)
            <if test="$!column.name != null#if($column.type.equals("java.lang.String")) and $!column.name != ''#end">
                $!column.obj.name = #{$!column.name},
            </if>
#end
        </set>
        where $!pk.obj.name = #{$!pk.name}
    </update>

    <select id="selectById" resultMap="$!{tableInfo.name}Map">
        select
          #allSqlColumn()

        from $!tableInfo.obj.name
        where $!pk.obj.name = #{$!pk.name}
    </select>

    <select id="selectByCondition" resultMap="$!{tableInfo.name}Map">
        select
          #allSqlColumn()

        from $!tableInfo.obj.name
        <where>
#foreach($column in $tableInfo.fullColumn)
            <if test="$!column.name != null#if($column.type.equals("java.lang.String")) and $!column.name != ''#end">
                and $!column.obj.name = #{$!column.name}
            </if>
#end
        </where>
    </select>

    <select id="count" resultType="java.lang.Long">
        select count(1)
        from $!tableInfo.obj.name
        <where>
#foreach($column in $tableInfo.fullColumn)
            <if test="$!column.name != null#if($column.type.equals("java.lang.String")) and $!column.name != ''#end">
                and $!column.obj.name = #{$!column.name}
            </if>
#end
        </where>
    </select>

</mapper>

Service 接口模版 service.java.vm

##定义初始变量
#set($tableName = $tool.append($tableInfo.name, "Service"))
#set($firstLowerCaseTableNames = $tool.append($!tool.firstLowerCase($!{tableInfo.name}), "s"))
##设置回调
$!callback.setFileName($tool.append($tableName, ".java"))
$!callback.setSavePath($tool.append($tableInfo.savePath, "/service"))

##拿到主键
#if(!$tableInfo.pkColumn.isEmpty())
    #set($pk = $tableInfo.pkColumn.get(0))
#end

#if($tableInfo.savePackageName)package $!{tableInfo.savePackageName}.#{end}service;

import $!{tableInfo.savePackageName}.entity.$!{tableInfo.name};
import com.github.pagehelper.PageParam;
import java.util.List;

/**
 * @author $!author
 */
public interface $!{tableName} {
	
    $!{tableInfo.name} insert($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));
	
	int insertBatch(List<$!{tableInfo.name}> $!{firstLowerCaseTableNames});

    int insertOrUpdateBatch(List<$!{tableInfo.name}> $!{firstLowerCaseTableNames});

    int deleteById($!pk.shortType $!pk.name);
	
    int update($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));

    $!{tableInfo.name} selectById($!pk.shortType $!pk.name);

    List<$!{tableInfo.name}> selectByCondition($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));
	
	List<$!{tableInfo.name}> selectByCondition($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}), PageParam pageParam);

    long count($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}));
}

Service 接口实现类模版 serviceImpl.java.vm

##定义初始变量
#set($tableName = $tool.append($tableInfo.name, "ServiceImpl"))
#set($firstLowerCaseTableNames = $tool.append($!tool.firstLowerCase($!{tableInfo.name}), "s"))
##设置回调
$!callback.setFileName($tool.append($tableName, ".java"))
$!callback.setSavePath($tool.append($tableInfo.savePath, "/service/impl"))

##拿到主键
#if(!$tableInfo.pkColumn.isEmpty())
    #set($pk = $tableInfo.pkColumn.get(0))
#end

#if($tableInfo.savePackageName)package $!{tableInfo.savePackageName}.#{end}service.impl;

import $!{tableInfo.savePackageName}.entity.$!{tableInfo.name};
import $!{tableInfo.savePackageName}.mapper.$!{tableInfo.name}Mapper;
import $!{tableInfo.savePackageName}.service.$!{tableInfo.name}Service;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageParam;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

/**
 * @author $!author
 */
@Service
@Transactional(rollbackFor = Exception.class)
public class $!{tableName} implements $!{tableInfo.name}Service {
	
    @Resource
    private $!{tableInfo.name}Mapper $!tool.firstLowerCase($!{tableInfo.name})Mapper;
	
    @Override
    public $!{tableInfo.name} insert($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name})) {
		$!{tool.firstLowerCase($!{tableInfo.name})}.setCreateTime(LocalDateTime.now());
		$!{tool.firstLowerCase($!{tableInfo.name})}.setUpdateTime(LocalDateTime.now());
        $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.insert($!tool.firstLowerCase($!{tableInfo.name}));
        return $!tool.firstLowerCase($!{tableInfo.name});
    }
	
	@Override
    public int insertBatch(List<$!{tableInfo.name}> $!{firstLowerCaseTableNames}) {
		$!{firstLowerCaseTableNames}.forEach($!tool.firstLowerCase($!{tableInfo.name}) -> {
            $!{tool.firstLowerCase($!{tableInfo.name})}.setCreateTime(LocalDateTime.now());
            $!{tool.firstLowerCase($!{tableInfo.name})}.setUpdateTime(LocalDateTime.now());
        });
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.insertBatch($!{firstLowerCaseTableNames});
    }
	
	@Override
    public int insertOrUpdateBatch(List<$!{tableInfo.name}> $!{firstLowerCaseTableNames}) {
		$!{firstLowerCaseTableNames}.forEach($!tool.firstLowerCase($!{tableInfo.name}) -> {
            $!{tool.firstLowerCase($!{tableInfo.name})}.setCreateTime(LocalDateTime.now());
            $!{tool.firstLowerCase($!{tableInfo.name})}.setUpdateTime(LocalDateTime.now());
        });
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.insertOrUpdateBatch($!{firstLowerCaseTableNames});
    }
	
	@Override
    public int deleteById(Integer id) {
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.deleteById(id);
    }
	
    @Override
    public int update($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name})) {
        $!{tool.firstLowerCase($!{tableInfo.name})}.setCreateBy(null);
        $!{tool.firstLowerCase($!{tableInfo.name})}.setCreateTime(null);
        $!{tool.firstLowerCase($!{tableInfo.name})}.setUpdateTime(LocalDateTime.now());
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.update($!tool.firstLowerCase($!{tableInfo.name}));
    }
	
	@Override
    public $!{tableInfo.name} selectById(Integer id) {
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.selectById(id);
    }
	
	@Override
    public List<$!{tableInfo.name}> selectByCondition($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name})) {
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.selectByCondition($!tool.firstLowerCase($!{tableInfo.name}));
    }
	
	@Override
    public List<$!{tableInfo.name}> selectByCondition($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name}), PageParam pageParam) {
        PageHelper.startPage(pageParam.getPageNum(), pageParam.getPageSize(), pageParam.getOrderBy());
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.selectByCondition($!tool.firstLowerCase($!{tableInfo.name}));
    }
	
	@Override
    public long count($!{tableInfo.name} $!tool.firstLowerCase($!{tableInfo.name})) {
        return $!{tool.firstLowerCase($!{tableInfo.name})}Mapper.count($!tool.firstLowerCase($!{tableInfo.name}));
    }
}

Controller 模板 controller.java.vm

##定义初始变量
#set($tableName = $tool.append($tableInfo.name, "Controller"))
#set($firstLowerCaseTableName = $!tool.firstLowerCase($!tableInfo.name))
#set($firstLowerCaseTableNames = $tool.append($!tool.firstLowerCase($!{tableInfo.name}), "s"))
#set($serviceName = $!tool.append($!tool.firstLowerCase($!tableInfo.name), "Service"))
##设置回调
$!callback.setFileName($tool.append($tableName, ".java"))
$!callback.setSavePath($tool.append($tableInfo.savePath, "/controller"))
##拿到主键
#if(!$tableInfo.pkColumn.isEmpty())
    #set($pk = $tableInfo.pkColumn.get(0))
#end

#if($tableInfo.savePackageName)package $!{tableInfo.savePackageName}.#{end}controller;

import $!{tableInfo.savePackageName}.entity.$!{tableInfo.name};
import $!{tableInfo.savePackageName}.service.$!{tableInfo.name}Service;
import com.github.pagehelper.PageInfo;
import com.github.pagehelper.PageParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * @author $!author
 */
@Tag(name = "$!{tableInfo.name}")
@RestController
@RequestMapping("/$!{firstLowerCaseTableName}")
@Validated
public class $!{tableName} {

    @Resource
    private $!{tableInfo.name}Service $!{serviceName};
	
	@Operation(summary = "新增")
    @PostMapping
    public Result<$!{tableInfo.name}> insert(@RequestBody @Validated $!{tableInfo.name} $!{firstLowerCaseTableName}) {
        return Result.success($!{serviceName}.insert($!{firstLowerCaseTableName}));
    }
	
	@Operation(summary = "批量新增")
    @PostMapping("/insertBatch")
    public Result<Integer> insertBatch(@RequestBody @Valid List<$!{tableInfo.name}> $!{firstLowerCaseTableNames}) {
        return Result.success($!{serviceName}.insertBatch($!{firstLowerCaseTableNames}));
    }
	
	@Operation(summary = "批量新增或修改")
    @PostMapping("/insertOrUpdateBatch")
    public Result<Integer> insertOrUpdateBatch(@RequestBody @Valid List<$!{tableInfo.name}> $!{firstLowerCaseTableNames}) {
        return Result.success($!{serviceName}.insertOrUpdateBatch($!{firstLowerCaseTableNames}));
    }
	
	@Operation(summary = "删除")
    @DeleteMapping("/{id}")
    public Result<Integer> deleteById(@PathVariable Integer id) {
        return Result.success($!{serviceName}.deleteById(id));
    }
	
	@Operation(summary = "修改")
    @PutMapping
    public Result<Integer> update(@RequestBody @Validated $!{tableInfo.name} $!{firstLowerCaseTableName}) {
        return Result.success($!{serviceName}.update($!{firstLowerCaseTableName}));
    }
	
	@Operation(summary = "根据 ID 查询")
    @GetMapping("/{id}")
    public Result<$!{tableInfo.name}> selectById(@PathVariable Integer id) {
        return Result.success($!{serviceName}.selectById(id));
    }
	
	@Operation(summary = "根据条件查询")
    @GetMapping("/list")
    public Result<List<$!{tableInfo.name}>> selectByCondition($!{tableInfo.name} $!{firstLowerCaseTableName}) {
        return Result.success($!{serviceName}.selectByCondition($!{firstLowerCaseTableName}));
    }
	
	@Operation(summary = "根据条件分页查询")
    @GetMapping("/list/page")
    public Result<PageInfo<$!{tableInfo.name}>> selectByCondition($!{tableInfo.name} $!{firstLowerCaseTableName}, PageParam pageParam) {
        return Result.success(new PageInfo<>($!{serviceName}.selectByCondition($!{firstLowerCaseTableName}, pageParam)));
    }
	
	@Operation(summary = "根据条件查询记录数")
    @GetMapping("/count")
    public Result<Long> count($!{tableInfo.name} $!{firstLowerCaseTableName}) {
        return Result.success($!{serviceName}.count($!{firstLowerCaseTableName}));
    }
}

使用 IDEA 连接到数据库后,选择一个表或者多个表,右键选择 EasyCodeGenerate Code,在弹出的对话框中选择模板,即可生成对应的代码。

image-20240805203538912

生成代码后,根据具体需求进行修改。例如为日期时间字段加上 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),以便返回指定格式的时间字符串。

修改完成后,启动应用,访问接口文档地址: http://localhost:8080/doc.html,进行接口测试。open in new window

image-20240805204150096

认证鉴权

认证

使用 Sa-Tokenopen in new window 权限认证框架进行认证鉴权。引入依赖:

        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot3-starter</artifactId>
            <version>1.38.0</version>
        </dependency>

增加以下配置:

sa-token:
  # token 名称(同时也是 cookie 名称)
  token-name: token
  # token 有效期(单位:秒) 默认30天,-1 代表永久有效
  timeout: 2592000
  # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
  active-timeout: -1
  # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
  is-concurrent: true
  # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
  is-share: true
  # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
  token-style: uuid
  # 是否输出操作日志
  is-log: true

src/main/java/net/stonecoding/ems/common/util 下创建 PasswordUtil 工具类,用于用户密码的加密和校验:

public class PasswordUtil {

    /**
     * 加密密码
     */
    public static String encryptPassword(String plainTextPassword) {
        return BCrypt.hashpw(plainTextPassword, BCrypt.gensalt());
    }

    /**
     * 验证密码
     */
    public static boolean verifyPassword(String plainTextPassword, String hashedPassword) {
        return BCrypt.checkpw(plainTextPassword, hashedPassword);
    }
}

在用户增加时,为用户密码加密:

    @Override
    public User insert(User user) {
        user.setPassword(PasswordUtil.encryptPassword(user.getPassword()));
		user.setCreateTime(LocalDateTime.now());
		user.setUpdateTime(LocalDateTime.now());
        userMapper.insert(user);
        return user;
    }

现在就可以在接口文档中通过 User 的新增接口创建用户,保存到数据库中:

[ems]> select id,username,password,status from user;
+----+----------+--------------------------------------------------------------+--------+
| id | username | password                                                     | status |
+----+----------+--------------------------------------------------------------+--------+
|  2 | admin    | $2a$10$ahTVa4DY90m9W.oGoTynleBAcnouulHeNjxJWmkDNeJz8u755KeBC |      1 |
+----+----------+--------------------------------------------------------------+--------+
1 row in set (0.00 sec)

创建认证控制器类 AuthenticationController 实现登录和注销功能:

@Tag(name = "Authentication")
@RestController
@RequestMapping("/authentication")
@Validated
public class AuthenticationController {

    @Resource
    private UserService userService;

    @PostMapping("/login")
    public Result<SaTokenInfo> login(@RequestBody @Validated User loginUser) {
        User user = userService.selectByUsername(loginUser.getUsername());
        if (Objects.nonNull(user) && PasswordUtil.verifyPassword(loginUser.getPassword(), user.getPassword())) {
            StpUtil.login(user.getId());
            return Result.success(StpUtil.getTokenInfo());
        } else {
            return Result.error(ResultCode.BAD_REQUEST, ResultMessage.USERNAME_OR_PASSWORD_WRONG);
        }
    }

    @PostMapping("/logout")
    public Result<String> logout() {
        StpUtil.logout();
        return Result.success();
    }
}

其中 selectByUsername 方法是根据前端登录请求中的用户名查询用户信息,对应的 SQL 如下:

    <select id="selectByUsername" resultMap="UserMap">
        select
          id, username, password, status, create_by, create_time, update_by, update_time
        from user
        where username = #{username}
    </select>

重启应用,在接口文档发起登录测试,返回如下信息:

{
  "code": 200,
  "message": "执行成功",
  "data": {
    "tokenName": "token",
    "tokenValue": "2481f48e-7aca-434c-97de-a82c0c5c09ee",
    "isLogin": true,
    "loginId": "2",
    "loginType": "login",
    "tokenTimeout": 2592000,
    "sessionTimeout": 2592000,
    "tokenSessionTimeout": -2,
    "tokenActiveTimeout": -1,
    "loginDevice": "default-device",
    "tag": null
  },
  "exception": null
}

包含名称为 token,值为 2481f48e-7aca-434c-97de-a82c0c5c09ee 的 Token。

在实际项目中,只有登录接口对外开放,其它接口都需要登录认证通过后才能访问,可以通过增加拦截器来实现,在 src/main/java/net/stonecoding/ems/config 下创建 SaTokenConfigure 配置类,注册拦截器:

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
                .addPathPatterns("/**")
                .excludePathPatterns("/authentication/login", "/doc.html", "/swagger-resources/**", "/webjars/**", "/v3/**", "/swagger-ui.html/**", "/swagger-ui.html"); 
    }
}

这里除了排除登录接口 /authentication/login 外,还排除了 Knife4j 的接口,以便使用接口文档。

重启应用,在接口文档中使用登录接口登录后,将 Token 添加到全局参数中,这样后续的请求就可以带上 Token 进行访问了。

image-20240810225050533

鉴权

完成认证后,就需要对不同的用户授予不同的角色,并根据角色关联到权限。

先在接口文档中通过 Role 的新增接口创建角色,保存到数据库中:

[ems]> select id,name,description,status from role;
+----+-------+--------------+--------+
| id | name  | description  | status |
+----+-------+--------------+--------+
|  1 | admin | 管理员       |      1 |
|  2 | user  | 普通用户     |      1 |
|  3 | guest | 游客         |      1 |
+----+-------+--------------+--------+
3 rows in set (0.00 sec)

然后通过 UserRole 的新增接口关联用户和角色,保存到数据库中:

[ems]> select id,user_id,role_id from user_role;
+----+---------+---------+
| id | user_id | role_id |
+----+---------+---------+
|  1 |       2 |       1 |
|  2 |       2 |       2 |
|  3 |       2 |       3 |
+----+---------+---------+
3 rows in set (0.00 sec)

然后通过 Permission 的新增接口创建权限,保存到数据库中。这里权限分为三类,从大到小依次为目录(D),菜单(M)和按钮(B)权限:


[ems]> select id,parent_id,name,path,type,permission from permission;
+----+-----------+--------------+--------------+------+--------------------+
| id | parent_id | name         | path         | type | permission         |
+----+-----------+--------------+--------------+------+--------------------+
|  1 |         0 | 系统管理     | /system      | D    | system             |
|  2 |         1 | 用户管理     | /system/user | M    | system.user        |
|  3 |         2 | 用户新增     | /system/user | B    | system.user.insert |
|  4 |         2 | 用户删除     | /system/user | B    | system.user.delete |
|  5 |         2 | 用户修改     | /system/user | B    | system.user.update |
|  6 |         2 | 用户查询     | /system/user | B    | system.user.select |
+----+-----------+--------------+--------------+------+--------------------+
6 rows in set (0.00 sec)

最后通过 RolePermission 的新增接口关联角色和权限,保存到数据库中:

[ems]> select id,role_id,permission_id from role_permission;
+----+---------+---------------+
| id | role_id | permission_id |
+----+---------+---------------+
|  1 |       1 |             1 |
|  2 |       1 |             2 |
|  3 |       1 |             3 |
|  4 |       1 |             4 |
|  5 |       1 |             5 |
|  6 |       1 |             6 |
+----+---------+---------------+
6 rows in set (0.00 sec)

在后端,暴露给前端的都是接口,即按钮(B)权限,只需要校验登录用户是否具有该权限即可。在 src/main/java/net/stonecoding/ems/system/service/impl 下创建 StpInterfaceImpl 获取用户的角色和权限:

@Component
public class StpInterfaceImpl implements StpInterface {

    @Resource
    private PermissionMapper permissionMapper;

    @Resource
    private RoleMapper roleMapper;

    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        return permissionMapper.selectPermissionsByUserId(Integer.parseInt(loginId.toString()));
    }

    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        return roleMapper.selectRolesByUserId(Integer.parseInt(loginId.toString()));
    }

}

在 Permisson 的 Mapper 类中增加方法:

List<String> selectPermissionsByUserId(Integer loginId);

在 Permisson 的 Mapper XML 文件中增加 SQL:

    <select id="selectPermissionsByUserId" resultType="java.lang.String">
        select
            p.permission
        from
            permission p,
            role_permission rp,
            user_role ur
        where
            p.id = rp.permission_id
        and rp.role_id = ur.role_id
        and p.status = 1
        and ur.user_id = #{userId}
    </select>

在 Role 的 Mapper 类中增加方法:

List<String> selectRolesByUserId(Integer userId);

在 Role 的 Mapper XML 文件中增加 SQL:

    <select id="selectRolesByUserId" resultType="java.lang.String">
        select
            r.name
        from
            role r,
            user_role ur
        where
            r.id = ur.role_id
        and r.status = 1
        and ur.user_id = #{userId}
    </select>

Sa-Token 可以通过在接口方法上添加注解来鉴权,还可以通过路由拦截器统一鉴权。为了方便管理,这里使用路由器拦截统一鉴权:

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SaInterceptor(handler -> {
            SaRouter.match("/**")
                    .notMatch("/authentication/login", "/doc.html", "/swagger-resources/**", "/webjars/**", "/v3/**", "/swagger-ui.html/**", "/swagger-ui.html")
                    .check(r -> StpUtil.checkLogin());

            SaRouter.match(SaHttpMethod.POST).match("/user/**").check(r -> StpUtil.checkPermission("system.user.insert"));
            SaRouter.match(SaHttpMethod.DELETE).match("/user/**").check(r -> StpUtil.checkPermission("system.user.delete"));
            SaRouter.match(SaHttpMethod.PUT).match("/user/**").check(r -> StpUtil.checkPermission("system.user.update"));
            SaRouter.match(SaHttpMethod.GET).match("/user/**").check(r -> StpUtil.checkPermission("system.user.select"));
        })).addPathPatterns("/**");
    }
}

表示访问 /user 下的接口时,根据不同的请求方式,需要具有相应的权限才会访问成功。

修改全局异常处理类 src/main/java/net/stonecoding/ems/common/exception/GlobalExceptionHandler.java,增加对 SaTokenException 异常的处理:

    @ExceptionHandler(SaTokenException.class)
    public Result<String> handlerSaTokenException(SaTokenException e) {
        e.printStackTrace();
        return Result.error(ResultCode.FORBIDDEN, ResultMessage.RUN_FAILED, null, e.getMessage());
    }

前端

技术栈:

创建项目

使用 pnpmopen in new window 创建 Vue3 项目:

$ pnpm create vue
.../1914bb47306-c04                      |   +1 +
.../1914bb47306-c04                      | Progress: resolved 1, reused 0, downloaded 1, added 1, done

Vue.js - The Progressive JavaScript Framework

√ 请输入项目名称: ... emsweb
√ 是否使用 TypeScript 语法? ... 否 / 是
√ 是否启用 JSX 支持? ... 否 / 是
√ 是否引入 Vue Router 进行单页面应用开发? ... 否 / 是
√ 是否引入 Pinia 用于状态管理? ... 否 / 是
√ 是否引入 Vitest 用于单元测试? ... 否 / 是
√ 是否要引入一款端到端(End to End)测试工具? » 不需要
√ 是否引入 ESLint 用于代码质量检测? ... 否 / 是
√ 是否引入 Prettier 用于代码格式化? ... 否 / 是
√ 是否引入 Vue DevTools 7 扩展用于调试? (试验阶段) ... 否 / 是

正在初始化项目 D:\code\mycode\emsweb...

项目初始化完成,可执行以下命令:

  cd emsweb
  pnpm install
  pnpm format
  pnpm dev

这里选择了 Vue Router,Pinia,ESLint 及 Prettier。

创建完成后,按照最后的提示,切换到项目目录,安装依赖并启动项目:

$ cd emsweb

$ pnpm install
   ╭─────────────────────────────────────────────────────────────────╮
   │                                                                 │
   │                Update available! 9.6.0 → 9.7.0.                 │
   │   Changelog: https://github.com/pnpm/pnpm/releases/tag/v9.7.0   │
   │                Run "pnpm add -g pnpm" to update.                │
   │                                                                 │
   │        Follow @pnpmjs for updates: https://x.com/pnpmjs         │
   │                                                                 │
   ╰─────────────────────────────────────────────────────────────────╯

 WARN  5 deprecated subdependencies found: @humanwhocodes/config-array@0.11.14, @humanwhocodes/object-schema@2.0.3, glob@7.2.3, inflight@1.0.6, rimraf@3.0.2
Packages: +154
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 192, reused 109, downloaded 45, added 154, done
node_modules/.pnpm/esbuild@0.21.5/node_modules/esbuild: Running postinstall script, done in 438ms
node_modules/.pnpm/vue-demi@0.14.10_vue@3.4.37/node_modules/vue-demi: Running postinstall script, done in 101ms

dependencies:
+ pinia 2.2.2
+ vue 3.4.38
+ vue-router 4.4.3

devDependencies:
+ @rushstack/eslint-patch 1.10.4
+ @vitejs/plugin-vue 5.1.2
+ @vue/eslint-config-prettier 9.0.0
+ eslint 8.57.0 (9.9.0 is available)
+ eslint-plugin-vue 9.27.0
+ prettier 3.3.3
+ vite 5.4.0

Done in 17.1s

$ pnpm dev

> emsweb@0.0.0 dev D:\code\mycode\emsweb
> vite


  VITE v5.4.0  ready in 277 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

配置项目

修改 .eslintrc.cjs,配置 ESLint:

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  root: true,
  extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-prettier/skip-formatting'],
  parserOptions: {
    ecmaVersion: 'latest'
  },
  rules: {
    // prettier 专注于代码的美观度 (格式化工具)
    // 前置:
    // 1. 禁用格式化插件 Prettier - Code formatter
    // 2. 在 VS Code 设置中关闭 Format On Save
    // 3. 安装 ESLint 插件, 并配置保存时自动修复 "source.fixAll": "explicit"
    'prettier/prettier': [
      'warn',
      {
        singleQuote: true, // 单引号
        semi: false, // 无分号
        printWidth: 145, // 每行宽度至多 140 字符
        trailingComma: 'none', // 不加对象|数组最后逗号
        endOfLine: 'auto' // 换行符号不限制(Win Mac 不一致)
      }
    ],
    // ESLint 关注于规范, 如果不符合规范,报错
    'vue/multi-word-component-names': ['off'], // 关闭组件命名规范校验
    'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验 (props 解构丢失响应式)
    'no-undef': 'error' // 添加未定义变量错误提示
  },
  // 声明全局变量名,解决直接使用 ElMessage 等组件的报错问题
  globals: {
    ElMessage: 'readonly',
    ElMessageBox: 'readonly',
    ElLoading: 'readonly'
  }
}

然后调整目录,删除不需要的文件,包括:

  • 删除 src/assets 目录下的所有文件
  • 删除 src/components 目录下的所有文件夹和文件
  • 删除 src/views 目录下的文件

修改 src/route/index.js,删除自带的路由规则:

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: []
})

export default router

修改 App.vue 文件:

<script setup></script>

<template>
  <div></div>
</template>

<style lang="less" scoped></style>

修改 main.js 文件:

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')

新增以下目录:

  • src/api
  • src/utils

安装 LESS:

$ pnpm i less less-loader -D

src\style\base.css 中编写基础样式:

/* 去除常见标签默认的 margin 和 padding */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* 设置网页统一的字体大小、行高、字体系列相关属性 */
body {
  font: 16px/1.5 "Microsoft Yahei",
    "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", sans-serif;
  color: #333;
}

/* 去除列表默认样式 */
ul,
ol {
  list-style: none;
}

/* 去除默认的倾斜效果 */
em,
i {
  font-style: normal;
}

/* 去除 a 标签默认下划线,并设置默认文字颜色 */
a {
  text-decoration: none;
  color: #333;
}

/* 设置 img 的垂直对齐方式为居中对齐,去除img默认下间隙 */
img {
  width: 100%;
  height: 100%;
  vertical-align: middle;
}

/* 去除 input 默认样式 */
input {
  border: none;
  outline: none;
  color: #333;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-weight: 400;
}

src\style\common.css 中编写通用样式:

html,body,#app{
  height: 100%;
  margin: 0;
  padding: 0;
  min-width: 1366px;
}

.el-breadcrumb{
  margin-bottom: 16px;
  font-size: 12px;
}

.el-card{
  box-shadow: 0 1px 1px rgba(0,0,0,0.15) !important;
}

.el-table{
  margin-top: 20px;
  font-size: 12px;
}

.el-pagination {
  margin-top: 20px;
}

.el-cascader-panel{
  height: 200px;
}

.el-steps {
  margin: 16px 0;
}

.el-step__title{
  font-size: 12px;
}

.ql-editor {
  min-height: 300px;
}

然后在 main.js 中引入:

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

import './style/base.css'

import './style/common.css'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')

配置路由

修改路由配置文件 src/router/index.js,去掉示例路由,保留基本框架:

import { createRouter, createWebHistory } from 'vue-router'

// createRouter 创建路由实例
// 配置模式:
// 1. createWebHistory:History 模式,地址栏不带 #
// 2. createWebHashHistory: Hash 模式,地址栏带 #
// 其中 import.meta.env.BASE_URL 就是 vite.config.js 中的 base 配置项
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: []
})

export default router

由于在 Vue3 的组合式 API 中, <script setup> 下无法使用 this,不能再直接访问 this.$routerthis.$route。需要分别使用 useRouteruseRoute 函数分别获取路由实例和当前路由信息:

<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
console.log(router, route)
</script>

Element Plus

安装 Element Plus 及图标库:

$ pnpm add element-plus @element-plus/icons-vue

修改 main.js,将 Element Plus 引入到 Vue 项目:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import pinia from './stores'

import App from './App.vue'
import router from './router'

import './style/main.css'
import 'element-plus/dist/index.css'

const app = createApp(App)

app.use(ElementPlus, { size: 'small', zIndex: 3000, locale: zhCn })
app.use(pinia)
app.use(router)

app.mount('#app')

安装按需导入插件:

$ pnpm add -D unplugin-vue-components unplugin-auto-import

vite.config.js 中配置按需导入插件:

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()]
    }),
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

然后就可以在项目中使用 Element 的组件了,同时还可以直接使用自己创建的位于 src/components 目录下的组件,无需导入。

修改 main.js,从 @element-plus/icons-vue 中导入所有图标并进行全局注册:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import pinia from './stores'

import App from './App.vue'
import router from './router'

import './style/main.css'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(ElementPlus, { size: 'small', zIndex: 3000, locale: zhCn })
app.use(pinia)
app.use(router)

app.mount('#app')

安装 unplugin-auto-import 后,还可以设置自动导入 Vue 相关函数:

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue'],
      resolvers: [ElementPlusResolver()],
      eslintrc: {
        enabled: true // 改为 true 用于生成 .eslintrc-auto-import.json 配置文件,生成后删除该项,避免重复生成消耗
      }
    }),
    Components({
      resolvers: [ElementPlusResolver()]
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

.eslintrc.cjs 中加入生成的 .eslintrc-auto-import.json

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  root: true,
  extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-prettier/skip-formatting', './.eslintrc-auto-import.json'],
  parserOptions: {
    ecmaVersion: 'latest'
  },
  rules: {
    // prettier 专注于代码的美观度 (格式化工具)
    // 前置:
    // 1. 禁用格式化插件 Prettier - Code formatter
    // 2. 在 VS Code 设置中关闭 Format On Save
    // 3. 安装 ESLint 插件, 并配置保存时自动修复 "source.fixAll": "explicit"
    'prettier/prettier': [
      'warn',
      {
        singleQuote: true, // 单引号
        semi: false, // 无分号
        printWidth: 145, // 每行宽度至多 140 字符
        trailingComma: 'none', // 不加对象|数组最后逗号
        endOfLine: 'auto' // 换行符号不限制(Win Mac 不一致)
      }
    ],
    // ESLint 关注于规范, 如果不符合规范,报错
    'vue/multi-word-component-names': ['off'], // 关闭组件命名规范校验
    'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验 (props 解构丢失响应式)
    'no-undef': 'error' // 添加未定义变量错误提示
  },
  // 声明全局变量名,解决直接使用 ElMessage 等组件的报错问题
  globals: {
    ElMessage: 'readonly',
    ElMessageBox: 'readonly',
    ElLoading: 'readonly'
  }
}

图标库

Element Plus 自带的图标库中的图标较少,可以使用第三方图标库 Iconify。

$ pnpm add -D @iconify/vue unplugin-icons

vite.config.js 中配置按需导入插件:

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue'],
      resolvers: [ElementPlusResolver()]
    }),
    Components({
      resolvers: [ElementPlusResolver(), IconsResolver({ enabledCollections: ['ep'] })]
    }),
    Icons({
      autoInstall: true // 自动安装图标组件
    })
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

状态管理

在创建项目的时候,已经选择并安装了 Pinia,还需要安装持久化插件:

$ pnpm i pinia-plugin-persistedstate

main.js 将插件添加到 Pinia 实例上:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

import App from './App.vue'
import router from './router'

import './style/main.css'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(ElementPlus, { size: 'small', zIndex: 3000, locale: zhCn })

app.use(createPinia().use(piniaPluginPersistedstate))
app.use(router)

app.mount('#app')

这里以管理用户数据为例,在 src/stores 下创建 modules 目录,在此目录下创建仓库文件 user.js 并持久化数据:

import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useUserStore = defineStore(
  'user',
  () => {
    const token = ref('')
    const setToken = (newToken) => {
      token.value = newToken
    }

    const user = ref({})
    const setUser = (obj) => {
      user.value = obj
    }

    return { token, setToken, user, setUser }
  },
  {
    persist: true
  }
)

src/stores 目录下创建 index.js,将 main.js 中与 Pinia 相关的代码拆分到 index.js 中:

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia

再在 main.js 中引入并使用 Pinia:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import pinia from './stores'

import App from './App.vue'
import router from './router'

import './style/main.css'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(ElementPlus, { size: 'small', zIndex: 3000, locale: zhCn })
app.use(pinia)
app.use(router)

app.mount('#app')

src/stores/index.js 中统一导出 modeules 目录下的仓库文件 :

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export default pinia

export * from './modules/user'

这样组件文件中就可以都从 src/stores/index.js 中引入了:

<script setup>
import { useUserStore } from "@/stores";
const userStore = useUserStore()
</script>

请求配置

安装 Axios:

$ pnpm i axios

src/utils 下创建 request.js,配置 Axios 模块:

import axios from 'axios'
import NProgress from 'nprogress'
import { useUserStore } from '@/stores'
import { ElMessage } from 'element-plus'
import router from '@/router'
NProgress.configure({ showSpinner: false })
const baseURL = 'http://localhost:8080'

const instance = axios.create({
  // 基础地址
  baseURL,
  // 超时时间
  timeout: 10000
})

// 请求拦截器
instance.interceptors.request.use(
  (config) => {
    NProgress.start()
    // 携带 Token
    const userStore = useUserStore()

    if (userStore.token) {
      config.headers.token = userStore.token
    }

    if (!userStore.token) {
      router.push('/login')
    }

    return config
  },
  (err) => Promise.reject(err)
)

// 响应拦截器
instance.interceptors.response.use(
  (res) => {
    NProgress.done()
    // 处理业务成功
    if (res.data.code === 200) {
      return res
    }

    if (res.data.code === 403) {
      const userStore = useUserStore()
      userStore.setToken('')
      userStore.setUser({})

      ElMessage.info('请重新登录')
      router.push('/login')
      return res
    }

    // 处理业务失败,给错误提示,抛出错误
    ElMessage.error(res.data.message || '服务异常')
    return Promise.reject(res)
  },
  (err) => {
    NProgress.done()
    // 注意:只有在后端没有配置全局异常处理器的情况下,才会走到这里
    // 错误的特殊情况 => 401 权限不足 或 Token 过期 => 拦截到登录
    if (err.response?.status === 401) {
      router.push('/login')
    }

    let { message } = err
    if (message == 'Network Error') {
      message = '接口连接异常'
    } else if (message.includes('timeout')) {
      message = '接口请求超时'
    } else if (message.includes('Request failed with status code')) {
      message = '接口' + message.substr(message.length - 3) + '异常'
    }

    // 错误的默认情况 => 只要给提示
    ElMessage.error(message || '服务异常')
    return Promise.reject(err)
  }
)

export default instance
export { baseURL }

进度条

使用 NProgress 这个轻量级的进度条组件,在发起请求和切换路由时显示进度条。

安装 NProgress:

$ pnpm add nprogress

main.js 中引入进度条样式:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import pinia from './stores'

import App from './App.vue'
import router from './router'

import './style/main.css'
import 'element-plus/dist/index.css'
import 'nprogress/nprogress.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(ElementPlus, { size: 'small', zIndex: 3000, locale: zhCn })
app.use(pinia)
app.use(router)

app.mount('#app')

src\utils\request.js 中为请求配置进度条:

import axios from 'axios'
import NProgress from 'nprogress'
import { useUserStore } from '@/stores'
import { ElMessage } from 'element-plus'
import router from '@/router'
NProgress.configure({ showSpinner: false })
const baseURL = 'http://localhost:8080'

const instance = axios.create({
  // 基础地址
  baseURL,
  // 超时时间
  timeout: 10000
})

// 请求拦截器
instance.interceptors.request.use(
  (config) => {
    NProgress.start()
    // 携带 Token
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers.token = userStore.token
    }
    return config
  },
  (err) => Promise.reject(err)
)

// 响应拦截器
instance.interceptors.response.use(
  (res) => {
    NProgress.done()
    // 处理业务成功
    if (res.data.code === 200) {
      return res
    }
    // 处理业务失败,给错误提示,抛出错误
    ElMessage.error(res.data.message || '服务异常')
    return Promise.reject(res.data)
  },
  (err) => {
    NProgress.done()
    // 错误的特殊情况 => 401 权限不足 或 Token 过期 => 拦截到登录
    if (err.response?.status === 401) {
      router.push('/login')
    }

    // 错误的默认情况 => 只要给提示
    ElMessage.error(err.response?.data.message || '服务异常')
    return Promise.reject(err)
  }
)

export default instance
export { baseURL }

src\router\index.js 中为路由配置进度条:

import NProgress from 'nprogress'
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'
NProgress.configure({ showSpinner: false })

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: []
})

// 登录访问拦截 => 默认是直接放行的
// 根据返回值决定,是放行还是拦截
// 返回值:
// 1. undefined / true:  直接放行
// 2. false: 拦回 from 的地址页面
// 3. 具体路径 或 路径对象:  拦截到对应的地址
// '/login'   { name: 'login' }
router.beforeEach((to) => {
  NProgress.start()
  // 如果没有 token, 且访问的是非登录页,拦截到登录,其他情况正常放行
  const useStore = useUserStore()
  if (!useStore.token && to.path !== '/login') return '/login'
})

router.afterEach(() => {
  NProgress.done()
})

export default router

路由设计

根据业务规划路由:

path文件功能组件名路由级别
/loginviews/login/Login.vue登录&注册Login一级路由
/views/layout/Layout.vue布局架子Layout一级路由
├─ /homeviews/home/Home.vue主页Home二级路由
├─ /system/userviews/system/user/User.vue用户管理User二级路由
├─ /system/roleviews/system/role/Role.vue角色管理Role二级路由

src/router/index.js 中配置路由规则:

import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/login/Login.vue')
    },
    {
      path: '/',
      component: () => import('@/views/layout/Layout.vue'),
      redirect: '/home',
      children: [
        {
          path: '/home',
          name: 'home',
          component: () => import('@/views/home/Home.vue'),
          meta: { title: '主页' }
        },
        {
          path: '/system/user',
          name: 'user',
          component: () => import('@/views/system/user/User.vue'),
          meta: { title: '用户管理' }
        },
        {
          path: '/system/role',
          name: 'role',
          component: () => import('@/views/system/role/Role.vue'),
          meta: { title: '角色管理' }
        },
        {
          path: '/system/permission',
          name: 'permission',
          component: () => import('@/views/system/permission/Permission.vue'),
          meta: { title: '权限管理' }
        }
      ]
    }
  ]
})

// 登录访问拦截 => 默认是直接放行的
// 根据返回值决定,是放行还是拦截
// 返回值:
// 1. undefined / true:  直接放行
// 2. false: 拦回 from 的地址页面
// 3. 具体路径 或 路径对象:  拦截到对应的地址
// '/login'   { name: 'login' }
router.beforeEach((to) => {
  NProgress.start()
  // 如果没有 token, 且访问的是非登录页,拦截到登录,其他情况正常放行
  const useStore = useUserStore()
  if (!useStore.token && to.path !== '/login') return '/login'
})

router.afterEach(() => {
  NProgress.done()
})

export default router

根据以上规则,创建对应的组件。

修改 App.vue 组件,添加路由出口:

<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>

<template>
  <div class="router_container">
    <router-view :key="route.path"></router-view>
  </div>
</template>

<style lang="less" scoped>
.router_container {
  height: 100%;
}
</style>

登录认证

功能需求:

  • 登录页面
  • 登录功能,包括校验,登录
  • 基础数据,登录时获取用户 token,用户信息,用户角色以及用户权限数据

src/api/authentication.js 中创建认证请求模块,向后端接口发起登录和退出请求:

import request from '@/utils/request'

// 登录系统
export const authenticationLoginService = ({ username, password }) => request.post('/authentication/login', { username, password })

// 退出系统
export const authenticationLogoutService = () => request.post('/authentication/logout')

src/stores/modules/user.js 中创建用户数据仓库,用于管理并持久化用户数据及 Token:

import { ref } from 'vue'
import { defineStore } from 'pinia'
import capitalize from 'lodash/capitalize'

export const useUserStore = defineStore(
  'user',
  () => {
    const token = ref('')
    const setToken = (newToken) => {
      token.value = newToken
    }

    const user = ref({})
    const setUser = (obj) => {
      user.value = obj
    }

    const userRoleList = ref([])
    const setUserRoleList = (list) => {
      userRoleList.value = list
    }

    const userPermissionList = ref([])
    const setUserPermissionList = (list) => {
      userPermissionList.value = list
    }
    const userPathList = computed(() => userPermissionList.value.filter((item) => item.type === 'M').map((item) => item.path))
    const userDynamicRouteList = computed(() =>
      userPermissionList.value
        .filter((item) => item.type === 'M')
        .map((item) => {
          const name = item.path.substring(item.path.lastIndexOf('/') + 1)
          const componentName = capitalize(name)
          const componentPath = `/src/views${item.path}/${componentName}.vue`
          return {
            path: item.path,
            name: name,
            component: () => import(`${componentPath}`),
            meta: { title: item.name }
          }
        })
    )
    const userButtonList = computed(() => userPermissionList.value.filter((item) => item.type === 'B').map((item) => item.permission))

    return {
      token,
      setToken,
      user,
      setUser,
      userRoleList,
      setUserRoleList,
      userPermissionList,
      setUserPermissionList,
      userPathList,
      userDynamicRouteList,
      userButtonList
    }
  },
  {
    persist: true
  }
)

src/stores/index.js 中统一导出:

export * from './modules/user'

src/views/login/Login.vue 中创建登录页面,该页面包含登录页面头组件和登录页面体组件:

<script setup>
import LoginHeader from './components/LoginHeader.vue'
import LoginBody from './components/LoginBody.vue'
</script>

<template>
  <div class="login-wrapper">
    <LoginHeader />
    <div class="login-container">
      <div class="title-container">
        <h1 class="title">EMS</h1>
      </div>
      <LoginBody />
    </div>
  </div>
</template>

<style lang="less" scoped>
.login-wrapper {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-size: cover;
  background-position: 100%;
  position: relative;
}

.login-container {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  min-height: 400px;
  line-height: 22px;
}

.title-container {
  .title {
    text-align: center;
    font-size: 36px;
    line-height: 44px;
    color: rgba(0, 0, 0, 90%);
    margin-top: 4px;

    &.margin-no {
      margin-top: 0;
    }
  }
}
</style>

登录页面头组件 src\views\login\components\LoginHeader.vue

<script setup>
import { Icon } from '@iconify/vue'
const toGitee = () => {
  window.open('https://gitee.com/stonebox/emsweb')
}

const toHelper = () => {
  window.open('https://stonecoding.net/application/ems')
}
</script>

<template>
  <header class="login-header">
    <div class="operations-container">
      <el-button round link @click="toGitee">
        <Icon icon="tdesign:logo-github" width="20" height="20" />
      </el-button>
      <el-button round link @click="toHelper">
        <Icon icon="tdesign:help-circle" width="20" height="20" />
      </el-button>
    </div>
  </header>
</template>

<style lang="less" scoped>
.login-header {
  padding: 0 24px;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  color: rgba(0, 0, 0, 90%);
  height: 56px;

  .operations-container {
    display: flex;
    align-items: center;

    .el-button {
      margin-left: 16px;
    }

    &:hover {
      cursor: pointer;
    }
  }
}
</style>

登录页面体组件 src\views\login\components\LoginBody.vue

<script setup>
import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { useUserStore } from '@/stores'
import { authenticationLoginService } from '@/api/authentication.js'

const loginModel = ref({
  username: 'admin',
  password: '123456'
})

const loginRules = {
  username: [
    { required: true, message: '请输入账号', trigger: 'blur' },
    { min: 2, max: 20, message: '用户名必须是 2-20 位的字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { pattern: /^\S{6,20}$/, message: '密码必须是 6-20 位的非空字符', trigger: 'blur' }
  ]
}

const loginRef = ref()
const userStore = useUserStore()
const router = useRouter()
const login = async () => {
  await loginRef.value.validate()
  const res = await authenticationLoginService(loginModel.value)
  userStore.setToken(res.data.data.token)
  userStore.setUser(res.data.data.user)
  userStore.setUserRoleList(res.data.data.role)
  userStore.setUserPermissionList(res.data.data.permission)
  ElMessage.success('登录成功')
  router.push('/')
}
</script>

<template>
  <div>
    <el-form ref="loginRef" class="item-container" :model="loginModel" :rules="loginRules" label-width="0">
      <el-form-item prop="username">
        <el-input v-model.trim="loginModel.username" size="large" type="text" clearable placeholder="请输入账号">
          <template #prefix>
            <Icon icon="tdesign:user" width="16" height="16" />
          </template>
        </el-input>
      </el-form-item>
      <el-form-item prop="password">
        <el-input v-model.trim="loginModel.password" size="large" type="password" clearable placeholder="请输入密码" show-password>
          <template #prefix>
            <Icon icon="tdesign:lock-on" width="16" height="16" />
          </template>
        </el-input>
      </el-form-item>
      <el-form-item class="btn-container">
        <el-button size="large" type="primary" @click="login" style="width: 400px"> 登录 </el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<style lang="less" scoped>
.item-container {
  width: 400px;
  margin-top: 48px;

  .btn-container {
    margin-top: 48px;
  }
}
</style>

此时进行登录请求,会遇到跨域问题,这是因为前端和后端的端口不一致。参考 Sa-Token 官方文档解决跨域问题,在 src/main/java/net/stonecoding/ems/config/SaTokenConfigure.java 配置类中增加一个过滤器:

    @Bean
    public SaServletFilter getSaServletFilter() {
        return new SaServletFilter().setBeforeAuth(obj -> {
                    SaHolder.getResponse()
                            .setHeader("Access-Control-Allow-Origin", "*")
                            .setHeader("Access-Control-Allow-Methods", "*")
                            .setHeader("Access-Control-Allow-Headers", "*")
                            .setHeader("Access-Control-Max-Age", "3600");

                    SaRouter.match(SaHttpMethod.OPTIONS)
                            .back();
                });
    }

重启后端程序,然后就可以进行登录了。

登录后,一般都会用到用户的一些信息,比如将登录名称显示在页面上。根据后端的登录接口,返回的 StpUtil.getTokenInfo() 只有登录用户的 ID,没有用户名,如果要获取用户名,还需要再根据用户 ID 发起请求获取用户信息,就多了一次请求,数据库也需要多执行一次 SQL。其实在该登录接口中,已经通过 selectByUsername 方法获取了用户信息,只需要将该信息一起返回给前端即可,修改登录接口如下:

    @Operation(summary = "登录")
    @PostMapping("/login")
    public Result<Map<String, Object>> login(@RequestBody @Validated User loginUser) {
        User user = userService.selectByUsername(loginUser.getUsername());
        if (Objects.nonNull(user) && user.getStatus() == 1 && PasswordUtil.verifyPassword(loginUser.getPassword(), user.getPassword())) {
            StpUtil.login(user.getId());
            StpUtil.getSession().set("user", user);
            Map<String, Object> loginInfo = new HashMap<>();
            user.setPassword(null);
            loginInfo.put("user", user);
            loginInfo.put("token", StpUtil.getTokenValue());
            loginInfo.put("role", userService.selectRoleById(user.getId()));
            loginInfo.put("permission", userService.selectPermissionById(user.getId()));
            return Result.success(loginInfo);
        } else {
            return Result.error(ResultCode.BAD_REQUEST, ResultMessage.USERNAME_OR_PASSWORD_WRONG);
        }
    }

此时登录请求的响应体为:

{
    "code": 200,
    "message": "执行成功",
    "data": {
        "role": [
            {
                "id": 1,
                "name": "admin",
                "description": "管理员",
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:03:47",
                "updateBy": "admin",
                "updateTime": "2024-09-09 11:51:09"
            },
            {
                "id": 2,
                "name": "user",
                "description": "普通用户",
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:19:13",
                "updateBy": null,
                "updateTime": "2024-08-08 14:19:13"
            },
            {
                "id": 3,
                "name": "guest",
                "description": "游客",
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:19:29",
                "updateBy": "admin",
                "updateTime": "2024-09-05 11:34:03"
            }
        ],
        "permission": [
            {
                "id": 1,
                "parentId": 0,
                "name": "系统管理",
                "path": "/system",
                "type": "D",
                "permission": "system",
                "icon": "flat-color-icons:settings",
                "sort": 1,
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:45:04",
                "updateBy": "admin",
                "updateTime": "2024-09-03 13:06:50"
            },
            {
                "id": 2,
                "parentId": 1,
                "name": "用户管理",
                "path": "/system/user",
                "type": "M",
                "permission": "system.user",
                "icon": "flat-color-icons:businessman",
                "sort": 1,
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:50:32",
                "updateBy": "admin",
                "updateTime": "2024-09-03 13:07:07"
            },
            {
                "id": 3,
                "parentId": 2,
                "name": "用户新增",
                "path": "/system/user",
                "type": "B",
                "permission": "system.user.insert",
                "icon": "",
                "sort": 0,
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:51:49",
                "updateBy": null,
                "updateTime": "2024-08-19 10:53:49"
            },
            {
                "id": 4,
                "parentId": 2,
                "name": "用户删除",
                "path": "/system/user",
                "type": "B",
                "permission": "system.user.delete",
                "icon": "",
                "sort": 0,
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:52:10",
                "updateBy": null,
                "updateTime": "2024-08-19 10:59:02"
            },
            {
                "id": 5,
                "parentId": 2,
                "name": "用户修改",
                "path": "/system/user",
                "type": "B",
                "permission": "system.user.update",
                "icon": "",
                "sort": 0,
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-08 14:52:27",
                "updateBy": null,
                "updateTime": "2024-08-19 10:59:13"
            },
            {
                "id": 6,
                "parentId": 2,
                "name": "用户查询",
                "path": "/system/user",
                "type": "B",
                "permission": "system.user.select",
                "icon": null,
                "sort": 0,
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-19 10:55:26",
                "updateBy": "admin",
                "updateTime": "2024-09-03 09:25:56"
            },
            {
                "id": 7,
                "parentId": 0,
                "name": "公司管理",
                "path": "/company",
                "type": "D",
                "permission": "company",
                "icon": "flat-color-icons:home",
                "sort": 1,
                "status": 1,
                "createBy": null,
                "createTime": "2024-08-30 14:28:25",
                "updateBy": "admin",
                "updateTime": "2024-09-04 10:01:47"
            },
            {
                "id": 15,
                "parentId": 1,
                "name": "角色管理",
                "path": "/system/role",
                "type": "M",
                "permission": "system.role",
                "icon": "flat-color-icons:collaboration",
                "sort": 2,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:08:58",
                "updateBy": "admin",
                "updateTime": "2024-09-03 13:20:42"
            },
            {
                "id": 16,
                "parentId": 15,
                "name": "角色新增",
                "path": "/system/role",
                "type": "B",
                "permission": "system.role.insert",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:09:23",
                "updateBy": null,
                "updateTime": "2024-09-03 13:09:23"
            },
            {
                "id": 17,
                "parentId": 15,
                "name": "角色删除",
                "path": "/system/role",
                "type": "B",
                "permission": "system.role.delete",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:10:09",
                "updateBy": null,
                "updateTime": "2024-09-03 13:10:09"
            },
            {
                "id": 18,
                "parentId": 15,
                "name": "角色修改",
                "path": "/system/role",
                "type": "B",
                "permission": "system.role.update",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:12:56",
                "updateBy": null,
                "updateTime": "2024-09-03 13:12:56"
            },
            {
                "id": 19,
                "parentId": 15,
                "name": "角色查询",
                "path": "/system/role",
                "type": "B",
                "permission": "system.role.select",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:13:10",
                "updateBy": null,
                "updateTime": "2024-09-03 13:13:10"
            },
            {
                "id": 20,
                "parentId": 1,
                "name": "权限管理",
                "path": "/system/permission",
                "type": "M",
                "permission": "system.permission",
                "icon": "flat-color-icons:key",
                "sort": 3,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:13:48",
                "updateBy": "admin",
                "updateTime": "2024-09-03 13:20:48"
            },
            {
                "id": 21,
                "parentId": 20,
                "name": "权限新增",
                "path": "/system/permission",
                "type": "B",
                "permission": "system.permission.insert",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:14:10",
                "updateBy": null,
                "updateTime": "2024-09-03 13:14:10"
            },
            {
                "id": 22,
                "parentId": 20,
                "name": "权限删除",
                "path": "/system/permission",
                "type": "B",
                "permission": "system.permission.delete",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:14:26",
                "updateBy": null,
                "updateTime": "2024-09-03 13:14:26"
            },
            {
                "id": 23,
                "parentId": 20,
                "name": "权限修改",
                "path": "/system/permission",
                "type": "B",
                "permission": "system.permission.update",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:14:40",
                "updateBy": null,
                "updateTime": "2024-09-03 13:14:40"
            },
            {
                "id": 24,
                "parentId": 20,
                "name": "权限查询",
                "path": "/system/permission",
                "type": "B",
                "permission": "system.permission.select",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:14:53",
                "updateBy": null,
                "updateTime": "2024-09-03 13:14:53"
            },
            {
                "id": 26,
                "parentId": 7,
                "name": "员工管理",
                "path": "/company/employee",
                "type": "M",
                "permission": "company.employee",
                "icon": "flat-color-icons:manager",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 13:32:31",
                "updateBy": "admin",
                "updateTime": "2024-09-04 10:01:47"
            },
            {
                "id": 27,
                "parentId": 26,
                "name": "员工新增",
                "path": "/company/employee",
                "type": "B",
                "permission": "company.employee.insert",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 14:20:16",
                "updateBy": "admin",
                "updateTime": "2024-09-04 10:01:47"
            },
            {
                "id": 28,
                "parentId": 26,
                "name": "员工删除",
                "path": "/company/employee",
                "type": "B",
                "permission": "company.employee.delete",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 14:20:29",
                "updateBy": "admin",
                "updateTime": "2024-09-04 10:01:47"
            },
            {
                "id": 29,
                "parentId": 26,
                "name": "员工修改",
                "path": "/company/employee",
                "type": "B",
                "permission": "company.employee.update",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 14:20:37",
                "updateBy": "admin",
                "updateTime": "2024-09-04 10:01:47"
            },
            {
                "id": 30,
                "parentId": 26,
                "name": "员工查询",
                "path": "/company/employee",
                "type": "B",
                "permission": "company.employee.select",
                "icon": "",
                "sort": 999,
                "status": 1,
                "createBy": "admin",
                "createTime": "2024-09-03 14:20:48",
                "updateBy": "admin",
                "updateTime": "2024-09-04 10:01:47"
            }
        ],
        "user": {
            "id": 2,
            "username": "admin",
            "password": null,
            "status": 1,
            "createBy": null,
            "createTime": "2024-08-08 09:13:57",
            "updateBy": null,
            "updateTime": "2024-08-08 09:13:57"
        },
        "token": "825eca1d-f13b-4e60-a960-c5b9938b46ea"
    },
    "exception": null
}

页面布局

登录后的主页面采用上下布局,包含头部和主体,主体部分是一个可以打开标签页的容器,其中第一个标签页为主页。在主页渲染所有的菜单,点击菜单,打开对应的标签页。

布局页面 src\views\layout\Layout.vue

<script setup>
import LayoutHeader from './components/LayoutHeader.vue'
import LayoutMain from './components/LayoutMain.vue'
</script>

<template>
  <div class="common-layout">
    <el-container>
      <el-header><LayoutHeader /></el-header>
      <el-main><LayoutMain /></el-main>
    </el-container>
  </div>
</template>

<style lang="less" scoped></style>

布局页面头部 src\views\layout\components\LayoutHeader.vue

<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores'
import { Icon } from '@iconify/vue'

const toHome = () => {
  router.push('/home')
}

const toGitee = () => {
  window.open('https://gitee.com/stonebox/emsweb')
}

const toHelper = () => {
  window.open('https://stonecoding.net/application/ems')
}

const router = useRouter()
const toUser = () => {
  router.push('/user/${id}')
}

const userStore = useUserStore()
const handleLogout = () => {
  userStore.setToken('')
  userStore.setUser({})
  userStore.setUserRoleList([])
  userStore.setUserPermissionList([])
  router.push('/login')
}
</script>

<template>
  <div class="header">
    <div class="header-left" @click="toHome"></div>
    <div class="header-right">
      <ul>
        <li @click="toGitee"><Icon icon="tdesign:logo-github" width="20" height="20" /></li>
        <li @click="toHelper"><Icon icon="tdesign:help-circle" width="20" height="20" /></li>
        <li @click="toUser"><Icon icon="tdesign:user" width="20" height="20" /></li>
        <li @click="handleLogout"><Icon icon="tdesign:poweroff" width="20" height="20" /></li>
      </ul>
    </div>
  </div>
</template>

<style lang="less" scoped>
.header {
  margin: 0 auto;
  width: 100%;
  height: 40px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  .header-left {
    width: 60px;
    height: 26px;
    margin-left: 8px;
    background: white url(@/assets/images/logo.png) no-repeat center/contain;
    &:hover {
      cursor: pointer;
    }
  }
  .header-right {
    display: flex;
    justify-content: flex-end;
    height: 20px;
    ul {
      display: flex;
      li {
        height: 20px;
        margin-left: 16px;
        vertical-align: middle;
        &:hover {
          cursor: pointer;
        }
      }
      li:last-child {
        margin-right: 8px;
      }
    }
  }
}
</style>

主页包含所有的菜单链接,点击链接,就会打开一个标签页,并路由到对应的页面内容。故需要创建一个 Store 用于保存标签页对应的路由信息,当对标签页进行操作时,同时需要更新 Store 中的路由信息。

创建 src\stores\modules\tab.js

import { defineStore } from 'pinia'

export const useTabStore = defineStore(
  'tab',
  () => {
    const tabList = ref([
      {
        path: '/home',
        routeIdx: 0,
        title: '主页',
        name: 'home',
        isHome: true
      }
    ])

    const refreshing = ref(false)

    const setTabList = (arr) => {
      tabList.value = arr
    }

    const getTabList = () => {
      return tabList.value
    }

    const getTab = (path) => {
      return tabList.value.find((item) => item.path === path)
    }

    const addRouteToTabList = (newRoute) => {
      if (!tabList.value.find((item) => item.path === newRoute.path)) {
        tabList.value = tabList.value.concat({ ...newRoute })
      }
    }

    const removeRouteFromTabList = (path) => {
      tabList.value = tabList.value.filter((item) => item.path !== path)
    }

    const removeLeftRouteFromTabList = (path) => {
      const index = tabList.value.findIndex((item) => item.path === path)
      tabList.value.splice(1, index - 1)
    }

    const removeRightRouteFromTabList = (path) => {
      const index = tabList.value.findIndex((item) => item.path === path)
      tabList.value.splice(index + 1)
    }

    const removeOtherRouteFromTabList = (path) => {
      const index = tabList.value.findIndex((item) => item.path === path)
      tabList.value = index === 0 ? [tabList.value[index]] : [tabList.value[0]].concat([tabList.value[index]])
    }

    return {
      tabList,
      refreshing,
      setTabList,
      getTabList,
      getTab,
      addRouteToTabList,
      removeRouteFromTabList,
      removeLeftRouteFromTabList,
      removeRightRouteFromTabList,
      removeOtherRouteFromTabList
    }
  },
  {
    persist: true
  }
)

修改布局页面 src\views\layout\Layout.vue,在进入该页面时,将当前打开页面的路由加入到 tab.js 仓库中,并监视页面的变化,将增加的页面路由添加到 tab.js 仓库中:

<script setup>
import LayoutHeader from './components/LayoutHeader.vue'
import LayoutMain from './components/LayoutMain.vue'

import { useRoute } from 'vue-router'
import { useTabStore } from '@/stores'
const route = useRoute()
const tabStore = useTabStore()

const addRouteToTabList = () => {
  tabStore.addRouteToTabList({
    path: route.path,
    query: route.query,
    title: route.meta.title,
    name: route.name,
    isAlive: true,
    meta: route.meta
  })
}

onMounted(() => {
  addRouteToTabList()
})

watch(
  () => route.path,
  () => {
    addRouteToTabList()
  }
)
</script>

<template>
  <div class="common-layout">
    <el-container>
      <el-header><LayoutHeader /></el-header>
      <el-main><LayoutMain /></el-main>
    </el-container>
  </div>
</template>

<style lang="less" scoped>
.el-header {
  padding: 0px;
  height: 40px;
}

.el-main {
  padding: 0px;
}
</style>

布局页面主体 src\views\layout\components\LayoutMain.vue 包含标签页组件以及路由出口:

<script setup>
import LayoutMainTabs from './LayoutMainTabs .vue'
</script>

<template>
  <div class="main_container">
    <LayoutMainTabs />
    <div class="router_container">
      <router-view v-slot="{ Component }">
        <transition name="fade" mode="out-in">
          <keep-alive :max="10">
            <component :is="Component" />
          </keep-alive>
        </transition>
      </router-view>
    </div>
  </div>
</template>

<style lang="less" scoped>
.main_container {
  height: 100%;
  display: flex;
  flex-direction: column;
}

.router_container {
  flex: 1;
  width: 100%;
}

.fade-leave-active,
.fade-enter-active {
  transition: opacity 0.28s cubic-bezier(0.38, 0, 0.24, 1);
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

标签页组件 src\views\layout\components\LayoutMainTabs.vue,在打开的标签页上添加了右键菜单:

<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useTabStore } from '@/stores'
import { Icon } from '@iconify/vue'

const route = useRoute()
const router = useRouter()

const tabStore = useTabStore()
const tabRouters = computed(() => tabStore.tabList.filter((item) => item.isAlive || item.isHome))

// 关闭选项卡,根据是否有前后选项卡,进行关闭后的路由跳转
const handleTabRemove = (tabPaneName) => {
  const { tabList } = tabStore
  const index = tabList.findIndex((item) => item.path === tabPaneName)
  const nextRouter = tabList[index + 1] || tabList[index - 1]
  tabStore.removeRouteFromTabList(tabPaneName)
  if (tabPaneName === route.path) router.push({ path: nextRouter.path, query: nextRouter.query })
}

// 点击不同选项卡时,实现页面的路由跳转
const handleTabChange = (tabPaneName) => {
  const { tabList } = tabStore
  const route = tabList.find((item) => item.path === tabPaneName)
  router.push({ path: tabPaneName, query: route.query })
}

const dropdownRef = ref()
const handleChange = (visible, path) => {
  if (!visible) return
  dropdownRef.value.forEach((item) => {
    if (item.id === path) return
    item.handleClose()
  })
}

const handleCloseLeft = (path, index) => {
  tabStore.removeLeftRouteFromTabList(path)
  handleOperationEffect('left', index)
}

const handleCloseRight = (path, index) => {
  tabStore.removeRightRouteFromTabList(path)
  handleOperationEffect('right', index)
}

const handleCloseOther = (path, index) => {
  tabStore.removeOtherRouteFromTabList(path)
  handleOperationEffect('other', index)
}

// 处理非当前路由操作的副作用
const handleOperationEffect = (type, index) => {
  const currentPath = router.currentRoute.value.path
  const { tabList } = tabStore
  const currentIdx = tabList.findIndex((item) => item.path === currentPath)
  // 存在三种情况需要刷新当前路由
  // 点击非当前路由的关闭其他、点击非当前路由的关闭左侧且当前路由小于触发路由、点击非当前路由的关闭右侧且当前路由大于触发路由
  const needRefreshRouter =
    (type === 'other' && currentIdx !== index) || (type === 'left' && currentIdx < index) || (type === 'right' && currentIdx === -1)
  if (needRefreshRouter) {
    const nextRouteIdx = type === 'right' ? tabList.length - 1 : 1
    const nextRouter = tabList[nextRouteIdx]
    router.push({ path: nextRouter.path, query: nextRouter.query })
  }
}
</script>

<template>
  <div>
    <el-tabs v-model="$route.path" type="border-card" class="tabs" @tab-remove="handleTabRemove" @tab-change="handleTabChange">
      <el-tab-pane
        v-for="(item, index) in tabRouters"
        :key="`${item.path}_${index}`"
        :label="item.title"
        :name="item.path"
        :closable="!item.isHome"
      >
        <template #label>
          <el-dropdown trigger="contextmenu" ref="dropdownRef" :id="item.path" @visible-change="handleChange($event, item.path)">
            <span>
              <template v-if="!item.isHome">
                {{ item.title }}
              </template>
              <Icon v-else icon="tdesign:home" width="20" height="20" />
            </span>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item v-if="index > 1" @click="() => handleCloseLeft(item.path, index)">
                  <Icon icon="tdesign:arrow-left" width="16" height="16" />&nbsp;关闭左侧
                </el-dropdown-item>
                <el-dropdown-item v-if="index < tabRouters.length - 1" @click="() => handleCloseRight(item.path, index)">
                  <Icon icon="tdesign:arrow-right" width="16" height="16" />&nbsp;关闭右侧
                </el-dropdown-item>
                <el-dropdown-item v-if="tabRouters.length > 2" @click="() => handleCloseOther(item.path, index)">
                  <Icon icon="tdesign:close-circle" width="16" height="16" />&nbsp;关闭其它
                </el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
        </template>
      </el-tab-pane>
    </el-tabs>
  </div>
</template>

<style lang="less" scoped>
:deep(.el-tabs__content) {
  padding: 0px;
}

.label {
  color: var(--el-color-primary); //激活标签页高亮
}
:deep(.el-tabs__item) {
  &:hover {
    span {
      color: var(--el-color-primary); //鼠标移到标签页高亮
    }
  }
  .el-dropdown {
    line-height: inherit; // 统一标签页显示名称行高
    span {
      height: 20px;
    }
  }
}
</style>

主页 src\views\home\Home.vue,根据用户权限数据渲染菜单:

<script setup>
import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { useUserStore } from '@/stores'
import { listToTree } from '@/utils/list'

const router = useRouter()
const userStore = useUserStore()
const userPermissionTree = listToTree(userStore.userPermissionList.filter((item) => item.type !== 'B'))
</script>

<template>
  <div class="home">
    <el-space direction="vertical" :fill="true" class="space">
      <el-card shadow="hover" class="card" v-for="item in userPermissionTree" :key="item.id">
        <template #header>
          <div class="card-header">
            <Icon :icon="item.icon" width="20" height="20" />
            <span>{{ item.name }}</span>
          </div>
        </template>
        <ul>
          <li v-for="subItem in item.children" :key="subItem.id">
            <div @click="router.push(`${subItem.path}`)">
              <Icon :icon="subItem.icon" width="40" height="40" />
              <h5>{{ subItem.name }}</h5>
            </div>
          </li>
        </ul>
      </el-card>
    </el-space>
  </div>
</template>

<style lang="less" scoped>
:deep(.el-card__header) {
  padding: 8px;
}

:deep(.el-card__body) {
  padding: 8px;
}

.space {
  width: 100%;
}

.card {
  margin-top: 10px;
  font-size: 12px;
  .card-header {
    display: flex;
    align-items: center;
    span {
      margin-left: 5px;
    }
  }
  ul {
    display: flex;
    justify-content: flex-start;
    li {
      margin-right: 20px;
      text-align: center;
      :hover {
        transform: scale(1.05);
        cursor: pointer;
      }
    }
  }
}
</style>

用户页面

功能需求:

  • 用户的增删改查
  • 重置用户密码
  • 为用户分配角色
<script setup>
import { Edit, Key, Delete } from '@element-plus/icons-vue'
import {
  userSelectByConditionAndPageService,
  userSelectByConditionService,
  userSelectRoleByIdService,
  userInsertService,
  userUpdateService,
  userDeleteService
} from '@/api/user'
import { userRoleInsertBatchService, userRoleDeleteBatchService } from '@/api/user-role'
import { roleSelectByConditionService } from '@/api/role'
import { useUserStore } from '@/stores'

// 查询用户
const selectUserModel = ref({
  username: '',
  status: ''
})
const userStatusType = ref([
  { key: 0, display_name: '停用' },
  { key: 1, display_name: '启用' }
])
const pageParam = ref({
  pageNum: 1,
  pageSize: 10,
  orderBy: 'create_time desc'
})
const loading = ref(true)
const userList = ref([])
const total = ref(0)

const getUserList = async () => {
  const res = await userSelectByConditionAndPageService(selectUserModel.value, pageParam.value)
  userList.value = res.data.data.list
  total.value = res.data.data.total
  loading.value = false
}

getUserList()

const handleSelectUser = () => {
  pageParam.value.page = 1
  getUserList()
}

const resetSelectUser = () => {
  selectUserModel.value = {
    username: '',
    status: ''
  }
  pageParam.value = {
    pageNum: 1,
    pageSize: 10,
    orderBy: 'create_time desc'
  }
  getUserList()
}

const handleSizeChange = (pageSize) => {
  pageParam.value.pageSize = pageSize
  getUserList()
}

const handleCurrentChange = (pageNum) => {
  pageParam.value.pageNum = pageNum
  getUserList()
}

// 新增用户
const insertUserDrawer = ref(false)
const insertUserRef = ref(null)
const insertUserModel = ref({
  username: '',
  password: 'Abcd1234',
  status: 1,
  createBy: ''
})
const userStore = useUserStore()
const allRoleList = ref()

const checkInsertUsername = async (rule, value, callback) => {
  const res = await userSelectByConditionService({ username: value })
  if (res.data.data.length > 0) {
    callback(new Error('用户名已存在'))
  } else {
    callback()
  }
}

const insertUserRules = {
  username: [
    { required: true, message: '请输入账号', trigger: 'blur' },
    { min: 2, max: 20, message: '用户名必须是 2-20 位的字符', trigger: 'blur' },
    { validator: checkInsertUsername, trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { pattern: /^\S{6,20}$/, message: '密码必须是 6-20 位的非空字符', trigger: 'blur' }
  ]
}

const getRoleList = async () => {
  const res = await roleSelectByConditionService({ status: 1 })
  allRoleList.value = res.data.data
}

const insertUser = () => {
  getRoleList()
  insertUserDrawer.value = true
}

const roleRef = ref(null)

// 角色的筛选
const filterNode = (value, data) => {
  if (!value) return true
  return data.name.includes(value)
}
const filterText = ref('')
watch(filterText, (val) => {
  roleRef.value?.filter(val)
})

const handleInsertUser = async () => {
  await insertUserRef.value.validate()
  insertUserModel.value.createBy = userStore.user.username
  const res = await userInsertService(insertUserModel.value)

  const userId = res.data.data.id
  const checkedKeys = [...roleRef.value.getCheckedKeys()]
  if (checkedKeys.length > 0) {
    const userRoleList = checkedKeys.map((item) => {
      return {
        userId,
        roleId: item,
        createBy: userStore.user.username
      }
    })
    await userRoleInsertBatchService(userRoleList)
  }

  ElMessage.success('新增成功')
  insertUserDrawer.value = false
  insertUserRef.value.resetFields()
  selectUserModel.value.id = res.data.data.id
  getUserList()
}

// 修改用户状态
/* const handleUpdateStatus = async (row) => {
  row.updateBy = userStore.user.username
  await userUpdateService(row)
  ElMessage.success('修改成功')
  getUserList()
} */

// 修改用户
const updateUserDrawer = ref(false)
const updateUserRef = ref(null)
const updateUserModel = ref({})
let originalKeys = []
let originalUser = {}

const updateUser = async (row) => {
  originalUser = { ...row }
  const res = await userSelectRoleByIdService(row.id)
  originalKeys = res.data.data?.map((item) => item.id)
  getRoleList()
  updateUserModel.value = { ...row }
  updateUserDrawer.value = true
}

const checkUpdateUsername = async (rule, value, callback) => {
  if (originalUser.username === value) {
    callback()
    return
  }

  const res = await userSelectByConditionService({ username: value })
  if (res.data.data.length > 0) {
    callback(new Error('用户名已存在'))
  } else {
    callback()
  }
}

const updateUserRules = {
  username: [
    { required: true, message: '请输入账号', trigger: 'blur' },
    { min: 2, max: 20, message: '用户名必须是 2-20 位的字符', trigger: 'blur' },
    { validator: checkUpdateUsername, trigger: 'blur' }
  ],
  password: [{ pattern: /^\S{6,20}$/, message: '密码必须是 6-20 位的非空字符', trigger: 'blur' }]
}

const handleUpdateUser = async () => {
  await updateUserRef.value.validate()
  updateUserModel.value.updateBy = userStore.user.username
  await userUpdateService(updateUserModel.value)

  const checkedKeys = [...roleRef.value.getCheckedKeys()]

  const insertRoleIds = checkedKeys.filter((item) => !originalKeys.includes(item))
  if (insertRoleIds.length > 0) {
    const userRoleInsertList = insertRoleIds.map((item) => {
      return {
        userId: updateUserModel.value.id,
        roleId: item,
        createBy: userStore.user.username
      }
    })
    await userRoleInsertBatchService(userRoleInsertList)
  }

  const deleteRoleIds = originalKeys.filter((item) => !checkedKeys.includes(item))
  if (deleteRoleIds.length > 0) {
    const userRoleDeleteList = deleteRoleIds.map((item) => {
      return {
        userId: updateUserModel.value.id,
        roleId: item
      }
    })
    await userRoleDeleteBatchService(userRoleDeleteList)
  }

  ElMessage.success('修改成功')
  updateUserDrawer.value = false
  updateUserRef.value.resetFields()
  selectUserModel.value.id = updateUserModel.value.id
  getUserList()
}

// 重置密码
const resetPasswordDrawer = ref(false)
const resetPasswordRef = ref(null)
const resetPasswordModel = ref({
  id: '',
  password: 'Abcd1234',
  updateBy: ''
})
const resetPasswordRules = {
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { pattern: /^\S{6,20}$/, message: '密码必须是 6-20 位的非空字符', trigger: 'blur' }
  ]
}

const resetPassword = async (row) => {
  resetPasswordModel.value.id = row.id
  resetPasswordDrawer.value = true
}

const handleResetPassword = async () => {
  await resetPasswordRef.value.validate()
  resetPasswordModel.value.updateBy = userStore.user.username
  await userUpdateService(resetPasswordModel.value)
  ElMessage.success('重置密码成功')
  resetPasswordDrawer.value = false
  getUserList()
}

// 删除用户
const deleteUser = (row) => {
  ElMessageBox.confirm('此操作将永久删除用户 ' + row.username + ' , 是否继续?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(async () => {
      await userDeleteService(row.id)
      ElMessage.success('删除成功')
      getUserList()
    })
    .catch((err) => err)
}
</script>

<template>
  <div class="user_container">
    <el-card style="border: 0px; height: 100%">
      <el-form ref="selectUserRef" :model="selectUserModel" :inline="true" label-width="0">
        <el-form-item>
          <el-input v-model="selectUserModel.username" placeholder="用户名" maxlength="20" clearable />
        </el-form-item>
        <el-form-item>
          <el-select v-model="selectUserModel.status" placeholder="状态" clearable style="width: 120px">
            <el-option v-for="item in userStatusType" :key="item.key" :label="item.display_name" :value="item.key" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSelectUser"> 查询 </el-button>
          <el-button type="default" @click="resetSelectUser"> 重置 </el-button>
        </el-form-item>
      </el-form>
      <el-button type="success" @click="insertUser" v-permission="['system.user.insert']"> 新增 </el-button>

      <div class="table_container">
        <el-table v-loading="loading" :data="userList" stripe>
          <el-table-column label="序号" type="index" align="center" width="80" />
          <el-table-column prop="username" label="用户名" align="center" width="120" />
          <el-table-column prop="createBy" label="创建者" align="center" width="120" />
          <el-table-column prop="createTime" label="创建时间" align="center" width="180" />
          <el-table-column prop="updateBy" label="修改者" align="center" width="120" />
          <el-table-column prop="updateTime" label="修改时间" align="center" width="180" />
          <el-table-column label="状态" align="center" width="80">
            <template #default="{ row }">
              <!-- <el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="handleUpdateStatus(row)"></el-switch> -->
              <el-tag v-if="row.status === 1">启用</el-tag>
              <el-tag type="warning" v-else>停用</el-tag>
            </template>
          </el-table-column>
          <el-table-column label="操作" align="center" width="120" v-permission="['system.user.update', 'system.user.delete']">
            <template #default="{ row }">
              <el-button :icon="Edit" circle plain type="primary" @click="updateUser(row)" v-permission="['system.user.update']"></el-button>
              <el-button :icon="Key" circle plain type="primary" @click="resetPassword(row)"></el-button>
              <el-button :icon="Delete" circle plain type="danger" @click="deleteUser(row)" v-permission="['system.user.delete']"></el-button>
            </template>
          </el-table-column>
          <template #empty>
            <el-empty description="没有数据" v-if="!loading" />
          </template>
        </el-table>
        <el-pagination
          :current-page="pageParam.pageNum"
          :page-size="pageParam.pageSize"
          :total="total"
          size="small"
          background
          layout="<-, total, sizes, prev, pager, next, jumper"
          class="mt-4"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>

    <el-drawer v-model="insertUserDrawer" title="新增用户" :show-close="false">
      <div class="drawer_body">
        <el-form ref="insertUserRef" :model="insertUserModel" :rules="insertUserRules" label-width="auto">
          <el-form-item label="用户名" prop="username">
            <el-input v-model.trim="insertUserModel.username" clearable />
          </el-form-item>
          <el-form-item label="初始密码" prop="password">
            <el-input v-model.trim="insertUserModel.password" clearable />
          </el-form-item>
          <el-form-item label="状态" prop="status">
            <el-radio-group v-model="insertUserModel.status">
              <el-radio :value="1">启用</el-radio>
              <el-radio :value="0">禁用</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="角色">
            <el-input v-model="filterText" placeholder="请输入角色名称进行过滤" />
            <el-tree
              :data="allRoleList"
              :default-checked-keys="[2]"
              :props="{ label: 'name', children: 'children' }"
              show-checkbox
              node-key="id"
              ref="roleRef"
              class="tree-border"
              :filter-node-method="filterNode"
            ></el-tree>
          </el-form-item>
        </el-form>
      </div>
      <div class="drawer_footer">
        <el-button type="primary" @click="handleInsertUser">确认</el-button>
        <el-button @click="insertUserDrawer = false">取消</el-button>
      </div>
    </el-drawer>

    <el-drawer v-model="updateUserDrawer" title="修改用户" :show-close="false">
      <div class="drawer_body">
        <el-form ref="updateUserRef" :model="updateUserModel" :rules="updateUserRules" label-width="auto">
          <el-form-item label="用户名" prop="username">
            <el-input v-model.trim="updateUserModel.username" clearable />
          </el-form-item>
          <el-form-item label="重置密码" prop="password">
            <el-input v-model.trim="updateUserModel.password" clearable />
          </el-form-item>
          <el-form-item label="状态" prop="status">
            <el-radio-group v-model="updateUserModel.status">
              <el-radio :value="1">启用</el-radio>
              <el-radio :value="0">禁用</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="角色">
            <el-input v-model="filterText" placeholder="请输入角色名称进行过滤" />
            <el-tree
              :data="allRoleList"
              :default-checked-keys="originalKeys"
              :props="{ label: 'name', children: 'children' }"
              show-checkbox
              node-key="id"
              ref="roleRef"
              class="tree-border"
              :filter-node-method="filterNode"
            ></el-tree>
          </el-form-item>
        </el-form>
      </div>
      <div class="drawer_footer">
        <el-button type="primary" @click="handleUpdateUser">确认</el-button>
        <el-button @click="updateUserDrawer = false">取消</el-button>
      </div>
    </el-drawer>

    <el-drawer v-model="resetPasswordDrawer" title="重置密码" :show-close="false">
      <div class="drawer_body">
        <el-form ref="resetPasswordRef" :model="resetPasswordModel" :rules="resetPasswordRules" label-width="auto">
          <el-form-item label="重置密码" prop="password">
            <el-input v-model="resetPasswordModel.password" clearable />
          </el-form-item>
        </el-form>
        <div class="drawer_footer">
          <el-button type="primary" @click="handleResetPassword">确认</el-button>
          <el-button @click="resetPasswordDrawer = false">取消</el-button>
        </div>
      </div>
    </el-drawer>
  </div>
</template>

<style lang="less" scoped>
.user_container {
  height: 100%;
  position: relative;
  // overflow: hidden;
}

// .table_container {
//   display: flex;
//   flex-direction: column;
//   // position: absolute;
//   // width: 100%;
//   // height: 100%;
// }

:deep(.el-overlay) {
  position: absolute;
}

:deep(.el-drawer__title) {
  text-align: center;
}

.drawer_footer {
  margin-top: 40px;
  text-align: center;
}

:deep(.el-table__inner-wrapper)::before {
  width: 0px;
}

.tree-border {
  margin-top: 5px;
  border: 1px solid #e5e6e7;
  background: #ffffff none;
  border-radius: 4px;
  width: 100%;
}
</style>

角色页面

功能需求:

  • 角色的增删改查
  • 为角色分配权限
<script setup>
import { Edit, Delete } from '@element-plus/icons-vue'
import {
  roleSelectByConditionAndPageService,
  roleSelectByConditionService,
  roleSelectPermissionByIdService,
  roleInsertService,
  roleUpdateService,
  roleDeleteService
} from '@/api/role'
import { rolePermissionInsertBatchService, rolePermissionDeleteBatchService } from '@/api/role-permission'
import { listToTree } from '@/utils/list'
import { permissionSelectByConditionService } from '@/api/permission'
import { useUserStore } from '@/stores'

// 查询角色
const selectRoleModel = ref({
  name: '',
  status: ''
})
const roleStatusType = ref([
  { key: 0, display_name: '停用' },
  { key: 1, display_name: '启用' }
])
const pageParam = ref({
  pageNum: 1,
  pageSize: 10,
  orderBy: 'create_time desc'
})
const loading = ref(true)
const roleList = ref([])
const total = ref(0)

const getRoleList = async () => {
  const res = await roleSelectByConditionAndPageService(selectRoleModel.value, pageParam.value)
  roleList.value = res.data.data.list
  total.value = res.data.data.total
  loading.value = false
}

getRoleList()

const handleSelectRole = () => {
  pageParam.value.page = 1
  getRoleList()
}

const resetSelectRole = () => {
  selectRoleModel.value = {
    name: '',
    status: ''
  }
  pageParam.value = {
    pageNum: 1,
    pageSize: 10,
    orderBy: 'create_time desc'
  }
  getRoleList()
}

const handleSizeChange = (pageSize) => {
  pageParam.value.pageSize = pageSize
  getRoleList()
}

const handleCurrentChange = (pageNum) => {
  pageParam.value.pageNum = pageNum
  getRoleList()
}

// 新增角色
const insertRoleDrawer = ref(false)
const insertRoleRef = ref(null)
const insertRoleModel = ref({
  name: '',
  description: '',
  status: 1,
  createBy: ''
})

const permissionList = ref([])
const permissionTree = ref()
const userStore = useUserStore()

const checkInsertRoleName = async (rule, value, callback) => {
  const res = await roleSelectByConditionService({ name: value })
  if (res.data.data.length > 0) {
    callback(new Error('名称已存在'))
  } else {
    callback()
  }
}

const insertRoleRules = {
  name: [
    { required: true, message: '请输入名称', trigger: 'blur' },
    { min: 2, max: 20, message: '名称必须是 2-20 位的字符', trigger: 'blur' },
    { validator: checkInsertRoleName, trigger: 'blur' }
  ],
  description: [{ max: 128, message: '描述不能超过 128 个的字符', trigger: 'blur' }]
}

const getPermissionList = async () => {
  const res = await permissionSelectByConditionService({ status: 1 })
  permissionList.value = res.data.data
  permissionTree.value = listToTree(permissionList.value)
  if (permissionTree.value.length === 0) {
    permissionTree.value = permissionList.value
  }
}

const insertRole = () => {
  getPermissionList()
  insertRoleDrawer.value = true
}

// 树的展开/折叠
const treeRef = ref(null)
const treeExpand = ref(false)
const toogleTreeExpand = (value) => {
  for (let i = 0; i < permissionList.value.length; i++) {
    treeRef.value.store.nodesMap[permissionList.value[i].id].expanded = value
  }
}

// 树的全选/全不选
const treeSelect = ref(false)
const toogleTreeSelect = (value) => {
  treeRef.value.setCheckedNodes(value ? permissionList.value : [])
}

const resetTree = () => {
  treeExpand.value = false
  treeSelect.value = false
}

// 树的筛选
const filterNode = (value, data) => {
  if (!value) return true
  return data.name.includes(value)
}
const filterText = ref('')
watch(filterText, (val) => {
  treeRef.value?.filter(val)
})

const handleInsertRole = async () => {
  await insertRoleRef.value.validate()
  insertRoleModel.value.createBy = userStore.user.username
  const res = await roleInsertService(insertRoleModel.value)
  const roleId = res.data.data.id
  const checkedKeys = [...treeRef.value.getCheckedKeys(), ...treeRef.value.getHalfCheckedKeys()]
  if (checkedKeys.length > 0) {
    const rolePermissionList = checkedKeys.map((item) => {
      return {
        roleId,
        permissionId: item,
        createBy: userStore.user.username
      }
    })
    await rolePermissionInsertBatchService(rolePermissionList)
  }
  ElMessage.success('新增成功')
  insertRoleDrawer.value = false
  insertRoleRef.value.resetFields()
  resetTree()
  selectRoleModel.value.id = res.data.data.id
  getRoleList()
}

const cancelInsertRole = () => {
  insertRoleDrawer.value = false
  insertRoleRef.value.resetFields()
  resetTree()
}

// 修改角色状态
const handleUpdateStatus = async (row) => {
  row.updateBy = userStore.user.username
  await roleUpdateService(row)
  ElMessage.success('修改成功')
  getRoleList()
}

// 修改角色
const updateRoleDrawer = ref(false)
const updateRoleRef = ref(null)
const updateRoleModel = ref({})
let originalKeys = []
let defaultCheckedKeys = []
let originalRole = {}
const updateRole = async (row) => {
  originalRole = { ...row }
  const res = await roleSelectPermissionByIdService(row.id)
  originalKeys = res.data.data?.map((item) => item.id)
  defaultCheckedKeys = res.data.data?.filter((item) => item.type === 'B').map((item) => item.id)
  getPermissionList()
  updateRoleDrawer.value = true
  updateRoleModel.value = { ...row }
}

const checkUpdateRoleName = async (rule, value, callback) => {
  if (originalRole.name === value) {
    callback()
    return
  }

  const res = await roleSelectByConditionService({ name: value })
  if (res.data.data.length > 0) {
    callback(new Error('名称已存在'))
  } else {
    callback()
  }
}

const updateRoleRules = {
  name: [
    { required: true, message: '请输入名称', trigger: 'blur' },
    { min: 2, max: 20, message: '名称必须是 2-20 位的字符', trigger: 'blur' },
    { validator: checkUpdateRoleName, trigger: 'blur' }
  ],
  description: [{ max: 128, message: '描述不能超过 128 个的字符', trigger: 'blur' }]
}

const handleUpdateRole = async () => {
  await updateRoleRef.value.validate()

  updateRoleModel.value.updateBy = userStore.user.username
  await roleUpdateService(updateRoleModel.value)

  const checkedKeys = [...treeRef.value.getCheckedKeys(), ...treeRef.value.getHalfCheckedKeys()]

  const insertPermissionIds = checkedKeys.filter((item) => !originalKeys.includes(item))
  if (insertPermissionIds.length > 0) {
    const rolePermissionInsertList = insertPermissionIds.map((item) => {
      return {
        roleId: updateRoleModel.value.id,
        permissionId: item,
        createBy: userStore.user.username
      }
    })
    await rolePermissionInsertBatchService(rolePermissionInsertList)
  }

  const deletePermissionIds = originalKeys.filter((item) => !checkedKeys.includes(item))
  if (deletePermissionIds.length > 0) {
    const rolePermissionDeleteList = deletePermissionIds.map((item) => {
      return {
        roleId: updateRoleModel.value.id,
        permissionId: item
      }
    })
    await rolePermissionDeleteBatchService(rolePermissionDeleteList)
  }

  ElMessage.success('修改成功')
  updateRoleDrawer.value = false
  updateRoleRef.value.resetFields()
  resetTree()
  selectRoleModel.value.id = updateRoleModel.value.id
  getRoleList()
}

const cancelUpdateRole = () => {
  updateRoleDrawer.value = false
  updateRoleRef.value.resetFields()
  resetTree()
}

// 删除角色
const deleteRole = (row) => {
  ElMessageBox.confirm('此操作将永久删除角色 ' + row.name + ' , 是否继续?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(async () => {
      await roleDeleteService(row.id)
      ElMessage.success('删除成功')
      getRoleList()
    })
    .catch((err) => err)
}
</script>

<template>
  <div class="role_container">
    <el-card style="border: 0px; height: 100%">
      <el-form ref="selectRoleRef" :model="selectRoleModel" :inline="true" label-width="0">
        <el-form-item>
          <el-input v-model="selectRoleModel.name" placeholder="角色名" maxlength="20" clearable />
        </el-form-item>
        <el-form-item>
          <el-select v-model="selectRoleModel.status" placeholder="状态" clearable style="width: 120px">
            <el-option v-for="item in roleStatusType" :key="item.key" :label="item.display_name" :value="item.key" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSelectRole"> 查询 </el-button>
          <el-button type="default" @click="resetSelectRole"> 重置 </el-button>
        </el-form-item>
      </el-form>
      <el-button type="success" @click="insertRole"> 新增 </el-button>

      <div class="table_container">
        <el-table v-loading="loading" :data="roleList" stripe>
          <el-table-column label="序号" type="index" align="center" width="80" />
          <el-table-column prop="name" label="角色名" align="center" width="120" />
          <el-table-column prop="description" label="描述" align="center" width="180" />
          <el-table-column prop="createBy" label="创建者" align="center" width="80" />
          <el-table-column prop="createTime" label="创建时间" align="center" width="180" />
          <el-table-column prop="updateBy" label="修改者" align="center" width="80" />
          <el-table-column prop="updateTime" label="修改时间" align="center" width="180" />
          <el-table-column label="状态" align="center" width="80">
            <template #default="{ row }">
              <el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="handleUpdateStatus(row)"></el-switch>
            </template>
          </el-table-column>
          <el-table-column label="操作" align="center" width="120">
            <template #default="{ row }">
              <el-button :icon="Edit" circle plain type="primary" @click="updateRole(row)"></el-button>
              <el-button :icon="Delete" circle plain type="danger" @click="deleteRole(row)"></el-button>
            </template>
          </el-table-column>
          <template #empty>
            <el-empty description="没有数据" v-if="!loading" />
          </template>
        </el-table>
        <el-pagination
          :current-page="pageParam.pageNum"
          :page-size="pageParam.pageSize"
          :total="total"
          size="small"
          background
          layout="<-, total, sizes, prev, pager, next, jumper"
          class="mt-4"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>

    <el-drawer v-model="insertRoleDrawer" title="新增角色" :show-close="false">
      <div class="drawer_body">
        <el-form ref="insertRoleRef" :model="insertRoleModel" :rules="insertRoleRules" label-width="auto">
          <el-form-item label="角色名" prop="name">
            <el-input v-model.trim="insertRoleModel.name" clearable />
          </el-form-item>
          <el-form-item label="描述" prop="description">
            <el-input v-model="insertRoleModel.description" clearable />
          </el-form-item>
          <el-form-item label="状态" prop="status">
            <el-radio-group v-model="insertRoleModel.status">
              <el-radio :value="1">启用</el-radio>
              <el-radio :value="0">禁用</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="权限">
            <el-checkbox v-model="treeExpand" @change="toogleTreeExpand">展开/折叠</el-checkbox>
            <el-checkbox v-model="treeSelect" @change="toogleTreeSelect">全选/全不选</el-checkbox>
            <el-input v-model="filterText" placeholder="请输入权限名称进行过滤" />
            <el-tree
              :data="permissionTree"
              :props="{ label: 'name', children: 'children' }"
              show-checkbox
              node-key="id"
              ref="treeRef"
              class="tree-border"
              :filter-node-method="filterNode"
            ></el-tree>
          </el-form-item>
        </el-form>
      </div>
      <div class="drawer_footer">
        <el-button type="primary" @click="handleInsertRole">确认</el-button>
        <el-button @click="cancelInsertRole">取消</el-button>
      </div>
    </el-drawer>

    <el-drawer v-model="updateRoleDrawer" title="修改角色" :show-close="false">
      <div class="drawer_body">
        <el-form ref="updateRoleRef" :model="updateRoleModel" :rules="updateRoleRules" label-width="auto">
          <el-form-item label="角色名" prop="name">
            <el-input v-model.trim="updateRoleModel.name" clearable />
          </el-form-item>
          <el-form-item label="描述" prop="description">
            <el-input v-model="updateRoleModel.description" clearable />
          </el-form-item>
          <el-form-item label="状态" prop="status">
            <el-radio-group v-model="updateRoleModel.status">
              <el-radio :value="1">启用</el-radio>
              <el-radio :value="0">禁用</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="权限">
            <el-checkbox v-model="treeExpand" @change="toogleTreeExpand">展开/折叠</el-checkbox>
            <el-checkbox v-model="treeSelect" @change="toogleTreeSelect">全选/全不选</el-checkbox>
            <el-input v-model="filterText" placeholder="请输入权限名称进行过滤" />
            <el-tree
              :data="permissionTree"
              :default-checked-keys="defaultCheckedKeys"
              :props="{ label: 'name', children: 'children' }"
              show-checkbox
              node-key="id"
              ref="treeRef"
              class="tree-border"
              :filter-node-method="filterNode"
            ></el-tree>
          </el-form-item>
        </el-form>
      </div>
      <div class="drawer_footer">
        <el-button type="primary" @click="handleUpdateRole">确认</el-button>
        <el-button @click="cancelUpdateRole">取消</el-button>
      </div>
    </el-drawer>
  </div>
</template>

<style lang="less" scoped>
.role_container {
  height: 100%;
  position: relative;
}

:deep(.el-overlay) {
  position: absolute;
}

:deep(.el-drawer__title) {
  text-align: center;
}

.drawer_footer {
  margin-top: 40px;
  text-align: center;
}

:deep(.el-table__inner-wrapper)::before {
  width: 0px;
}

.tree-border {
  margin-top: 5px;
  border: 1px solid #e5e6e7;
  background: #ffffff none;
  border-radius: 4px;
  width: 100%;
}
</style>

权限页面

功能需求:

  • 权限的增删改查
  • 使用树形表格展示权限数据
<script setup>
import { Edit, Delete } from '@element-plus/icons-vue'
import { Icon } from '@iconify/vue'
import { listToTree, getNodeAndDescendants } from '@/utils/list'
import {
  permissionSelectByConditionService,
  permissionInsertService,
  permissionInsertOrUpdateBatchService,
  permissionDeleteBatchService,
  permissionUpdateService
} from '@/api/permission'
import { flatColorIcons } from '@/utils/iconify'
import { useUserStore } from '@/stores'

// 查询权限
const selectPermissionModel = ref({
  name: '',
  status: ''
})
const permissionStatusType = ref([
  { key: 0, display_name: '停用' },
  { key: 1, display_name: '启用' }
])
const loading = ref(true)
const permissionList = ref([])
const permissionTree = ref()

const getPermissionList = async () => {
  const res = await permissionSelectByConditionService(selectPermissionModel.value)
  permissionList.value = res.data.data
  permissionTree.value = listToTree(permissionList.value)
  if (permissionTree.value.length === 0) {
    permissionTree.value = permissionList.value
  }
  loading.value = false
}

getPermissionList()

const handleSelectPermission = () => {
  getPermissionList()
}

const resetSelectPermission = () => {
  selectPermissionModel.value = {
    username: '',
    status: ''
  }
  getPermissionList()
}

// 新增权限
const insertPermissionDrawer = ref(false)
const insertPermissionRef = ref(null)
const insertPermissionModel = ref({
  parentId: '',
  name: '',
  icon: '',
  type: 'D',
  path: '',
  permission: '',
  sort: 999,
  status: 1,
  createBy: ''
})

const checInsertPermissionName = async (rule, value, callback) => {
  if (permissionList.value.filter((item) => item.name === value).length > 0) {
    callback(new Error('名称已存在'))
  } else {
    callback()
  }
}

const insertPermissionRules = {
  parentId: [{ required: true, message: '请选择上级', trigger: 'blur' }],
  name: [
    { required: true, message: '请输入名称', trigger: 'blur' },
    { max: 8, message: '名称不能超过 8 个的字符', trigger: 'blur' },
    { validator: checInsertPermissionName, trigger: 'blur' }
  ],
  icon: [{ required: true, message: '请选择图标', trigger: 'blur' }],
  path: [
    { required: true, message: '请输入路径', trigger: 'blur' },
    { max: 128, message: '路径不能超过 128 个的字符', trigger: 'blur' }
  ],
  permission: [
    { required: true, message: '请输入权限', trigger: 'blur' },
    { max: 128, message: '权限不能超过 64 个的字符', trigger: 'blur' }
  ]
}

const radioChange = () => {
  insertPermissionModel.value.parentId = ''
  insertPermissionModel.value.icon = ''
}

const directoryList = computed(() => {
  return permissionList.value.filter((item) => item.type === 'D')
})

const menuList = computed(() => {
  return permissionList.value.filter((item) => item.type === 'M')
})

const userStore = useUserStore()

const handleInsertPermission = async () => {
  await insertPermissionRef.value.validate()
  insertPermissionModel.value.createBy = userStore.user.username
  if (insertPermissionModel.value.type === 'D') {
    insertPermissionModel.value.parentId = 0
  }
  await permissionInsertService(insertPermissionModel.value)
  ElMessage.success('新增成功')
  getPermissionList()
}

const handleInsertPermissionAndExit = async () => {
  await handleInsertPermission()
  insertPermissionDrawer.value = false
  insertPermissionRef.value.resetFields()
}

// 修改状态
const handleUpdateStatus = async (row) => {
  const handledPermissionList = getNodeAndDescendants(permissionList.value, row.id)
  handledPermissionList.forEach((item) => {
    item.status = row.status
    item.updateBy = userStore.user.username
  })
  await permissionInsertOrUpdateBatchService(handledPermissionList)
  ElMessage.success('更新成功')
  getPermissionList()
}

// 修改权限
const updatePermissionDrawer = ref(false)
const updatePermissionRef = ref(null)
const updatePermissionModel = ref({})

const checUpdatePermissionName = async (rule, value, callback) => {
  if (permissionList.value.filter((item) => item.id !== updatePermissionModel.value.id).filter((item) => item.name === value).length > 0) {
    callback(new Error('名称已存在'))
  } else {
    callback()
  }
}

const updatePermissionRules = {
  parentId: [{ required: true, message: '上级不能为空', trigger: 'blur' }],
  name: [
    { required: true, message: '名称不能为空', trigger: 'blur' },
    { max: 8, message: '名称不能超过 8 个的字符', trigger: 'blur' },
    { validator: checUpdatePermissionName, trigger: 'blur' }
  ],
  icon: [{ required: true, message: '图标不能为空', trigger: 'blur' }],
  path: [
    { required: true, message: '路径不能为空', trigger: 'blur' },
    { max: 128, message: '路径不能超过 128 个的字符', trigger: 'blur' }
  ],
  permission: [
    { required: true, message: '权限不能为空', trigger: 'blur' },
    { max: 128, message: '权限不能超过 64 个的字符', trigger: 'blur' }
  ]
}

const updatePermission = (row) => {
  updatePermissionModel.value = { ...row }
  updatePermissionDrawer.value = true
  updatePermissionModel.value.updateBy = userStore.user.username
}

const handleupdatePermission = async () => {
  await updatePermissionRef.value.validate()
  await permissionUpdateService(updatePermissionModel.value)
  ElMessage.success('修改成功')
  getPermissionList()
  updatePermissionDrawer.value = false
}

// 删除权限
const deletePermission = (row) => {
  ElMessageBox.confirm('此操作将永久删除权限 ' + row.name + ' 及其下级权限, 是否继续?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(async () => {
      const handledPermissionList = getNodeAndDescendants(permissionList.value, row.id)
      const ids = handledPermissionList.map((item) => item.id)
      await permissionDeleteBatchService(ids)
      ElMessage.success('删除成功')
      getPermissionList()
    })
    .catch((err) => err)
}
</script>

<template>
  <div class="permission_container">
    <el-card style="border: 0px; height: 100%">
      <el-form ref="selectPermissionRef" :model="selectPermissionModel" :inline="true" label-width="0">
        <el-form-item>
          <el-input v-model="selectPermissionModel.name" placeholder="权限名" maxlength="20" clearable />
        </el-form-item>
        <el-form-item>
          <el-select v-model="selectPermissionModel.status" placeholder="状态" clearable style="width: 120px">
            <el-option v-for="item in permissionStatusType" :key="item.key" :label="item.display_name" :value="item.key" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSelectPermission"> 查询 </el-button>
          <el-button type="default" @click="resetSelectPermission"> 重置 </el-button>
        </el-form-item>
      </el-form>
      <el-button type="success" @click="insertPermissionDrawer = true"> 新增 </el-button>

      <div class="table_container">
        <el-table
          v-loading="loading"
          :data="permissionTree"
          stripe
          :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
          row-key="id"
        >
          <el-table-column prop="name" label="名称" width="180" />
          <el-table-column prop="icon" label="图标" align="center" width="80">
            <template #default="{ row }">
              <Icon :icon="row.icon" width="20" height="20" />
            </template>
          </el-table-column>
          <el-table-column prop="type" label="类型" align="center" width="120" :show-overflow-tooltip="true">
            <template #default="{ row }">
              <el-tag v-if="row.type === 'D'">目录</el-tag>
              <el-tag type="success" v-else-if="row.type === 'M'">菜单</el-tag>
              <el-tag type="warning" v-else>按钮</el-tag>
            </template>
          </el-table-column>

          <el-table-column prop="path" label="路径" width="180" />
          <el-table-column prop="permission" label="权限" width="180" />
          <el-table-column prop="sort" label="排序" align="center" width="40" />
          <el-table-column prop="createTime" label="创建时间" align="center" width="180" />
          <el-table-column label="状态" align="center" width="80">
            <template #default="{ row }">
              <el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="handleUpdateStatus(row)"></el-switch>
            </template>
          </el-table-column>
          <el-table-column label="操作" align="center" width="120">
            <template #default="{ row }">
              <el-button :icon="Edit" circle plain type="primary" @click="updatePermission(row)"></el-button>
              <el-button :icon="Delete" circle plain type="danger" @click="deletePermission(row)"></el-button>
            </template>
          </el-table-column>
          <template #empty>
            <el-empty description="没有数据" v-if="!loading" />
          </template>
        </el-table>
      </div>
    </el-card>

    <el-drawer v-model="insertPermissionDrawer" title="新增权限" :show-close="false" size="38%">
      <div class="drawer_body">
        <el-form ref="insertPermissionRef" :model="insertPermissionModel" :rules="insertPermissionRules" label-width="auto">
          <el-form-item label="类型" prop="type">
            <el-radio-group v-model="insertPermissionModel.type" @change="radioChange">
              <el-radio-button value="D" @change="radioChange">目录</el-radio-button>
              <el-radio-button value="M" @change="radioChange">菜单</el-radio-button>
              <el-radio-button value="B" @change="radioChange">按钮</el-radio-button>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="名称" prop="name">
            <el-input v-model.trim="insertPermissionModel.name" />
          </el-form-item>
          <el-form-item v-if="insertPermissionModel.type !== 'B'" label="图标" prop="icon">
            <el-select v-model="insertPermissionModel.icon" placeholder="请选择" filterable>
              <el-option v-for="(item, index) in flatColorIcons" :key="index" :label="item" :value="item">
                <span style="float: left"><Icon :icon="item" width="20" height="20" /></span>
                <span style="float: right">{{ item }}</span>
              </el-option>
            </el-select>
          </el-form-item>
          <el-form-item v-if="insertPermissionModel.type === 'M'" label="上级" prop="parentId">
            <el-select v-model="insertPermissionModel.parentId" placeholder="请选择">
              <el-option v-for="item in directoryList" :key="item.id" :label="item.name" :value="item.id"> </el-option>
            </el-select>
          </el-form-item>
          <el-form-item v-if="insertPermissionModel.type === 'B'" label="上级" prop="parentId">
            <el-select v-model="insertPermissionModel.parentId" placeholder="请选择">
              <el-option v-for="item in menuList" :key="item.id" :label="item.name" :value="item.id"> </el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="路径" prop="path">
            <el-input v-model="insertPermissionModel.path" />
          </el-form-item>
          <el-form-item label="权限" prop="permission">
            <el-input v-model="insertPermissionModel.permission" />
          </el-form-item>
          <el-form-item label="状态" prop="status">
            <el-radio-group v-model="insertPermissionModel.status">
              <el-radio :value="1">启用</el-radio>
              <el-radio :value="0">禁用</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="排序" prop="sort">
            <el-input-number v-model="insertPermissionModel.sort" :min="1" :max="999" controls-position="right" />
          </el-form-item>
        </el-form>
      </div>
      <div class="drawer_footer">
        <el-button type="primary" @click="handleInsertPermission">确认</el-button>
        <el-button type="primary" @click="handleInsertPermissionAndExit">确认并退出</el-button>
        <el-button @click="insertPermissionDrawer = false">取消</el-button>
      </div>
    </el-drawer>

    <el-drawer v-model="updatePermissionDrawer" title="修改权限" :show-close="false" size="38%">
      <div class="drawer_body">
        <el-form ref="updatePermissionRef" :model="updatePermissionModel" :rules="updatePermissionRules" label-width="auto">
          <el-form-item label="类型" prop="type">
            <el-radio-group v-model="updatePermissionModel.type" @change="radioChange">
              <el-radio-button value="D" @change="radioChange">目录</el-radio-button>
              <el-radio-button value="M" @change="radioChange">菜单</el-radio-button>
              <el-radio-button value="B" @change="radioChange">按钮</el-radio-button>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="名称" prop="name">
            <el-input v-model.trim="updatePermissionModel.name" />
          </el-form-item>
          <el-form-item v-if="updatePermissionModel.type !== 'B'" label="图标" prop="icon">
            <el-select v-model="updatePermissionModel.icon" placeholder="请选择" filterable>
              <el-option v-for="(item, index) in flatColorIcons" :key="index" :label="item" :value="item">
                <span style="float: left"><Icon :icon="item" width="20" height="20" /></span>
                <span style="float: right">{{ item }}</span>
              </el-option>
            </el-select>
          </el-form-item>
          <el-form-item v-if="updatePermissionModel.type === 'M'" label="上级" prop="parentId">
            <el-select v-model="updatePermissionModel.parentId" placeholder="请选择">
              <el-option v-for="item in directoryList" :key="item.id" :label="item.name" :value="item.id"> </el-option>
            </el-select>
          </el-form-item>
          <el-form-item v-if="updatePermissionModel.type === 'B'" label="上级" prop="parentId">
            <el-select v-model="updatePermissionModel.parentId" placeholder="请选择">
              <el-option v-for="item in menuList" :key="item.id" :label="item.name" :value="item.id"> </el-option>
            </el-select>
          </el-form-item>
          <el-form-item label="路径" prop="path">
            <el-input v-model="updatePermissionModel.path" />
          </el-form-item>
          <el-form-item label="权限" prop="permission">
            <el-input v-model="updatePermissionModel.permission" />
          </el-form-item>
          <el-form-item label="状态" prop="status">
            <el-radio-group v-model="updatePermissionModel.status">
              <el-radio :value="1">启用</el-radio>
              <el-radio :value="0">禁用</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item label="排序" prop="sort">
            <el-input-number v-model="updatePermissionModel.sort" :min="1" :max="999" controls-position="right" />
          </el-form-item>
        </el-form>
      </div>
      <div class="drawer_footer">
        <el-button type="primary" @click="handleupdatePermission">确认</el-button>
        <el-button @click="updatePermissionDrawer = false">取消</el-button>
      </div>
    </el-drawer>
  </div>
</template>

<style lang="less" scoped>
.permission_container {
  height: 100%;
  position: relative;
}

:deep(.el-overlay) {
  position: absolute;
}

:deep(.el-drawer__title) {
  text-align: center;
}

.drawer_footer {
  margin-top: 40px;
  text-align: center;
}

:deep(.el-table__inner-wrapper)::before {
  width: 0px;
}
</style>

动态路由

为避免增加新的页面后,需要手动添加路由配置的麻烦,可以根据用户登录时获取的权限数据,在访问对应页面时,动态生成路由。需要在路由导航守卫中动态添加路由,否则刷新浏览器会导致路由丢失。

import NProgress from 'nprogress'
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'
NProgress.configure({ showSpinner: false })

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/login/Login.vue')
    },
    {
      path: '/',
      name: 'layout',
      component: () => import('@/views/layout/Layout.vue'),
      redirect: '/home',
      children: [
        {
          path: '/home',
          name: 'home',
          component: () => import('@/views/home/Home.vue'),
          meta: { title: '主页' }
        } /* ,
        {
          path: '/system/user',
          name: 'user',
          component: () => import('@/views/system/user/User.vue'),
          meta: { title: '用户管理' }
        },
        {
          path: '/system/role',
          name: 'role',
          component: () => import('@/views/system/role/Role.vue'),
          meta: { title: '角色管理' }
        },
        {
          path: '/system/permission',
          name: 'permission',
          component: () => import('@/views/system/permission/Permission.vue'),
          meta: { title: '权限管理' }
        } */
      ]
    },
    {
      path: '/:pathMatch(.*)*',
      name: '404',
      component: () => import('@/views/error/404.vue')
    }
  ]
})

const hasNecessaryRoute = (to) => {
  return router.getRoutes().some((item) => item.path === to.path)
}

router.beforeEach((to) => {
  NProgress.start()
  // 如果没有 token, 且访问的是非登录页,拦截到登录
  const userStore = useUserStore()
  if (!userStore.token && to.path !== '/login') return '/login'

  // 根据用户权限判断要访问的页面是否可以访问,如果不可以,拦截到登录页面
  const defaultPathList = ['/login', '/', '/home']
  const userPathList = [...defaultPathList, ...userStore.userPathList]
  if (!userPathList.includes(to.path)) return '/login'

  // 添加动态路由。确保在添加动态路由之前,Vue Router 已经初始化完成。动态路由的添加应该在用户登录并获取到权限信息之后进行。
  // 在导航守卫内部动态添加路由,通过返回新的位置来触发重定向
  if (!hasNecessaryRoute(to)) {
    router.addRoute(
      'layout',
      userStore.userDynamicRouteList.find((item) => item.path === to.path)
    )
    return to.fullPath
  }
})

router.afterEach(() => {
  NProgress.done()
})

export default router

这里增加了一个 404 页面,解决动态添加路由导致的 No match found for location with path 警告。

权限指令

对于页面的增删改查按钮,可以为其添加权限指令,只有拥有对应权限的用户才能看到该按钮。

创建权限指令文件 src\directive\permission\permission.js

import { useUserStore } from '@/stores'

export default {
  mounted(el, binding) {
    const { value } = binding
    const allPermission = '*.*.*'
    const userStore = useUserStore()

    if (value && value instanceof Array && value.length > 0) {
      const permissionFlag = value

      const hasPermissions = userStore.userButtonList.some((permission) => {
        return allPermission === permission || permissionFlag.includes(permission)
      })

      if (!hasPermissions) {
        el.parentNode && el.parentNode.removeChild(el)
      }
    } else {
      throw new Error(`权限不足`)
    }
  }
}

创建 src\directive\index.js,统一注册自定义的指令:

import permission from './permission/permission'

export default function directive(app) {
  app.directive('permission', permission)
}

main.js 中引入:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import pinia from './stores'

import App from './App.vue'
import router from './router'
import directive from './directive'

import 'element-plus/dist/index.css'
import './assets/style/base.css'
import './assets/style/common.css'
import 'nprogress/nprogress.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

const app = createApp(App)

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(ElementPlus, { size: 'small', zIndex: 3000, locale: zhCn })
app.use(pinia)
app.use(router)

directive(app)

app.mount('#app')

然后就可以在对应的组件上使用了:

<el-table-column label="操作" align="center" width="120" v-permission="['system.user.update', 'system.user.delete']">
  <template #default="{ row }">
    <el-button :icon="Edit" circle plain type="primary" @click="updateUser(row)" v-permission="['system.user.update']"></el-button>
    <el-button :icon="Key" circle plain type="primary" @click="resetPassword(row)"></el-button>
    <el-button :icon="Delete" circle plain type="danger" @click="deleteUser(row)" v-permission="['system.user.delete']"></el-button>
  </template>
</el-table-column>

在线用户

功能需求:

  • 分页获取在线用户,可以根据用户名查询在线用户
  • 可以强制下线在线用户

使用 Sa-Token 后,可以直接从内存中获取所有在线用户,如果配置了 Redis,则从 Redis 中获取。

先在后端程序中编写 src/main/java/net/stonecoding/ems/system/controller/OnlineController.java 控制器类,这里的分页需要手动设置:

/**
 * @author stonecoding.net
 */
@Tag(name = "Online")
@RestController
@RequestMapping("/online")
public class OnlineController {

    @Resource
    private UserService userService;

    @Operation(summary = "根据条件分页查询在线用户")
    @GetMapping("/list/page")
    public Result<PageInfo<User>> selectByCondition(User user, PageParam pageParam) {
        int pageNum = pageParam.getPageNum();
        int pageSize = pageParam.getPageSize();
        PageInfo<User> pageInfo = new PageInfo<>();
        pageInfo.setPageNum(pageNum);
        pageInfo.setPageSize(pageSize);
        pageInfo.setPrePage(pageNum - 1 == 0 ? 0 : pageNum - 1);

        List<User> list = new ArrayList<>();
        SaSession session;
        int pageNums = 0;

        if (StringUtils.isNotBlank(user.getUsername())) {
            User user1 = userService.selectByUsername(user.getUsername());
            if (user1 != null) {
                session = StpUtil.getSessionByLoginId(user1.getId());
                User sessionUser = session.getModel("user", User.class);
                sessionUser.setCreateTime(Instant.ofEpochMilli(session.getCreateTime()).atZone(ZoneId.systemDefault()).toLocalDateTime());
                list.add(sessionUser);
                pageInfo.setTotal(list.size());
                pageNums = list.size() % pageSize == 0 ? list.size() / pageSize : list.size() / pageSize + 1;
            }
        }

        if (StringUtils.isBlank(user.getUsername())) {
            List<String> loginIds = StpUtil.searchSessionId("", 0, -1, false);
            int startIndex = (pageNum - 1) * pageSize;
            int endIndex = Math.min(startIndex + pageSize, loginIds.size());
            for (int i = startIndex; i < endIndex; i++) {
                session = StpUtil.getSessionBySessionId(loginIds.get(i));
                User sessionUser = session.getModel("user", User.class);
                sessionUser.setCreateTime(Instant.ofEpochMilli(session.getCreateTime()).atZone(ZoneId.systemDefault()).toLocalDateTime());
                list.add(sessionUser);
            }
            pageInfo.setTotal(loginIds.size());
            pageNums = loginIds.size() % pageSize == 0 ? loginIds.size() / pageSize : loginIds.size() / pageSize + 1;
        }

        pageInfo.setPages(pageNums);
        pageInfo.setList(list);
        pageInfo.setNextPage(pageNums == pageNum ? pageNums : pageNum + 1);

        return Result.success(pageInfo);
    }

    @Operation(summary = "下线用户")
    @DeleteMapping("/kickOut/{id}")
    public Result<String> kickOut(@PathVariable Integer id) {
        StpUtil.kickout(id);
        return Result.success();
    }
}

前端的接口文件 src\api\online.js

import request from '@/utils/request'

// 根据条件查询在线用户
export const onlineSelectByConditionService = (user, pageParam) => request.get('/online/list/page', { params: { ...user, ...pageParam } })

// 下线用户
export const onlineKickOutService = (id) => request.delete('/online/kickOut/' + id)

前端页面文件 src\views\system\online\Online.vue

<script setup>
import { CircleClose } from '@element-plus/icons-vue'
import { onlineSelectByConditionService, onlineKickOutService } from '@/api/online'

// 查询在线用户
const selectUserModel = ref({
  username: ''
})
const pageParam = ref({
  pageNum: 1,
  pageSize: 10
})

const loading = ref(true)
const userList = ref([])
const total = ref(0)

const getOnlineUserList = async () => {
  const res = await onlineSelectByConditionService(selectUserModel.value, pageParam.value)
  userList.value = res.data.data.list
  total.value = res.data.data.total
  loading.value = false
}

getOnlineUserList()

const handleSelectOnlineUser = () => {
  pageParam.value.page = 1
  getOnlineUserList()
}

const resetSelectOnlineUser = () => {
  selectUserModel.value = {
    username: ''
  }
  pageParam.value = {
    pageNum: 1,
    pageSize: 10
  }
  getOnlineUserList()
}

const handleSizeChange = (pageSize) => {
  pageParam.value.pageSize = pageSize
  getOnlineUserList()
}

const handleCurrentChange = (pageNum) => {
  pageParam.value.pageNum = pageNum
  getOnlineUserList()
}

// 下线用户
const kickOut = async (row) => {
  await onlineKickOutService(row.id)
  getOnlineUserList()
}
</script>

<template>
  <div class="user_container">
    <el-card style="border: 0px; height: 100%">
      <el-form ref="selectUserRef" :model="selectUserModel" :inline="true" label-width="0">
        <el-form-item>
          <el-input v-model="selectUserModel.username" placeholder="用户名" maxlength="20" clearable />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSelectOnlineUser"> 查询 </el-button>
          <el-button type="default" @click="resetSelectOnlineUser"> 重置 </el-button>
        </el-form-item>
      </el-form>

      <div class="table_container">
        <el-table v-loading="loading" :data="userList" stripe>
          <el-table-column label="序号" type="index" align="center" width="80" />
          <el-table-column prop="username" label="用户名" align="center" width="120" />
          <el-table-column prop="createTime" label="登录时间" align="center" width="180" />
          <el-table-column label="操作" align="center" width="120">
            <template #default="{ row }">
              <el-button :icon="CircleClose" circle plain type="danger" @click="kickOut(row)"></el-button>
            </template>
          </el-table-column>
          <template #empty>
            <el-empty description="没有数据" v-if="!loading" />
          </template>
        </el-table>
        <el-pagination
          :current-page="pageParam.pageNum"
          :page-size="pageParam.pageSize"
          :total="total"
          size="small"
          background
          layout="<-, total, sizes, prev, pager, next, jumper"
          class="mt-4"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
  </div>
</template>

<style lang="less" scoped>
.user_container {
  height: 100%;
  position: relative;
}

:deep(.el-overlay) {
  position: absolute;
}

:deep(.el-drawer__title) {
  text-align: center;
}

.drawer_footer {
  margin-top: 40px;
  text-align: center;
}

:deep(.el-table__inner-wrapper)::before {
  width: 0px;
}

.tree-border {
  margin-top: 5px;
  border: 1px solid #e5e6e7;
  background: #ffffff none;
  border-radius: 4px;
  width: 100%;
}
</style>
上次编辑于:
贡献者: stone,stonebox