【从0带敲,手把手教】撸一个SpringBoot3+Vue3 脚手架

1/20/2026

程序员小孟,微信:codemeng

定制开发,咨询,有问题可以找我

学习网站:https://www.pdxmw.com/ (opens new window)

2026 毕设指导!一分钱不花,手把手教。

欢迎进入钻石 VIP 学习,一次上车永久学习:

钻石 VIP 学习

  1. 本期项目目的

本期视频会带领大家从数据库设计开始,从 0 到 1 的搭建一个属于自己的 SpringBoot3+Vue3 脚手架项目,脚手架是一个项目的基石,后续咱们可以基于这个脚手架开发自己的毕设或者是商业项目,而非每次都从 0 开始。

大家只要跟着视频一步一步学习,就可以独立完成脚手架项目的搭建。

这个项目的文档和教程全是免费学习,**一键三连 + 关注** 获取视频配套的核心资料。

我们出视频的目的,就是带大家学习. ****

适用人群

  • 学习技术的大学生
  • 做毕设的小伙伴
  • 想学习 SpringBoot+Vue 的小伙伴

# 项目功能&技术栈介绍

现在网上有很多的很不错的基于 SpringBoot3+Vue3 的脚手架项目,功能也很强大,但也正是因为功能太多,导致入门学习的时候不很友好,想改东西就是找不到,所以这期课程我会带领大家从 0 到 1 搭建一个基础的脚手架项目。

# 项目功能

  • ✅ 用户管理:用户的增删改查
  • ✅ 角色管理:角色的增删改查
  • ✅ 权限管理:权限的增删改查(有多级权限)
  • ✅ 用户角色关联:一个用户可以拥有多个角色
  • ✅ 角色权限关联【角色授权】:一个角色可以拥有多个权限
  • ✅ JWT 认证:基于 Token 的身份认证
  • ✅ 登录鉴权
    • 路由导航守卫:前端路由权限控制
    • 后端鉴权:JWT
  • ✅ 动态路由
  • ✅ 动态按钮

# 开发技术栈

后端

  • Spring Boot 3.x
  • MyBatis Plus
  • MySQL 8.0+
  • JWT (jjwt 0.12.3)
  • Hutool 5.8.23

前端

  • Vue 3.3.4
  • Element Plus 2.4.4
  • Vue Router 4.2.5
  • Pinia 2.1.7
  • Axios 1.6.2
  • Vite 5.0.8

# 数据库设计

脚手架项目其实就是一个基础的权限管理系统,包含的数据表有如下几个:

  • 用户表: sys_user
  • 角色表:sys_role
  • 权限表:sys_permission
  • 用户角色关联表:sys_user_role
  • 角色权限关联表:sys_role_permission

创建权限模块数据表的 sql 语句如下:

-- 创建数据库
CREATE DATABASE IF NOT EXISTS `auth_db_02` DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;

USE `auth_db_02`;

-- 用户表
CREATE TABLE IF NOT EXISTS `sys_user` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `username` VARCHAR(50) NOT NULL COMMENT '用户名',
  `password` VARCHAR(255) NOT NULL COMMENT '密码(MD5加密)',
  `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
  `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
  `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
  `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` TINYINT DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`),
  KEY `idx_status` (`status`),
  KEY `idx_deleted` (`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8  COMMENT='用户表';

-- 角色表
CREATE TABLE IF NOT EXISTS `sys_role` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `role_code` VARCHAR(50) NOT NULL COMMENT '角色编码',
  `role_name` VARCHAR(50) NOT NULL COMMENT '角色名称',
  `description` VARCHAR(255) DEFAULT NULL COMMENT '描述',
  `status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` TINYINT DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_role_code` (`role_code`),
  KEY `idx_status` (`status`),
  KEY `idx_deleted` (`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8  COMMENT='角色表';

-- 权限表
CREATE TABLE IF NOT EXISTS `sys_permission` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `permission_code` VARCHAR(50) NOT NULL COMMENT '权限编码', -- system:user:add
  `permission_name` VARCHAR(50) NOT NULL COMMENT '权限名称',
  `description` VARCHAR(255) DEFAULT NULL COMMENT '描述',
  `parent_id` BIGINT DEFAULT NULL COMMENT '父权限ID',
  `resource_type` VARCHAR(20) DEFAULT 'menu' COMMENT '资源类型:menu-菜单,button-按钮',
  `path` VARCHAR(255) DEFAULT NULL COMMENT '路径',
  `component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径',
  `icon` varcahr(50) comement '图标',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` TINYINT DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_permission_code` (`permission_code`),
  KEY `idx_parent_id` (`parent_id`),
  KEY `idx_deleted` (`deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8  COMMENT='权限表';

-- 用户角色关联表
CREATE TABLE IF NOT EXISTS `sys_user_role` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `user_id` BIGINT NOT NULL COMMENT '用户ID',
  `role_id` BIGINT NOT NULL COMMENT '角色ID',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_role` (`user_id`, `role_id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8  COMMENT='用户角色关联表';

-- 角色权限关联表
CREATE TABLE IF NOT EXISTS `sys_role_permission` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `role_id` BIGINT NOT NULL COMMENT '角色ID',
  `permission_id` BIGINT NOT NULL COMMENT '权限ID',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`),
  KEY `idx_role_id` (`role_id`),
  KEY `idx_permission_id` (`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8  COMMENT='角色权限关联表';

-- 初始化数据

-- 插入默认管理员用户(密码:123456,MD5:e10adc3949ba59abbe56e057f20f883e)
INSERT INTO `sys_user` (`username`, `password`, `nickname`, `email`, `status`, `create_time`, `deleted`)
VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', '管理员', 'admin@example.com', 1, NOW(), 0);

-- 插入默认角色
INSERT INTO `sys_role` (`role_code`, `role_name`, `description`, `status`, `create_time`, `deleted`)
VALUES
('admin', '管理员', '系统管理员', 1, NOW(), 0),
('user', '普通用户', '普通用户角色', 1, NOW(), 0);

-- 插入默认权限
BEGIN;
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (1, 'system:management', '系统管理', '系统管理模块', NULL, 'menu', '/system', 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:46', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (2, 'content:management', '内容管理', '内容管理模块', NULL, 'menu', '/content', 'Profile', 'document', '2025-11-10 16:19:34', '2025-11-12 17:26:02', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (3, 'user:management', '用户管理', '用户管理菜单', 1, 'menu', 'user', 'User', 'User', '2025-11-10 16:19:34', '2025-11-13 23:51:09', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (4, 'role:management', '角色管理', '角色管理菜单', 1, 'menu', 'role', 'Role', 'Grid', '2025-11-10 16:19:34', '2025-11-13 23:54:54', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (5, 'permission:management', '权限管理', '权限管理菜单', 1, 'menu', 'permission', 'Permission', 'Promotion', '2025-11-10 16:19:34', '2025-11-13 23:55:08', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (6, 'article:management', '文章管理', '文章管理菜单', 2, 'menu', 'article', 'Profile', 'article', '2025-11-10 16:19:34', '2025-11-13 15:08:48', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (7, 'category:management', '分类管理', '分类管理菜单', 2, 'menu', 'category', 'Profile', 'Bicycle', '2025-11-10 16:19:34', '2025-11-13 15:08:52', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (8, 'user:add', '新增用户', '新增用户按钮', 3, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (9, 'user:edit', '修改用户', '修改用户按钮', 3, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (10, 'user:delete', '删除用户', '删除用户按钮', 3, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (11, 'user:import', '导入用户', '导入用户按钮', 3, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (12, 'user:export', '导出用户', '导出用户按钮', 3, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (13, 'role:add', '新增角色', '新增角色按钮', 4, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (14, 'role:edit', '修改角色', '修改角色按钮', 4, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (15, 'role:delete', '删除角色', '删除角色按钮', 4, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (16, 'role:import', '导入角色', '导入角色按钮', 4, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (17, 'role:export', '导出角色', '导出角色按钮', 4, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (18, 'permission:add', '新增权限', '新增权限按钮', 5, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (19, 'permission:edit', '修改权限', '修改权限按钮', 5, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (20, 'permission:delete', '删除权限', '删除权限按钮', 5, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (21, 'permission:import', '导入权限', '导入权限按钮', 5, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (22, 'permission:export', '导出权限', '导出权限按钮', 5, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (23, 'article:add', '新增文章', '新增文章按钮', 6, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (24, 'article:edit', '修改文章', '修改文章按钮', 6, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (25, 'article:delete', '删除文章', '删除文章按钮', 6, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (26, 'article:import', '导入文章', '导入文章按钮', 6, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (27, 'article:export', '导出文章', '导出文章按钮', 6, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (28, 'category:add', '新增分类', '新增分类按钮', 7, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (29, 'category:edit', '修改分类', '修改分类按钮', 7, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (30, 'category:delete', '删除分类', '删除分类按钮', 7, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (31, 'category:import', '导入分类', '导入分类按钮', 7, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (32, 'category:export', '导出分类', '导出分类按钮', 7, 'button', NULL, 'Profile', 'Setting', '2025-11-10 16:19:34', '2025-11-13 23:53:06', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (33, 'picture:list', '轮播图管理', '管理首页轮播图', 2, 'menu', '/caracoul', 'Profile', 'Upload', '2025-11-10 22:38:30', '2025-11-12 17:26:02', 1);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (34, 'category:list', '轮播图管理', '', 2, 'menu', '/lunbotu', 'Profile', 'Tools', '2025-11-11 09:54:06', '2025-11-12 17:26:02', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (35, 'system:news', '新闻资讯管理', '', 2, 'menu', 'news', 'News', 'Basketball', '2025-11-17 14:52:51', '2025-11-17 14:52:51', 0);
INSERT INTO `sys_permission` (`id`, `permission_code`, `permission_name`, `description`, `parent_id`, `resource_type`, `path`, `component`, `icon`, `create_time`, `update_time`, `deleted`) VALUES (36, 'lunbotu', '轮播图管理', '', 2, 'menu', '/lunbotu', 'LunBoTu', 'Briefcase', '2025-11-17 15:33:28', '2025-11-17 15:33:28', 0);
COMMIT;
-- 为管理员用户分配管理员角色
INSERT INTO `sys_user_role` (`user_id`, `role_id`)
SELECT u.id, r.id FROM `sys_user` u, `sys_role` r
WHERE u.username = 'admin' AND r.role_code = 'admin';

-- 为管理员角色分配所有权限
INSERT INTO `sys_role_permission` (`role_id`, `permission_id`)
SELECT r.id, p.id FROM `sys_role` r, `sys_permission` p
WHERE r.role_code = 'admin';


# 基于 SpringBoot3 搭建后端 API 接口项目

这节课我们来搭建一个 基于 Springboot3 的后端 API 接口项目,这个项目中将包含一些通用的操作:

  • 统一的返回结果
  • 全局异常处理
  • 自定义异常以及捕获
  • 文件上传和下载的通用 Controller

# 创建 Springboot3 项目

# 封装返回统一结构数据

前后端分离开发的项目,后端要约定返回固定格式的数据,通常包括:code(状态码),message(消息),data(数据),可能还有 timestamp(时间戳)

common/Result.java

package com.maomao.auth.common;

import lombok.Data;

@Data
public class Result implements Serializable{
    private Integer code; //状态码
    private String message; //提示信息
    private Object data;    // 数据
    private Long timestamp; //时间戳

    //构造方法私有化
    private Result() {
        this.timestamp = System.currentTimeMillis();
    }

    public static Result success(Object data) {
        Result result = new Result();
        result.setCode(200);
        result.setMessage("操作成功");
        result.setData(data);
        return result;
    }

    public static Result success() {
        return success(null);
    }

    public static Result error(String message) {
        Result result = new Result();
        result.setCode(500);
        result.setMessage(message);
        result.setData(null);
        return result;
    }
    public static Result error(Integer code, String message) {
        Result result = new Result();
        result.setCode(code);
        result.setMessage(message);
        result.setData(null);
        return result;
    }

}

# 全局异常处理

如果异常不进行处理,就会抛出下图所示的异常页面,这样不友好,也不安全,会暴露你的代码逻辑。

common/GlobalExceptionHandler.java

package com.maomao.auth.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

@ControllerAdvice
public class GlobleExceptionHandler {
    private static Logger logger = LoggerFactory.getLogger(GlobleExceptionHandler.class);
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public Result error(Exception e) {
        logger.error("系统异常",e);
        return Result.error(e.getMessage());
    }
}

# 自定义异常处理

全局异常虽然能捕获异常信息,但是给用户的提示太过模糊,如果想提示具体一点的错误提示,比如账号冻结等,咱们就需要自定义异常(自定义异常提示信息).

common/CustomException.java

package com.maomao.auth.common;

@Data
public class CustomException extends RuntimeException{
    private int code;
    public CustomException(int code, String message) {
        super(message);
        this.code = code;
    }

    public CustomException(String message) {
        super(message);
        this.code = 500;
    }
}

在全局异常处理的类中,增加一个异常处理的方法

package com.maomao.auth.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

@ControllerAdvice
public class GlobleExceptionHandler {
    private static Logger logger = LoggerFactory.getLogger(GlobleExceptionHandler.class);
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public Result error(Exception e) {
        logger.error("系统异常",e);
        return Result.error("系统异常");
    }

    @ExceptionHandler(CustomException.class)
    @ResponseBody
    public Result error(CustomException e) {
        logger.error("自定义异常",e);
        return Result.error(e.getCode(),e.getMessage());
    }
}

# 通用的文件上传功能

1️⃣** pom.xml: 导入 Hutool 依赖**

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.35</version>
</dependency>

2️⃣ contoller/FileController.java : 编写文件上传的 api 接口 /upload

package com.maomao.auth.authapi.controller;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.springboot.learn.springbootlearn.common.Result;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.util.UUID;

@RestController
public class FileController {
    @Value("${files.upload.path}")
    private String uploadPath;//文件上传的目录

    @PostMapping("/upload")
    public Result uploadFile(HttpServletRequest request, @RequestParam("file")MultipartFile file) throws Exception{
        //获取文件的后缀名(类型)
        String ext = FileUtil.extName(file.getOriginalFilename());

        //创建文件存储目录,每天一个文件夹,用 yyyyMMdd来命名
        String datePath = DateUtil.format(new Date(),"yyyyMMdd") + "/";
        File dir = new File(uploadPath + datePath);
        if (dir.exists() == false){
            dir.mkdirs();
        }

        //生成唯一标识码(文件名)
        String uuid = UUID.randomUUID().toString();
        //保存文件
        File dest = new File(uploadPath +datePath+ uuid + "." + ext);
        file.transferTo(dest);

        String url = StrUtil.removeSuffix(request.getRequestURL(),"/upload") + "/api/"+datePath + uuid + "." + ext;
        return Result.success(url);
    }
}

3️⃣ application.yml : 配置文件上传的目录和文件上传的大小

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 20MB

files:
  upload:
    path: /Users/yaoyaomice/learning/

4️⃣ 使用 PostMan 测试文件上传的功能

5️⃣ 配置静态资源访问: config/WebConfig.java

package com.maomao.auth.authapi.common;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Value("${files.upload.path}")
    private String uploadPath;
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/api/**").addResourceLocations("file:" + uploadPath);
    }
}

6️⃣ 使用 PostMan 访问上传的资源文件,当然浏览器访问也是可以的

# 通用的文件下载功能

1️⃣ 在 contoller/FileController.java 中增加下载的 api 接口方法

package com.springboot.learn.springbootlearn.controller;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.springboot.learn.springbootlearn.common.CustomException;
import com.springboot.learn.springbootlearn.common.Result;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.net.URLEncoder;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;

@RestController
public class FileController {

    @Value("${files.download.path}")
    private String downloadPath;

    @GetMapping("/download/{filename}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String filename) throws Exception {
         //获取文件路径
        Path path = Paths.get(downloadPath).resolve(filename).normalize();
        Resource resource = new UrlResource(path.toUri());

        if (resource.exists() && resource.isReadable()){
            return ResponseEntity.ok()
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .header(HttpHeaders.CONTENT_DISPOSITION,"attachment;filename=\""+ URLEncoder.encode(resource.getFilename(),"utf-8") +"\"")
                    .body(resource);
        }else{
            throw  new CustomException("文件不存在!");
        }
    }
}

代码解释:

通过Paths.get(FILE_DIRECTORY).resolve(fileName).normalize()方法获取文件路径,并使用UrlResource类加载文件资源。

如果文件存在且可读,则返回一个包含文件内容的ResponseEntity对象,并设置适当的 HTTP 头部(如Content-Disposition)以指示浏览器下载文件。如果文件不存在或不可读,则返回 404 Not Found 响应

2️⃣ 在 application.yml 中配置下载文件的目录

files:
  upload:
    path: /Users/yaoyaomice/learning/
  download:
    path: /Users/yaoyaomice/learning/

# 编写用户管理的 API 接口&PostMan 测试

# API 接口编写规范

每个模块管理的 API 接口都至少包含入了如下 5 个 API 接口,使用 Restful 风格的 URL.

用户管理模块的模块名称为: user

  • 添加接口:post 请求 映射名称: /user
  • 修改接口:put 请求 映射名称: /user/{id}
  • 删除接口(支持批量删除): delete 请求 映射名称: /user/{ids}
  • 查询接口(多条件分页查询): get 请求 映射名称: /user/list
  • 查询单个: get 请求 映射名称: /user/{id}

# 集成 MyBatisPlus 框架

MyBatis-Plus (opens new window) 是一个 MyBatis (opens new window) 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

MybatisPlus 的官方文档: https://baomidou.com/getting-started/ (opens new window)

① 添加依赖

<!--MyBatisPlus 依赖-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.14</version>
</dependency>
<!-- MyBatisPlus 分页插件 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-jsqlparser</artifactId>
    <version>3.5.14</version>
</dependency>
<!--MySQL 驱动包-->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.0.32</version>
</dependency>

② application.yml 中配置数据源

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/auth_db?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: 12345678

3️⃣ 配置分页插件

config/MybatisPlusConfig.java

package com.maomao.auth.authapi.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {
    /**
     * 添加分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 如果配置多个插件, 切记分页最后添加
        // 如果有多数据源可以不配具体类型, 否则都建议配上具体的 DbType
        return interceptor;
    }
}

4️⃣ 启动类上添加 @MapperScan 注解

@SpringBootApplication
@MapperScan("com.maomao.auth.authapi.mapper")
public class AuthApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthApiApplication.class, args);
    }

}

# API 接口编写顺序

① 编写实体类

② 编写数据访问接口 mapper

③ 编写业务逻辑类

④ 编写控制器(api 接口)

⑤ 使用 postman 测试 API 接口

# 编写角色管理的 API 接口&PostMan 测试

角色管理模块的模块名称为: role

  • 添加接口:post 请求 映射名称: /role
  • 修改接口:put 请求 映射名称: /role/{id}
  • 删除接口(支持批量删除): delete 请求 映射名称: /role/{ids}
  • 查询接口(多条件分页查询): get 请求 映射名称: /role/list
  • 查询单个: get 请求 映射名称: /role/{id}
  • 角色授权:post 请求 映射名称:/role/assign

# 编写权限管理的 API 接口&PostMan 测试

权限管理模块的模块名称为: permission

  • 添加接口:post 请求 映射名称: /permission
  • 修改接口:put 请求 映射名称: /permission/{id}
  • 删除接口(支持批量删除): delete 请求 映射名称: /permission/{ids}
  • 查询接口(多条件分页查询): get 请求 映射名称: /permission/list
  • 查询单个: get 请求 映射名称: /permission/{id}

# 基于 Vue3 和 ElementPlus 搭建前端项目(主页面布局)

# 创建 Vue3 项目&项目瘦身

1️⃣ 创建 Vue3 项目,项目名称为:auth-vue

npm create vue@latest

cd auth-vue
npm install
npm run dev

2️⃣ 项目瘦身

只保留一个默认首页:Login.vue,其余的都不保留

3️⃣ 404 页面配置

① 创建一个 404.vue 页面

<template>
  <div class="container">
    <img src="@/assets/img/404.png" alt="" />
    <div class="back_home"><a href=" /">返回首页</a></div>
  </div>
</template>

<style scoped>
.container {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
}
.container img {
  height: 350px;
}
.container .back_home a {
  font-size: 20px;
  color: cadetblue;
}
</style>

② 配置路由 router/index.js

import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/Home.vue";

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: "/",
      name: "home",
      component: HomeView,
    },
    {
      path: "/notFound",
      name: "notFound",
      component: () => import("../views/404.vue"),
    },
    {
      path: "/:pathMatch(.*)",
      redirect: "/notFound",
    },
  ],
});

export default router;

# Vue3 集成 ElementPlus 组件库

ElementPlus 是一款基于 Vue 3,面向设计师和开发者的组件库,也可以成为前端 UI 框架,让开发者可以快速的构建精美的页面。

官网: https://element-plus.org/zh-CN/ (opens new window)

国内镜像:https://cn.element-plus.org/zh-CN/ (opens new window)

① 安装依赖

npm install element-plus --save

② 在 main.js 中引入 element-plus

import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";

const app = createApp(App);

app.use(ElementPlus);
app.mount("#app");

# ElementPlus 中 icon 图标的使用

① 安装 icon-vue 依赖

npm install @element-plus/icons-vue

② 全局注册图标组件

import * as ElementPlusIconsVue from "@element-plus/icons-vue";

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

# ElementPlus 国际化处理

main.js

import ElementPlus from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";

app.use(ElementPlus, {
  locale: zhCn,
});

# 基于 ElementPlus 组件库搭建管理员主页面布局

需要学习的组件 el-container : https://element-plus.org/zh-CN/component/container (opens new window)

用到的组件:

  • el-aside: 侧边栏
  • el-menu: 菜单
  • el-breadcrumb: 面包屑
  • el-dropdown: 下拉菜单
  • router-view

logo 图片:

layout/MainLayout.vue

# 实现用户管理的功能(增删改查)

# axios 简介

Axios 是一个基于 promise 的网络请求库,可以用于浏览器和 node.js

Axios 使用简单,包尺寸小且提供了易于扩展的接口

官方网址: https://www.axios-http.cn/ (opens new window)

① 安装 axios

npm install axios

② 使用 axios 发送请求

import axios from "axios";

//发送Get请求
axios.get("http://localhost:8080/sysuser/list").then((res) => {
  console.log(res);
});

# 处理跨域问题

由于浏览器的同源策略,为了安全,禁止发送 Ajax 请求到其他域中, 5173 --> 8080

一般情况都在服务端进行跨域处理,局部跨域处理可以在控制器上使用 @CrossOrigin 注解实现,但方便起见一般都会进行全局的跨域处理。

confit/CorsConfig.java

package com.maomao.auth.authapi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")  // 匹配所有路径 /login /register  /sysuser/list
                .allowedOriginPatterns("*")  // 允许所有源,你也可以配置特定的源,如"http://localhost:51730"
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的方法
                .allowedHeaders("*")  // 允许所有头
                .allowCredentials(true)  // 允许发送cookie
                .maxAge(3600);  // 预检请求的缓存时间(秒)
    }
}

# 封装 axios 操作

util/request.js

import axios from "axios";
import { ElMessage } from "element-plus";

const request = axios.create({
  baseURL: import.meta.env.VITE_APP_API_BASE_URL,
  timeout: 30000, //请求超时时间
});
//request拦截器
request.interceptors.request.use(
  (config) => {
    //取出本地存储中的用户信息
    let user = JSON.parse(sessionStorage.getItem("login_user"));
    if (user) {
      config.headers["token"] = user.token;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  },
);

//response拦截器
request.interceptors.response.use(
  (response) => {
    return response.data;
  },
  (error) => {
    if (error.response) {
      if (error.response.status == 404) {
        ElMessage.error("未找到请求接口");
      } else if (error.response.status == 500) {
        ElMessage.error("接口调用异常,请查看后台接口日志");
      } else {
        ElMessage.error(error.message);
      }
    } else {
      ElMessage.error(error.message);
    }

    return Promise.reject(error);
  },
);

export default request;

# 编写前端的 API 接口调用类

api/user.js

import http from "@/utils/request.js";

export function listUsers(params) {
  return http.get("/user/list", {
    params: params,
  });
}

export function getUser(id) {
  return http.get(`/user/${id}`);
}

export function createUser(data) {
  return http.post("/user", data);
}

export function updateUser(id, data) {
  return http.put(`/user/${id}`, data);
}

export function deleteUsers(ids) {
  // Backend accepts comma-separated path variable
  const path = Array.isArray(ids) ? ids.join(",") : String(ids);
  return http.delete(`/user/${path}`);
}

# 编写视图页面 User.vue & 配置路由

<template>
  <div style="padding: 16px;">
    <el-card>
      <template #header>
        <div
          style="display:flex;align-items:center;justify-content:space-between;"
        >
          <span>用户管理</span>
        </div>
      </template>

      <div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
        <el-input
          v-model="query.username"
          placeholder="用户名"
          clearable
          style="width:200px;"
        />
        <el-input
          v-model="query.nickname"
          placeholder="昵称"
          clearable
          style="width:200px;"
        />
        <el-select
          v-model="query.status"
          placeholder="状态"
          clearable
          style="width:140px;"
        >
          <el-option :value="1" label="启用" />
          <el-option :value="0" label="禁用" />
        </el-select>
        <el-select
          v-model="query.roleIds"
          placeholder="角色"
          clearable
          style="width:200px;"
          multiple
        >
          <el-option
            v-for="item in roles"
            :key="item.id"
            :value="item.id"
            :label="item.roleName"
          />
        </el-select>
        <el-button type="primary" @click="loadData(1)">查询</el-button>
        <el-button @click="resetQuery">重置</el-button>
      </div>

      <div style="margin: 15px 0px;">
        <el-button type="primary" :icon="Plus" @click="handleAdd"
          >新增</el-button
        >
        <el-button
          type="primary"
          :icon="Edit"
          :disabled="selectedIds.length !== 1"
          @click="handleEdit"
          >编辑</el-button
        >
        <el-button
          type="danger"
          :icon="Delete"
          :disabled="!selectedIds.length"
          @click="handleDelete"
          >批量删除</el-button
        >
        <el-button type="success" :icon="Download" @click="handleExport"
          >导出</el-button
        >
        <el-button type="warning" :icon="Upload" @click="handleImport"
          >导入</el-button
        >
      </div>
      <el-table
        :data="tableData"
        border
        stripe
        @selection-change="onSelectionChange"
      >
        <el-table-column type="selection" width="45" />
        <el-table-column prop="id" label="ID" width="80" />
        <el-table-column prop="username" label="用户名" min-width="140" />
        <el-table-column prop="nickname" label="昵称" min-width="140" />
        <el-table-column prop="email" label="邮箱" min-width="180" />
        <el-table-column prop="phone" label="手机号" min-width="140" />
        <el-table-column label="状态" width="100">
          <template #default="{ row }">
            <el-tag :type="row.status === 1 ? 'success' : 'info'">{{
              row.status === 1 ? "启用" : "禁用"
            }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createTime" label="创建时间" min-width="180">
          <template #default="{ row }">{{ row.createTime }}</template>
        </el-table-column>
        <el-table-column fixed="right" label="操作" width="180">
          <template #default="{ row }">
            <el-button type="primary" text @click="handleEdit(row)"
              >编辑</el-button
            >
            <el-button type="danger" text @click="handleDelete(row)"
              >删除</el-button
            >
          </template>
        </el-table-column>
      </el-table>

      <div style="margin-top:12px;display:flex;justify-content:flex-end;">
        <el-pagination
          background
          layout="total, sizes, prev, pager, next, jumper"
          :total="total"
          :page-sizes="[10, 20, 50, 100]"
          v-model:page-size="query.pageSize"
          v-model:current-page="query.pageNum"
          @size-change="loadData"
          @current-change="loadData"
        />
      </div>
    </el-card>

    <el-dialog
      v-model="dialogVisible"
      :title="dialogMode === 'create' ? '新增用户' : '编辑用户'"
      width="520px"
    >
      <el-form :model="form" :rules="rules" ref="formRef" label-width="88px">
        <el-form-item label="用户名" prop="username">
          <el-input v-model="form.username" />
        </el-form-item>
        <el-form-item
          label="密码"
          v-if="dialogMode === 'create'"
          prop="password"
        >
          <el-input v-model="form.password" type="password" />
        </el-form-item>
        <el-form-item label="昵称" prop="nickname">
          <el-input v-model="form.nickname" />
        </el-form-item>
        <el-form-item label="邮箱" prop="email">
          <el-input v-model="form.email" />
        </el-form-item>
        <el-form-item label="手机号" prop="phone">
          <el-input v-model="form.phone" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-select v-model="form.status" placeholder="请选择">
            <el-option :value="1" label="启用" />
            <el-option :value="0" label="禁用" />
          </el-select>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitForm">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from "vue";
import {
  listUsers,
  createUser,
  updateUser,
  deleteUsers,
  getUser,
} from "@/api/user.js";
import { listRoles } from "@/api/role.js";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Edit, Delete, Download, Upload } from "@element-plus/icons-vue";
import { download } from "@/utils/request.js";

const query = reactive({
  username: "",
  nickname: "",
  status: "",
  roleIds: [],
  pageNum: 1,
  pageSize: 10,
});
const tableData = ref([]);
const total = ref(0);
const selectedIds = ref([]);
const roles = ref([]);

const dialogVisible = ref(false);
const dialogMode = ref("create"); // 'create' | 'edit'
const formRef = ref();
const form = ref({
  id: undefined,
  username: "",
  password: "",
  nickname: "",
  email: "",
  phone: "",
  status: 1,
});

const rules = {
  username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
  password: [{ required: true, message: "请输入密码", trigger: "blur" }],
  nickname: [{ required: true, message: "请输入昵称", trigger: "blur" }],
};

const loadData = async () => {
  const res = await listUsers(query);
  tableData.value = res.data?.list || [];
  total.value = res.data?.total || 0;
};

const resetQuery = () => {
  query.username = "";
  query.nickname = "";
  query.status = undefined;
  query.pageNum = 1;
  query.pageSize = 10;
  query.roleIds = [];
  loadData();
};

const onSelectionChange = (rows) => {
  selectedIds.value = rows.map((r) => r.id);
};

const handleAdd = () => {
  dialogMode.value = "create";
  form.value = {
    id: undefined,
    username: "",
    password: "",
    nickname: "",
    email: "",
    phone: "",
    status: 1,
  };
  dialogVisible.value = true;
};

const handleEdit = async (row) => {
  let ids = row.id || selectedIds.value;
  dialogMode.value = "edit";
  let res = await getUser(ids);
  form.value = res.data;
  dialogVisible.value = true;
};

const submitForm = () => {
  formRef.value.validate(async (valid) => {
    if (!valid) return;
    if (dialogMode.value === "create") {
      await createUser(form.value);
      ElMessage.success("添加成功");
    } else {
      const id = form.value.id;
      delete form.value.id;
      await updateUser(id, form.value);
      ElMessage.success("更新成功");
    }
    dialogVisible.value = false;
    loadData();
  });
};

const handleDelete = async (row) => {
  const ids = row.id || selectedIds.value;
  await ElMessageBox.confirm(`确认删除编号【${ids}】吗?`, "提示", {
    type: "warning",
  });
  await deleteUsers(ids);
  ElMessage.success("删除成功");
  loadData();
};

const handleExport = () => {
  download("/user/export", query);
};

const handleImport = () => {};

onMounted(() => {
  loadData();
  listRoles({ pageSize: 100 }).then((res) => {
    roles.value = res.data.list;
  });
});
</script>

<style scoped></style>

# 实现用户角色的关联

  • 列表中显示关联的角色名称
  • 列表中根据选中的角色查询用户
  • 添加时添加用户的角色
  • 修改时回显用户的角色

# 实现用户的导出功能

添加依赖

 <dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.4.1</version>
</dependency>

UserController.java 增加 /export 的 api 接口

@RequestMapping("/export")
public void export(HttpServletResponse response, SysUser sysUser) throws IOException {
    QueryWrapper<SysUser> wrapper = new QueryWrapper<>();
    wrapper.like(StrUtil.isNotEmpty(sysUser.getUsername()),"username",sysUser.getUsername());

    List<SysUser> list = sysUserService.list(wrapper);

    ExcelUtils.export(response,list,"用户数据");
}

ExcelUtil.java 的工具类

package com.maomao.authapi.utils;

import cn.hutool.core.io.IoUtil;
import cn.hutool.poi.excel.ExcelUtil;
import cn.hutool.poi.excel.ExcelWriter;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.net.URLEncoder;
import java.util.List;

public class ExcelUtils {
    /**
     * 导出Excel
     * @param response 响应对象
     * @param list 导出的数据集合
     * @param filename 导出的文件名
     */
    public static void export(HttpServletResponse response, List list, String filename){
        try {
            ExcelWriter writer = ExcelUtil.getWriter();
            writer.write(list,true);
            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8");
            response.setHeader("Content-Disposition","attachement;filename="+ URLEncoder.encode(filename,"UTF-8") +".xlsx");
            ServletOutputStream os = response.getOutputStream();
            writer.flush(os,true);
            writer.close();
            IoUtil.close(os);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

前端调用后端的 export 接口,由于要下载文件,前端不能发送 Ajax 请求,直接访问后端接口地址。

修改 uitl/request.js, 增加 download 的方法:

export function download(url, params) {
  //构建查询参数
  let searchParam = new URLSearchParams();
  for (let key in params) {
    searchParam.append(key, params[key]);
  }
  //?name=lisi&nickname=zhangsan
  //通过 url 传递 token
  let user = JSON.parse(sessionStorage.getItem("login_user"));
  if (user) {
    searchParam.append("token", user.token);
  }
  const base_url = import.meta.env.VITE_APP_API_BASE_URL;
  window.location = base_url + url + "?" + searchParam.toString();
}

给导出按钮添加 click 事件

const handleExport = () => {
  download("/user/export", query);
};

# 实现用户的导入功能

编写数据导入的 api 接口

@PostMapping("/import")
public Result importData(HttpServletResponse response,@RequestParam("file") MultipartFile file) throws IOException {
    ExcelReader reader = ExcelUtil.getReader(file.getInputStream());
    //调用Hutool中ExcelReader的readAll方法,将读取的数据封装成List集合
    List<SysUser> list = reader.readAll(SysUser.class);
    //批量保存到数据库
    Integer count = 0;
    for (SysUser item : list) {
        item.setId(null);
        sysUserService.save(item);
        count ++;
    }
    Map<String,Object> map = new HashMap<>();
    map.put("count",count);
    return Result.success(map);
}

使用 el-upload 重构导入的按钮

<el-upload
  style="margin-left: 10px;"
  :show-file-list="false"
  :on-success="handleImportSuccess"
  :action="importUrl"
>
  <el-button type="warning" icon="Upload">导入</el-button>
</el-upload>
const importUrl = ref("");
importUrl.value = import.meta.env.VITE_APP_API_BASE_URL + "/user/import";

const handleImportSuccess = (res) => {
  if (res.code == 200) {
    ElMessage.success("导入成功!共导入" + res.data.count + "条数据。");
    loadData();
  } else {
    ElMessage.error("导入失败!" + res.message);
  }
};

# 实现角色管理的功能(增删改查+授权)

# 类比用户管理实现角色管理的功能

  1. 编写 调用后端接口的 api 接口: api/role.js
  2. 编写视图页面: Role.vue

# 实现角色授权功能: 显示权限菜单

使用 el-tree 组件渲染显示权限(菜单)的信息。

el-tree 文档:https://element-plus.org/zh-CN/component/tree#tree-%E6%A0%91%E5%BD%A2%E6%8E%A7%E4%BB%B6 (opens new window)

el-tree 组件要求的数据结构:

{
  id: 1,
  label: 'Level one 2',
  children: [
    {
      id: 2,
      label: 'Level two 2-1',
      children: [
        {
          id: 3,
          label: 'Level three 2-1-1',
        },
      ],
    }
}

1️⃣ 编写后端接口,返回 el-tree 所需的数据

contoller/PermissionController.java

@GetMapping("/treelist")
public Result treeList() {
    List<SysPermission> permissions = sysPermissionService.selectPermissionList();
    return Result.success(permissions);
}

service/impl/PermissionServiceImpl.java

@Override
public List<SysPermission> selectPermissionList() {
    List<SysPermission> list = baseMapper.selectList(null);
    return this.buildMenuTree(list);
}


private List<SysPermission> buildMenuTree(List<SysPermission> menus)
{
    List<SysPermission> returnList = new ArrayList<SysPermission>();
    List<Long> tempList = new ArrayList<Long>();
    for (SysPermission dept : menus)
    {
        tempList.add(dept.getId());
    }
    for (Iterator<SysPermission> iterator = menus.iterator(); iterator.hasNext();)
    {
        SysPermission menu = (SysPermission) iterator.next();
        // 如果是顶级节点, 遍历该父节点的所有子节点
        if (!tempList.contains(menu.getParentId()))
        {
            recursionFn(menus, menu);
            returnList.add(menu);
        }
    }
    if (returnList.isEmpty())
    {
        returnList = menus;
    }
    return returnList;
}

private void recursionFn(List<SysPermission> list, SysPermission t)
{
    // 得到子节点列表
    List<SysPermission> childList = getChildList(list, t);
    t.setChildren(childList);
    for (SysPermission tChild : childList)
    {
        if (hasChild(list, tChild))
        {
            recursionFn(list, tChild);
        }
    }
}

/**
 * 得到子节点列表
 */
private List<SysPermission> getChildList(List<SysPermission> list, SysPermission t)
{
    List<SysPermission> tlist = new ArrayList<SysPermission>();
    Iterator<SysPermission> it = list.iterator();
    while (it.hasNext())
    {
        SysPermission n = (SysPermission) it.next();
        if (n.getParentId() != null && n.getParentId().longValue() == t.getId().longValue())
        {
            tlist.add(n);
        }
    }
    return tlist;
}

/**
 * 判断是否有子节点
 */
private boolean hasChild(List<SysPermission> list, SysPermission t)
{
    return getChildList(list, t).size() > 0;
}

2️⃣ 实现 el-tree 的数据渲染

<el-dialog v-model="dialogVisibleAssign" title="角色授权" width="520px">
  <el-tree
      ref="treeRef"
      :data="permissionData"
      :default-expanded-keys="[1, 2]"
      show-checkbox
      node-key="id"
      :props="defaultProps"
  />
  <template #footer>
    <el-button @click="dialogVisibleAssign = false">取消</el-button>
    <el-button type="primary" @click="submitAssignForm">确定</el-button>
  </template>
</el-dialog>
const handleAssign = (row) => {
  dialogVisibleAssign.value = true;
  role.value = row;
  treelist().then((res) => {
    permissionData.value = res.data;
  });
};

3️⃣ 实现授权操作

给确定按钮绑定点击事件

const submitAssignForm = () => {
  let nodes = treeRef.value.getCheckedNodes(false, true);
  assignPermissions({
    id: role.value.id,
    permissionIds: nodes.map((item) => item.id),
  }).then((res) => {
    ElMessage.success("授权成功");
    dialogVisibleAssign.value = false;
  });
};

# 实现菜单授权功能:回显角色权限

1️⃣ 编写后端 api 接口,用于返回角色拥有的权限列表

contoller/PermissionController.java

@GetMapping("/role/{id}")
public Result rolePermission(@PathVariable Long id) {
    List<SysRolePermission> rolePermissions = sysRolePermissionMapper.selectList(new QueryWrapper<SysRolePermission>().eq("role_id", id));
    return Result.success(rolePermissions);
}

2️⃣ 打开授权对话框时加载角色权限并选中

//角色授权
const handleAssign = (row) => {
  dialogVisibleAssign.value = true;
  role.value = row;
  treelist().then((res) => {
    permissionData.value = res.data;

    //查询当前角色的权限
    listbyrole(row.id).then((r) => {
      //使用这种方式不行,子节点会级联选中
      //treeRef.value.setCheckedKeys(r.data.map(item => item.permissionId));
      r.data.forEach((item) => {
        treeRef.value.setChecked(item.permissionId, true, false);
      });
    });
  });
};

# 实现权限管理的功能

# 树形表格显示

1️⃣ 去掉分页, 增加 row-key 属性

<el-table :data="tableData" row-key="id" border stripe>
  <el-table-column prop="permissionName" label="权限名称" min-width="140" />
  <el-table-column prop="permissionCode" label="权限编码" min-width="140" />
 </el-table>

2️⃣ 修改 api 接口,返回树形结构的数据(children)

@GetMapping("/list")
public Result list(@RequestParam(defaultValue = "1") Integer pageNum,
                   @RequestParam(defaultValue = "10") Integer pageSize,
                   SysPermission permission) {
    QueryWrapper<SysPermission> queryWrapper = new QueryWrapper<>();

    List<SysPermission> permissions = sysPermissionService.list(queryWrapper);

    return Result.success(permissions);
}

//PermissionServiceImpl.java
@Override
public List<SysPermission> list(Wrapper<SysPermission> queryWrapper) {
    List<SysPermission> list = super.list(queryWrapper);
    return this.buildMenuTree(list); //构建树形菜单数据
}

# TreeSelect 组件实现父级菜单的选择

<el-form-item label="上级菜单" prop="parentId">
  <el-tree-select
      v-model="form.parentId"
      :data="treeDataList"
      :props="props"
      check-strictly
      node-key="id"
      :render-after-expand="false"
  />
</el-form-item>
const props = {
  label: "permissionName",
};

onMounted(() => {
  treelist().then((res) => {
    treeDataList.value = res.data;
  });
});

# 封装图标选择器

1️⃣ 在 main.js 中注册所有的组件且存入数组

app.config.globalProperties.$icons = [];
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.config.globalProperties.$icons.push(key);
  app.component(key, component);
}

2️⃣ 定义组件 @/components/ElIconPicker.vue

<script setup>
import { defineEmits, defineProps, getCurrentInstance } from "vue";

const {
  appContext: {
    app: {
      config: { globalProperties },
    },
  },
} = getCurrentInstance();

const props = defineProps({
  modelValue: {
    type: String,
    default: "",
  },
});
const emits = defineEmits(["update:modelValue"]);
</script>

<template>
  <el-popover trigger="focus" :width="256">
    <template #reference>
      <el-button :icon="modelValue">{{ modelValue }}</el-button>
    </template>
    <div class="el-icon-picker">
      <component
        v-for="icon in globalProperties.$icons"
        :key="icon"
        :class="[icon, 'icon', { 'icon-active': icon == modelValue }]"
        :is="icon"
        @click="emits('update:modelValue', icon)"
      >
      </component>
    </div>
  </el-popover>
</template>

<style scoped>
.el-icon-picker {
  height: 256px;
  overflow-y: scroll;
  display: flex;
  justify-content: space-around;
  flex-wrap: wrap;
}

.icon {
  display: inline-block;
  width: 24px;
  height: 24px;
  color: var(--el-text-color-regular);
  font-size: 20px;
  border-radius: 4px;
  cursor: pointer;
  text-align: center;
  line-height: 45px;
  margin: 5px;
}

.icon:hover {
  color: var(--el-color-primary);
}

.icon-active {
  color: var(--el-color-primary);
}
</style>

3️⃣ 在 Permission.vue 中使用组件

<el-icon-picker v-model="form.icon"></el-icon-picker>

<script setup>
import ElIconPicker from "@/components/ElIconPicker.vue";
</script>

# 实现用户登录、注销、修改资料、修改密码功能

# 编写登录的 API 接口

在前后的分离的项目中,登录鉴权一般都使用的是 JWT 的技术,所以后端的登录 API 接口中,用户登录成功后要签发一个 token,返回给前端, 前端每次发送 request 请求都会通过请求头(header)或者 url 地址来携带 token。

1️⃣ 添加 JWT 的依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.3</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.3</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.3</version>
    <scope>runtime</scope>
</dependency>

2️⃣ 编写 JWT 操作的工具类:utils/JwtUtil.java

package com.maomao.auth.authapi.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String generateToken(Long userId, String username) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("username", username);

        return Jwts.builder()
                .claims(claims)  // 替换 setClaims()
                .subject(username)  // 替换 setSubject()
                .issuedAt(new Date())  // 替换 setIssuedAt()
                .expiration(new Date(System.currentTimeMillis() + expiration))  // 替换 setExpiration()
                .signWith(getSigningKey())  // 移除了 SignatureAlgorithm 参数
                .compact();
    }

    public Claims getClaimsFromToken(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(getSigningKey())
                    .build()
                    .parseSignedClaims(token)  // 替换 parseClaimsJws()
                    .getPayload();
        } catch (Exception e) {
            return null;
        }
    }

    public Long getUserIdFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        if (claims != null) {
            return claims.get("userId", Long.class);
        }
        return null;
    }

    public String getUsernameFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        if (claims != null) {
            return claims.getSubject();
        }
        return null;
    }

    public boolean isTokenExpired(String token) {
        Claims claims = getClaimsFromToken(token);
        if (claims == null) {
            return true;
        }
        return claims.getExpiration().before(new Date());
    }

    public boolean validateToken(String token) {
        return !isTokenExpired(token);
    }
}

3️⃣ 在 application.yml 中配置 jwt 的密钥和过期时间

# JWT配置
jwt:
  secret: auth-system-secret-key-2024-1234
  expiration: 86400000 # 24小时(毫秒)

4️⃣ 在 UserService 中添加登录验证的方法:login

SysUserService.java

/**
 * 用户登录
 * @param user
 * @return
 */
public SysUser login(SysUser user);

SysUserServiceImpl.java

需要在 SysUser 中添加一个 token 属性。

@Override
public SysUser login(SysUser user) {
    SysUser sysUser = baseMapper.selectOne(new QueryWrapper<SysUser>().eq("username", user.getUsername()));
    if (sysUser == null){
        throw new CustomException("用户不存在");
    }
    String md5Password = DigestUtils.md5DigestAsHex(user.getPassword().getBytes());
    if (!sysUser.getPassword().equals(md5Password)){
        throw new CustomException("密码不正确");
    }
    if (sysUser.getStatus() == 0) {
        throw new CustomException("用户已被禁用");
    }
    //查询用户拥有的角色编号
    List<SysUserRole> userRoleList = sysUserRoleMapper.selectList(new QueryWrapper<SysUserRole>().eq("user_id", sysUser.getId()));
    List<Long> roleIds = userRoleList.stream().map(sysUserRole -> sysUserRole.getRoleId()).collect(Collectors.toList());
    sysUser.setRoleIds(ArrayUtil.toArray(roleIds, Long.class));

    String token = jwtUtil.generateToken(sysUser.getId(), sysUser.getUsername());
    sysUser.setToken(token);
    return sysUser;
}

5️⃣ 新建一个控制器,LoginController.java, 编写登录的 api 接口

@RestController
public class LoginController {
    @Autowired
    private SysUserService sysUserService;
    @PostMapping("/login")
    public Result login(@RequestBody SysUser user) {
        SysUser sysUser = sysUserService.login(user);
        return Result.success(sysUser);
    }
}

# pinia 简介

Pinia 是 Vue 的专属状态管理库,它允许你跨组件或页面共享状态。

pinia 官网:https://pinia.vuejs.org/zh/introduction.html (opens new window)

1️⃣ 安装 pinia

npm install pinia

2️⃣ 在 main.js 中注册使用 pinia

import { createPinia } from "pinia";

app.use(pinia);

3️⃣ 定义一个 用户的 store

Store (如 Pinia) 是一个保存状态和业务逻辑的实体,它并不与你的组件树绑定。换句话说,它承载着全局状态。它有点像一个永远存在的组件,每个组件都可以读取和写入它。它有三个概念,state、getter 和 action,我们可以假设这些概念相当于组件中的 data、 computed 和 methods。

store/user.js

import { defineStore } from "pinia";
import http from "@/utils/request.js";

export const useUserStore = defineStore("user", {
  state: () => ({
    userInfo: JSON.parse(localStorage.getItem("userInfo")) || null,
    token: localStorage.getItem("token") || null,
  }),
  actions: {
    setUserInfo(userInfo) {
      localStorage.setItem("userInfo", JSON.stringify(userInfo));
      this.userInfo = userInfo;
    },
    setToken(token) {
      localStorage.setItem("token", token);
      this.token = token;
    },
    async login(data) {
      const res = await http.post("/login", {
        username: data.username,
        password: data.password,
      });
      if (res.code == 200) {
        this.setUserInfo(res.data);
        this.setToken(res.data.token);
        return true;
      }
      return false;
    },
    logout() {
      this.setUserInfo(null);
      this.setToken("");
      localStorage.removeItem("token");
    },
  },
});

# 实现用户登录和注销功能

1️⃣ 编写登录的视图页面 Login.vue

<template>
  <div class="login-container">
    <div class="login-box">
      <h2>通用后台管理系统</h2>
      <el-form
        ref="loginFormRef"
        :model="loginForm"
        :rules="rules"
        class="login-form"
      >
        <el-form-item prop="username">
          <el-input
            v-model="loginForm.username"
            placeholder="用户名"
            size="large"
            prefix-icon="User"
          />
        </el-form-item>
        <el-form-item prop="password">
          <el-input
            v-model="loginForm.password"
            type="password"
            placeholder="密码"
            size="large"
            prefix-icon="Lock"
            @keyup.enter="handleLogin"
          />
        </el-form-item>
        <el-form-item>
          <el-button
            type="primary"
            size="large"
            :loading="loading"
            @click="handleLogin"
            style="width: 100%"
          >
            登录
          </el-button>
        </el-form-item>
      </el-form>
      <div style="text-align: right; color: #343434; font-size: 13px;">
        Author By @程序员毛毛@程序员小孟
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { login } from "@/api/login.js";
import { useUserStore } from "@/store/user.js";

const router = useRouter();
const userStore = useUserStore();

const loginFormRef = ref(null);
const loading = ref(false);

const loginForm = reactive({
  username: "admin",
  password: "123456",
});

const rules = {
  username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
  password: [{ required: true, message: "请输入密码", trigger: "blur" }],
};

const handleLogin = async () => {
  if (!loginFormRef.value) return;

  await loginFormRef.value.validate(async (valid) => {
    if (valid) {
      loading.value = true;
      const res = await userStore.login(loginForm);
      if (res) {
        ElMessage.success("登录成功");
        router.push("/users");
      } else {
        ElMessage.error("账号或者密码错误");
      }
      loading.value = false;
    }
  });
};
</script>

<style scoped>
.login-container {
  width: 100%;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  //background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  background: url("../assets/img/login_bg.jpg") no-repeat center center;
  background-size: cover;
}

.login-box {
  width: 400px;
  padding: 40px;
  background: white;
  border-radius: 10px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
}

.login-box h2 {
  text-align: center;
  margin-bottom: 30px;
  color: #333;
}

.login-form {
  margin-top: 20px;
}
</style>

2️⃣ 调用 store 中暴露的登录方法,进行登录校验

const handleLogin = async () => {
  if (!loginFormRef.value) return;

  await loginFormRef.value.validate(async (valid) => {
    if (valid) {
      loading.value = true;
      const res = await userStore.login(loginForm);
      if (res) {
        ElMessage.success("登录成功");
        router.push("/users");
      } else {
        ElMessage.error("账号或者密码错误");
      }
      loading.value = false;
    }
  });
};

3️⃣ 在主页面显示登录用户的信息

<el-dropdown style="outline: none; cursor: pointer;">
  <span style="display: flex;align-items: center;outline: none;">
    <img src="@/assets/img/avatar.jpg" alt="" style="width: 30px;height: 30px;border-radius: 50%; margin-right: 5px;">
    欢迎 {{userStore.userInfo?.nickname}}
  </span>
  <template #dropdown>
    <el-dropdown-menu>
      <el-dropdown-item @click="router.push('/profile')">个人中心</el-dropdown-item>
      <el-dropdown-item @click="router.push('/changePwd')">修改密码</el-dropdown-item>
      <el-dropdown-item divided @click="handleLogout">注销登录</el-dropdown-item>
    </el-dropdown-menu>
  </template>
</el-dropdown>

<script setup>
import { useUserStore } from "@/store/user";

const userStore = useUserStore();
</script>

4️⃣ 调用 store 的 logout 方法实现注销功能

import { useUserStore } from "@/store/user";

const userStore = useUserStore();

const handleLogout = () => {
  ElMessageBox.confirm("确定要退出登录吗?", "提示", {
    type: "warning",
  }).then(() => {
    userStore.logout();
    router.push("/login");
  });
};

# 实现修改个人资料功能

1️⃣ 编写个人资料的页面 Profile.vue, 从 pinia 中取出用户资料并显示

<template>
  <div style="padding: 16px;">
    <el-card>
      <template #header>
        <div
          style="display:flex;align-items:center;justify-content:space-between;"
        >
          <span>个人中心</span>
        </div>
      </template>
      <el-form :model="form" ref="formRef" label-width="88px">
        <el-form-item label="用户名" prop="username">
          <el-input v-model="form.username" />
        </el-form-item>
        <el-form-item label="昵称" prop="nickname">
          <el-input v-model="form.nickname" />
        </el-form-item>
        <el-form-item label="邮箱" prop="email">
          <el-input v-model="form.email" />
        </el-form-item>
        <el-form-item label="手机号" prop="phone">
          <el-input v-model="form.phone" />
        </el-form-item>
        <el-form-item label="角色" prop="roleIds">
          <el-tag
            style="margin-right: 10px;"
            type="warning"
            v-for="item in roleList"
            >{{ item.roleName }}</el-tag
          >
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-tag type="primary" v-if="form.status == 1">启用</el-tag>
          <el-tag type="danger" v-else>禁用</el-tag>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="onSubmit">确认修改</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useUserStore } from "@/store/user.js";
import { listRoles } from "@/api/role.js";

const userStore = useUserStore();

const form = ref({});
const roleList = ref([]);

onMounted(() => {
  Object.assign(form.value, userStore.userInfo);
  listRoles({ pageSize: 100 }).then((res) => {
    console.log(userStore.userInfo);
    roleList.value = res.data.list.filter((item) =>
      userStore.userInfo.roleIds.includes(item.id),
    );
  });
});
</script>

2️⃣ 调用 store 中的 updateUser 方法修改用户资料

const onSubmit = () => {
  userStore.updateUser(form.value).then((res) => {
    if (res) ElMessage.success("修改成功");
    else ElMessage.error("修改失败");
  });
};

store/user.js

async updateUser(data) {
    delete data.roleIds
    let res = await updateUser(data.id, data)
    if (res.code == 200){
        let u = await getUser(data.id);
        this.setUserInfo(u.data)
        return true
    }
    return false;
}

# 实现修改密码功能

1️⃣ 编写视图页面 ChangePwd.vue

<template>
  <div style="padding: 16px;">
    <el-card>
      <template #header>
        <div
          style="display:flex;align-items:center;justify-content:space-between;"
        >
          <span>修改密码</span>
        </div>
      </template>
      <el-form :model="form" ref="formRef" label-width="88px">
        <el-form-item label="原密码" prop="password">
          <el-input type="password" v-model="form.password" />
        </el-form-item>
        <el-form-item label="新密码" prop="newpassword">
          <el-input type="password" v-model="form.newpassword" />
        </el-form-item>
        <el-form-item label="确认密码" prop="repassword">
          <el-input type="password" v-model="form.repassword" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="onSubmit">确认修改</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>
<script setup>
import { ref } from "vue";

const form = ref({});
</script>

2️⃣ 编写后端修改密码 api 接口

@PostMapping("/changePwd")
public Result changePwd(HttpServletRequest request, @RequestBody Map<String, String> map) {
    //查询登录用户的信息
    String token = request.getHeader("token");
    Long userId = jwtUtil.getUserIdFromToken(token);
    SysUser user = sysUserService.getById(userId);

    if (!user.getPassword().equals(DigestUtil.md5Hex(map.get("password").getBytes()))){
        throw new CustomException("原密码错误");
    }
    sysUserService.updatePassword(userId,DigestUtil.md5Hex(map.get("newpassword").getBytes()));
    return Result.success();
}

3️⃣ 调用后端 api 接口,实现密码修改

const onSubmit = () => {
  formRef.value.validate((valid) => {
    if (valid) {
      changePassword(form.value).then((res) => {
        if (res.code == 200) {
          ElMessage.success("密码修改成功");
        } else {
          ElMessage.error(res.message);
        }
      });
    }
  });
};

# 实现登录后的动态路由和菜单的加载

# 思路分析

登录成功后,根据登录用户的角色查询对应的权限菜单, 根据所拥有的菜单动态显示侧边栏菜单并且生成动态路由。

# 编写后端的 API 接口查询用户所授权的菜单

controller/PermissionController.java

@GetMapping("/getRouters")
public Result getRouters(HttpServletRequest request) {
    String token = request.getHeader("token");
    Long userId = jwtUtil.getUserIdFromToken(token);

    List<SysPermission> permissions = sysPermissionService.selectPermissionByUserId(userId);

    return Result.success(permissions);
}

service.impl/PermissionServiceImpl.java

@Override
public List<SysPermission> selectPermissionByUserId(Long userId) {
    QueryWrapper<SysPermission> queryWrapper = new QueryWrapper<>();
    queryWrapper.in("id",baseMapper.selectPermissionIdsByUserId(userId));
    List<SysPermission> list = baseMapper.selectList(queryWrapper);

    return this.buildMenuTree(list);
}

PermissionMapper.java

@Mapper
public interface SysPermissionMapper extends BaseMapper<SysPermission> {
    @Select("SELECT permission_id from sys_role_permission where role_id in (SELECT role_id from sys_user_role where user_id = #{userId})")
    public List<Long> selectPermissionIdsByUserId(@Param("userId") Long userId);
}

# 创建 permissionStore,用于存储用户的路由信息

store/permission.js

import { defineStore } from "pinia";
import { getRouters } from "@/api/permission.js";
import { constantRoutes } from "@/router/index.js";

export const usePermissionStore = defineStore("permission", {
  state: () => ({
    routers: [],
  }),
  actions: {
    setRouters(routes) {
      this.routers = routes;
    },
    //生成路由
    generateRoutes() {
      return new Promise(async (resolve) => {
        const accessRouters = await getRouters();
        const array = new Array();
        //转换格式,将返回的权限菜单转成前端路由所需要的格式
        accessRouters.data.forEach((item) => {
          let tem = {
            path: item.path,
            name: item.name,
            meta: {
              title: item.permissionName, //标题文字
              icon: item.icon, //图标
            },
            component: () => import("../layout/MainLayout.vue"),
            children: item.children.map((child) => {
              return {
                path: child.path,
                name: child.name,
                meta: {
                  title: child.permissionName,
                  icon: child.icon,
                },
                component: () => import("../views/" + child.component + ".vue"),
              };
            }),
          };
          array.push(tem);
        });
        const routes = [...constantRoutes, ...array];
        this.setRouters(routes);
        resolve(array);
      });
    },
  },
});

# 在路由导航守卫中,调用 store 中的方法添加动态路由

router/index.js

import { createRouter, createWebHistory } from "vue-router";
import Login from "../views/Login.vue";
import { usePermissionStore } from "@/store/permission.js";

//静态路由列表
export const constantRoutes = [
  {
    path: "/login",
    name: "login",
    hidden: true,
    component: Login,
  },
  {
    path: "/",
    name: "home",
    redirect: "/dashborad",
    component: () => import("../layout/MainLayout.vue"),
    children: [
      {
        path: "dashborad",
        name: "dashboard",
        meta: {
          title: "首页",
          icon: "HomeFilled",
        },
        component: () => import("../views/Dashboard.vue"),
      },
      {
        path: "profile",
        name: "profile",
        hidden: true,
        meta: {
          title: "个人中心",
        },
        component: () => import("../views/Profile.vue"),
      },
      {
        path: "changePwd",
        name: "changePwd",
        hidden: true,
        meta: {
          title: "修改密码",
        },
        component: () => import("../views/ChangePwd.vue"),
      },
    ],
  },
];

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

router.beforeEach(async (to, from, next) => {
  if (to.path != "/login") {
    const permissionStore = usePermissionStore();
    if (permissionStore.routers.length == 0) {
      console.log("加载动态路由.....");
      //加载动态路由
      const routes = await permissionStore.generateRoutes();
      routes.forEach((route) => {
        router.addRoute(route);
      });
      if (!to.redirectedFrom) {
        next({ ...to, replace: true });
      } else {
        next();
      }
    } else {
      next();
    }
  } else {
    next();
  }
});

export default router;

# 编写侧边栏组件 SidebarMenu, 根据路由动态渲染侧边栏

layout/SidebarMenu.vue

<template>
  <el-menu
    :default-active="activeMenu"
    :collapse="isCollapse"
    router
    class="sidebar-menu"
    active-text-color="#ffd04b"
    background-color="#042E55"
    text-color="#ffffff"
  >
    <sidebar-item
      v-for="route in menuRoutes"
      :key="route.path"
      :item="route"
      :base-path="route.path"
    />
  </el-menu>
</template>
<script setup>
import { defineProps, ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { constantRoutes } from "@/router/index.js";
import SidebarItem from "@/layout/SidebarItem.vue";
import { usePermissionStore } from "@/store/permission.js";

const menuRoutes = ref([]);
const route = useRoute();
const router = useRouter();
const permissionStore = usePermissionStore();

const activeMenu = computed(() => {
  const { meta, path } = route;
  return meta.activeMenu || path;
});
//定义属性
defineProps({
  isCollapse: {
    type: Boolean,
    default: false,
  },
});

onMounted(() => {
  menuRoutes.value = permissionStore.routers;
});
</script>

<style scoped></style>

layout/SidebarItem.vue

<!-- components/Layout/SidebarItem.vue -->
<template>
  <div v-if="!item.hidden">
    <template
      v-if="
        hasOneShowingChild(item.children, item) &&
        (!onlyOneChild.children || onlyOneChild.noShowingChildren)
      "
    >
      <el-menu-item :index="resolvePath(onlyOneChild.path)">
        <el-icon v-if="onlyOneChild.meta?.icon">
          <component :is="onlyOneChild.meta.icon" />
        </el-icon>
        <template #title>
          {{ onlyOneChild.meta?.title }}
        </template>
      </el-menu-item>
    </template>

    <el-sub-menu v-else :index="resolvePath(item.path)">
      <template #title>
        <el-icon v-if="item.meta?.icon">
          <component :is="item.meta.icon" />
        </el-icon>
        <span>{{ item.meta?.title }}</span>
      </template>
      <sidebar-item
        v-for="child in item.children"
        :key="child.path"
        :item="child"
        :base-path="resolvePath(child.path)"
      />
    </el-sub-menu>
  </div>
</template>

<script setup>
import { computed, defineProps } from "vue";
import { resolve } from "path-browserify";

const props = defineProps({
  item: {
    type: Object,
    required: true,
  },
  basePath: {
    type: String,
    required: true,
  },
});

const onlyOneChild = computed(() => {
  if (!props.item.children) {
    props.item.children = [];
  }
  const children = props.item.children.filter((item) => !item.hidden) || [];

  if (children.length === 1 && !children[0].children) {
    return {
      ...children[0],
      path: resolve(props.basePath, children[0].path),
      noShowingChildren: true,
    };
  }

  return {
    ...props.item,
    path: props.basePath,
    noShowingChildren: !children || children.length === 0,
  };
});

const hasOneShowingChild = (children = [], parent) => {
  if (!children) {
    children = [];
  }
  const showingChildren = children.filter((item) => !item.hidden);

  if (showingChildren.length === 1) {
    return true;
  }

  if (showingChildren.length === 0) {
    return true;
  }
  return false;
};

const resolvePath = (routePath) => {
  return resolve(props.basePath, routePath);
};
</script>

# 实现按钮的控制、面包屑控件

# 根据路由实现动态的面包屑组件

1️⃣ 获取到当前匹配的路由信息: MainLayOut.vue

import { useRoute } from "vue-router";

const route = useRoute();
const breadcrumbList = ref([]);

const updateBreadcrumb = () => {
  breadcrumbList.value = route.matched.filter((item) => item.meta?.title);
};

onMounted(() => {
  updateBreadcrumb();
});

watch(
  () => route.path,
  (newPath, oldPath) => {
    updateBreadcrumb();
  },
  { immediate: true },
);

2️⃣ 动态渲染面包屑组件

<el-breadcrumb separator="/" style="flex: 1;">
  <el-breadcrumb-item :to="{ path: '/home' }">首页</el-breadcrumb-item>
  <el-breadcrumb-item
      v-for="item in breadcrumbList"
      :key="item.path"
      :to="item.path">
    {{ item.meta.title }}
  </el-breadcrumb-item>
</el-breadcrumb>

# 实现功能按钮的控制

权限分配可以具体到模块的增删改查的功能。

1️⃣ 修改用户登录的 api 接口,登录成功返回用户拥有的权限信息

修改 UserServiceImpl.java 的 login 方法

@Override
public SysUser login(SysUser user) {
    SysUser sysUser = baseMapper.selectOne(new QueryWrapper<SysUser>().eq("username", user.getUsername()));
    if (sysUser == null){
        throw new CustomException("用户不存在");
    }
    String md5Password = DigestUtils.md5DigestAsHex(user.getPassword().getBytes());
    if (!sysUser.getPassword().equals(md5Password)){
        throw new CustomException("密码不正确");
    }
    if (sysUser.getStatus() == 0) {
        throw new RuntimeException("用户已被禁用");
    }
    //查询用户角色
    List<SysUserRole> userRoleList = sysUserRoleMapper.selectList(new QueryWrapper<SysUserRole>().eq("user_id", sysUser.getId()));
    List<Long> roleIds = userRoleList.stream().map(sysUserRole -> sysUserRole.getRoleId()).collect(Collectors.toList());
    sysUser.setRoleIds(ArrayUtil.toArray(roleIds, Long.class));

    //查询用户拥有的权限字符串
    List<SysRolePermission> rolePermissionList = sysRolePermissionMapper.selectList(new QueryWrapper<SysRolePermission>().in("role_id", roleIds));
    List<SysPermission> permissionList = sysPermissionMapper.selectList(new QueryWrapper<SysPermission>().in("id", rolePermissionList.stream().map(sysRolePermission -> sysRolePermission.getPermissionId()).collect(Collectors.toList())));
    sysUser.setPermissions(permissionList.stream().map(sysPermission -> sysPermission.getPermissionCode()).collect(Collectors.toList()));

    String token = jwtUtil.generateToken(sysUser.getId(), sysUser.getUsername());
    sysUser.setToken(token);
    return sysUser;
}

2️⃣ 修改 store/user.js, 在 state 中增加 permissions 用于存储权限信息

state: () => ({
  userInfo: JSON.parse(localStorage.getItem("userInfo")) || null,
  token: localStorage.getItem("token") || null,
  permissions: localStorage.getItem("permissions").split("_") || [],
});

3️⃣ 自定义 Vue 指令并进行全局注册

directive/hasPermi.js

import { useUserStore } from "@/store/user.js";

export default {
  mounted(el, binding, vnode) {
    const userStore = useUserStore();

    const permissions = userStore.permissions;
    const { value } = binding;
    if (value && value instanceof Array && value.length > 0) {
      const permissionFlag = value;
      const hasPermissions = permissions.some((permission) => {
        return permissionFlag.includes(permission);
      });

      if (!hasPermissions) {
        el.parentNode && el.parentNode.removeChild(el);
      }
    }
  },
};

main.js

import hasPermi from "@/directive/hasPermi.js";
app.directive("hasPermi", hasPermi);

4️⃣ 在视图页面中使用自定义指令

<el-button
  type="primary"
  v-has-permi="['user:add']"
  :icon="Plus"
  @click="handleAdd"
>新增</el-button>
<el-button
  type="primary"
  v-has-permi="['user:edit']"
  :icon="Edit"
  :disabled="selectedIds.length !== 1"
  @click="handleEdit"
>编辑</el-button>
<el-button
  type="danger"
  v-has-permi="['user:delete']"
  :icon="Delete"
  :disabled="!selectedIds.length"
  @click="handleDelete"
>批量删除</el-button>
<el-button
  type="success"
  v-has-permi="['user:export']"
  :icon="Download"
  @click="handleExport"
>导出</el-button>

# 实现前后端的权限拦截控制

# 后端权限拦截校验

添加 JWT 的拦截器,判断前端是否传递了 token,如果没有 token 则提示无权访问。

common/JwtInterceptor.java

package com.maomao.auth.authapi.common;

import com.maomao.auth.authapi.utils.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class JWTInterceptor implements HandlerInterceptor {
    @Autowired
    private JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 跨域预检请求直接放行
        if ("OPTIONS".equals(request.getMethod())) {
            return true;
        }

        // 获取token
        String token = request.getHeader("token");
        if (token == null){
            token = request.getParameter("token");
        }
        if (token == null || token.isEmpty()) {
            throw new CustomException(401, "您无权操作!");
        }

        // 验证token
        if (!jwtUtil.validateToken(token)) {
            throw new CustomException(401, "您无权操作!");
        }

        return true;
    }
}

config/JWTConfig.java 注册拦截器并设置白名单

package com.maomao.auth.authapi.config;

import com.maomao.auth.authapi.common.JWTInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class JWTConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/login","/register","/upload","/download/**","/api/**"
                        );
    }

    @Bean
    public JWTInterceptor jwtInterceptor(){
        return new JWTInterceptor();
    }
}

# 前端权限拦截

前端配置权限拦截主要在路由导航守卫中来进行

router/index.js

//定义白名单
const whiteList = ["/login"];
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore();
  if (!userStore.token && !whiteList.includes(to.path)) {
    next("/login");
  }
  if (to.path != "/login") {
    const permissionStore = usePermissionStore();
    if (permissionStore.routers.length == 0) {
      console.log("加载动态路由.....");
      //加载动态路由
      const routes = await permissionStore.generateRoutes();
      routes.forEach((route) => {
        router.addRoute(route);
      });
      if (!to.redirectedFrom) {
        next({ ...to, replace: true });
      } else {
        next();
      }
    } else {
      next();
    }
  } else {
    next();
  }
});