EMS Developer Guide
EMS Developer Guide
后端
技术栈:
- Spring Boot 3.3.2
- MyBatis 3.5.14
- PageHelper 6.1.0
- MySQL 8.0.32
- Knife4j 4.5.0
- Sa-Token 1.38.0
创建项目
创建 Spring Boot 项目:
指定 Spring Boot 版本为 3.3.2,并添加基本依赖:
删除项目下的 .mvn
,mvnw
,mvnw.cmd
和 HELP.md
,并删除 src/main/resources
下的 static
和 templates
,最终目录结构如下:
启用 Build Project automatically
,配置热部署:
修改配置文件 src/main/resources/application.properties
为 src/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 Administration 安装 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 修改类型转换,将数据库中 datetime
和 timestamp
类型对应为 Java 的 java.time.LocalDataTime
:
然后自定义模版:
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 连接到数据库后,选择一个表或者多个表,右键选择 EasyCode
的 Generate Code
,在弹出的对话框中选择模板,即可生成对应的代码。
生成代码后,根据具体需求进行修改。例如为日期时间字段加上 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
,以便返回指定格式的时间字符串。
修改完成后,启动应用,访问接口文档地址: http://localhost:8080/doc.html,进行接口测试。
认证鉴权
认证
使用 Sa-Token 权限认证框架进行认证鉴权。引入依赖:
<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 进行访问了。
鉴权
完成认证后,就需要对不同的用户授予不同的角色,并根据角色关联到权限。
先在接口文档中通过 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());
}
前端
技术栈:
- Vue 3.4.38
- Element Plus 2.8.0
创建项目
使用 pnpm 创建 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.$router
或 this.$route
。需要分别使用 useRouter
和 useRoute
函数分别获取路由实例和当前路由信息:
<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 | 文件 | 功能 | 组件名 | 路由级别 |
---|---|---|---|---|
/login | views/login/Login.vue | 登录&注册 | Login | 一级路由 |
/ | views/layout/Layout.vue | 布局架子 | Layout | 一级路由 |
├─ /home | views/home/Home.vue | 主页 | Home | 二级路由 |
├─ /system/user | views/system/user/User.vue | 用户管理 | User | 二级路由 |
├─ /system/role | views/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" /> 关闭左侧
</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" /> 关闭右侧
</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" /> 关闭其它
</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>