若依Ruoyi-Vue学习笔记

文章目录

  • 0. 前言
    • 目标
    • 功能的基本流程
    • 环境要求
  • 1. 运行Ruoyi
    • 1.1 下载
    • 1.2 配置数据库
    • 1.3 配置Redis
    • 1.4 日志
    • 1.5 启动后端
    • 1.6 启动前端
  • 2. 登陆功能
    • 2.1 验证码
      • 基本思路
      • 前端实现
        • 请求的封装
        • 反向代理
      • 后端实现
    • 2.2 登陆
      • 前端实现
      • 后端实现
        • 控制层
        • 业务层
    • 2.3 获取用户角色和权限
      • 前端实现
      • 后端实现
    • 2.4 获取动态菜单路由
  • 3. 数据加载
    • 3.1 首页数据加载
    • 3.2 用户管理(PageHelper分页)
    • 3.3 部分树状图
  • 4. 用户增删查改
  • 5. 异步任务管理器
  • 6. 代码自动生成

0. 前言

Ruoyi前后端分离版:SpringBoot + Vue

官网:https://ruoyi.vip

参考视频:【开源项目学习】若依前后端分离版,通俗易懂,快速上手

目标

学习开源项目的目标:

  1. 用,减少自己的工作量
  2. 学习优秀开源项目的底层编程思想、设计思路,提升自己的编程能力

使用、学习开源项目的流程:

  1. 下载并运行
  2. 看懂业务流程
  3. 进行二次开发

功能的基本流程

  1. 加载Vue页面
  2. 请求后端

环境要求

  1. JDK1.8+
  2. MySQL8+
  3. Redis
  4. Maven
  5. Vue

1. 运行Ruoyi

1.1 下载

从Gitee官网复制url在IDEA中打开(后端),注意前端Vue项目ruoyi-ui需要额外使用一个idea打开。

1.2 配置数据库

表:直接执行/sql下的两个sql文件,在本地创建表

1M367v

数据源:修改配置文件中数据源配置

若依Ruoyi-Vue学习笔记_第1张图片

1.3 配置Redis

使用Docker启动Redis

修改Redis配置

若依Ruoyi-Vue学习笔记_第2张图片

若依Ruoyi-Vue学习笔记_第3张图片

1.4 日志

需要在ruoyi-admin/src/main/resources/logback.xml中修改日志存放位置:

若依Ruoyi-Vue学习笔记_第4张图片

1.5 启动后端

启动admin中的springboot启动类

(♥◠‿◠)ノ゙  若依启动成功   ლ(´ڡ`ლ)゙  
 .-------.       ____     __        
 |  _ _   \      \   \   /  /    
 | ( ' )  |       \  _. /  '       
 |(_ o _) /        _( )_ .'         
 | (_,_).' __  ___(_ o _)'          
 |  |\ \  |  ||   |(_,_)'         
 |  | \ `'   /|   `-'  /           
 |  |  \    /  \      /           
 ''-'   `'-'    `-..-'    

若依Ruoyi-Vue学习笔记_第5张图片

1.6 启动前端

根据ruoyi-ui项目中的README.md文件进行配置安装依赖,然后启动

# 克隆项目
git clone https://gitee.com/y_project/RuoYi-Vue

# 进入项目目录
cd ruoyi-ui

# 安装依赖
npm install

# 建议不要直接使用 cnpm 安装依赖,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题
npm install --registry=https://registry.npmmirror.com

# 启动服务
npm run dev

2. 登陆功能

2.1 验证码

基本思路

简而言之:前端让后端出一道算术题,后端把题目告诉前端,并把答案放入后端的Redis中,前端计算完结果后去后端的Redis中比对答案。

每次需要登录时,会在后端自动生成验证码,如“1+1=?@2”,验证码“1+1=?”会被转成图片传到前端登陆页面,答案“2”会被存储进Redis中(@是用于分割问题和答案的标记符号)。当前端输入完账号密码和验证码后,系统会拿验证码“2”和Redis中的答案“2”进行比较,成功则再验证账号密码。Redis中的key值会被传到前端,如果有多人登陆,每个客户端可根据自己的key值查询redis中的value值(答案)。

如果通过Docker启动的Redis,可通过交互模式进入Redis容器,然后进入Redis客户端查看验证码答案。

若依Ruoyi-Vue学习笔记_第6张图片

# 通过交互模式进入Redis容器
docker exec -it 6ce bash

# 进入Redis
redis-cli

# 查看所有key
keys *

# 查看验证码答案
127.0.0.1:6379> get captcha_codes:aecd3ba23ab94614b2a7840e2625107c
"\"35\""

若依Ruoyi-Vue学习笔记_第7张图片

前端实现

请求的封装

验证码的代码实现在ruoyi-ui/src/views/login.vue中。

基本流程概括:打开登陆页面,向后端请求验证码图片和一个uuid(Redis的key)

若依Ruoyi-Vue学习笔记_第8张图片

前端Vue和后端Springboot交互时通常使用axios(ajax),而这里看不到axios的调用是因为进行了多次封装。如果再深入追溯,可进入getCodeImg()方法,然后发现还有封装:

若依Ruoyi-Vue学习笔记_第9张图片

进入login.js找到getCodeImg(),发现了ajax的基本写法:url、请求类型、超时时间,注意至此还是在request封装中,依旧没有看到axios。

// 获取验证码
export function getCodeImg() {
  return request({
    url: '/captchaImage',
    headers: {
      isToken: false
    },
    method: 'get',
    timeout: 20000
  })
}

接着进入ruoyi-ui/src/utils/request.js,找到axios:

axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
  // axios中请求配置有baseURL选项,表示请求URL公共部分
  baseURL: process.env.VUE_APP_BASE_API,
  // 超时
  timeout: 10000
})

其中VUE_APP_BASE_API定义在了配置文件.env.development中:

# 若依管理系统/开发环境
VUE_APP_BASE_API = '/dev-api'

这样任何请求都添加前缀’/dev-api’。

统一前缀是为了区分开发环境和生产环境。

反向代理

此时注意一个点:Vue获取图片时,前端项目的端口是1024;后端项目的端口是8080。理论上对验证码的信息的请求应该是对后端发起请求,但是url中实际还是对前端1024端口请求。

原因:反向代理,url在前端请求前端,进行代理,映射到后端,如此操作是为了解决跨域问题。跨域问题在后端的解决方式是Springboot添加一个配置类;前端的解决方式是反向代理。跨域问题在前端或者后端解决都可。

反向代理的配置在ruoyi-ui/vue.config.js中:

// webpack-dev-server 相关配置
  devServer: {
    host: '0.0.0.0',
    port: port,
    open: true,
    proxy: {
      // detail: https://cli.vuejs.org/config/#devserver-proxy
      [process.env.VUE_APP_BASE_API]: {
        target: `http://localhost:8080`,
        changeOrigin: true,
        pathRewrite: {
          ['^' + process.env.VUE_APP_BASE_API]: ''
        }
      }
    },
    disableHostCheck: true
  },

上面的pathRewrite里,会把前面的请求前缀替换为空,即’',再映射到后端的端口,即target。如此请求url从http://localhost:1024/dev-api/captchaImage变成了http://localhost:8080/captchaImage

后端实现

首先先定位到验证码功能的控制器,使用全局搜索(ctrl+shift+F)对admin项目搜索captchaImage,找到CaptchaController

		/**
     * 生成验证码
     */
    @GetMapping("/captchaImage")
    public AjaxResult getCode(HttpServletResponse response) throws IOException
    {
        // 最终需要返回给前端的ajax结果(封装版)
        AjaxResult ajax = AjaxResult.success();
        // 检查是否开启验证码
        boolean captchaOnOff = configService.selectCaptchaOnOff();
        ajax.put("captchaOnOff", captchaOnOff);
        if (!captchaOnOff)
        {
            return ajax;
        }

        // 保存验证码信息
        String uuid = IdUtils.simpleUUID();
        // 拼接一个key,用于放入redis,如“captcha_codes:aecd3ba23ab94614b2a7840e2625107c”
        String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid;

        String capStr = null, code = null;
        BufferedImage image = null;

        // 生成验证码
        String captchaType = RuoYiConfig.getCaptchaType();
        if ("math".equals(captchaType))
        {
            String capText = captchaProducerMath.createText();
            capStr = capText.substring(0, capText.lastIndexOf("@"));
            code = capText.substring(capText.lastIndexOf("@") + 1);
            image = captchaProducerMath.createImage(capStr);
        }
        else if ("char".equals(captchaType))
        {
            capStr = code = captchaProducer.createText();
            image = captchaProducer.createImage(capStr);
        }
				// 将key和value存入redis,并设置缓存时间
        redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
        // 转换流信息写出
        FastByteArrayOutputStream os = new FastByteArrayOutputStream();
        try
        {
            ImageIO.write(image, "jpg", os);
        }
        catch (IOException e)
        {
            return AjaxResult.error(e.getMessage());
        }

        ajax.put("uuid", uuid);
        ajax.put("img", Base64.encode(os.toByteArray()));
        return ajax;
    }

这个AjaxResult就是后端给前端返回的数据对象,通常称为VO或ResultVO或R(前端与后端交互时的统一数据模型)。

2.2 登陆

前端实现

登陆的前端实现和验证码一样,依旧使用了前端反向代理。

登陆功能的前端实现主要是由handleLogin()方法实现的。

handleLogin() {
      this.$refs.loginForm.validate(valid => {
        if (valid) {
          this.loading = true;
          if (this.loginForm.rememberMe) {
            Cookies.set("username", this.loginForm.username, { expires: 30 });
            Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 });
            Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 });
          } else {
            Cookies.remove("username");
            Cookies.remove("password");
            Cookies.remove('rememberMe');
          }
          this.$store.dispatch("Login", this.loginForm).then(() => {
            this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
          }).catch(() => {
            this.loading = false;
            if (this.captchaOnOff) {
              this.getCode();
            }
          });
        }
      });
    }
  • 登陆使用的是表单,有“记住密码”功能,如果勾选,则将用户名密码和记住我选项存入cookie中,否则移除。
  • 其中登陆是由Login实现,它是一个action;获取到用户信息后构建并返回一个Promise,它是es6提供的异步处理的对象。
actions: {
    // 登录
    Login({ commit }, userInfo) {
      const username = userInfo.username.trim()
      const password = userInfo.password
      const code = userInfo.code
      const uuid = userInfo.uuid
      return new Promise((resolve, reject) => {
        login(username, password, code, uuid).then(res => {
          setToken(res.token)
          commit('SET_TOKEN', res.token)
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },
  • 后端校验成功后,将后端返回的token保存起来(令牌是加密后的用户信息)
  • 其中login方法又是封装定义好的方法…,最终还是ajax
// 登录方法
export function login(username, password, code, uuid) {
  const data = {
    username,
    password,
    code,
    uuid
  }
  return request({
    url: '/login',
    headers: {
      isToken: false
    },
    method: 'post',
    data: data
  })
}

后端实现

控制层

登陆的后端实现在ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysLoginController.java,逻辑很简单:生成需要返回的AjaxResult对象,调用Service层的login方法(需要username,password和验证码),生成令牌,放入ajax返回。

关于密码加密:密码是不会在前端或者后端中加密(传输时https协议会进行加密解密;http不会),而是在数据库中(持久层)进行加密存储。

    /**
     * 登录方法
     * 
     * @param loginBody 登录信息
     * @return 结果
     */
    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginBody loginBody)
    {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
                loginBody.getUuid());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }

业务层

大体流程与验证码生成类似:首先验证验证码,然后验证账号和密码。

    /**
     * 登录验证
     * 
     * @param username 用户名
     * @param password 密码
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public String login(String username, String password, String code, String uuid)
    {
        boolean captchaOnOff = configService.selectCaptchaOnOff();
        // 验证码开关
        if (captchaOnOff)
        {
            validateCaptcha(username, code, uuid);
        }
        // 用户验证
        Authentication authentication = null;
        try
        {
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager
                    .authenticate(new UsernamePasswordAuthenticationToken(username, password));
        }
        catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
								// 其他任何异常              
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        // 记录用户登陆日志
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
      	// 记录用户最近登陆信息
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

前后端不分离版使用的安全框架时Shiro;而分离版使用的是Spring Security。

注意最后的recordLoginInfo方法:记录用户最近的登陆信息。后台的数据表会记录用户登陆的ip和时间。

登陆的日志信息会存入sys_logininfor

若依Ruoyi-Vue学习笔记_第10张图片

用户最近登陆信息则存入(更新)sys_user
若依Ruoyi-Vue学习笔记_第11张图片

    /**
     * 校验验证码
     * 
     * @param username 用户名
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public void validateCaptcha(String username, String code, String uuid)
    {
        // 拼接Redis的key值
        String verifyKey = Constants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
        // 去Redis中验证key值是否存在(可能因为长时间未使用而过期)
        String captcha = redisCache.getCacheObject(verifyKey);
        // key已经使用过,及时删除
        redisCache.deleteObject(verifyKey);
        if (captcha == null)
        {
            // 如果key不存在,则“异步记录日志”
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
            // 抛出异常
            throw new CaptchaExpireException();
        }
        if (!code.equalsIgnoreCase(captcha))
        {
            // 如果code(value)验证不正确,同样异步记录日志
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
            throw new CaptchaException();
        }
    }
  • 代码亮点/难点就是使用了异步任务管理器(后面会说),当redis的key不存在时,异步记录日志。这么做的好处就是用到了异步分离,可以避免线程阻塞,让主线程运行快一些(但日志使用异步的意义不大)。
  • 关于抛出异常:大型项目和复杂业务直接return的情况少(理想状况才是直接return),通常都是自定义异常处理给系统抓取,前端展示异常信息。而如果只用return(包括错误情况),那么上层的调用方还需要对return的值作判断处理;而使用异常当前调用直接结束,相当于短路处理。

2.3 获取用户角色和权限

前端实现

通过查看浏览器的请求可以发现每次登陆除了login还有getInfogetRouters

若依Ruoyi-Vue学习笔记_第12张图片

这两个请求可以在ruoyi-ui/src/permission.js中找到:

router.beforeEach((to, from, next) => {
  NProgress.start()
  if (getToken()) {
    to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
    /* has token*/
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      if (store.getters.roles.length === 0) {
        isRelogin.show = true
        // 判断当前用户是否已拉取完user_info信息
        store.dispatch('GetInfo').then(() => {
          isRelogin.show = false
          store.dispatch('GenerateRoutes').then(accessRoutes => {
            // 根据roles权限生成可访问的路由表
            router.addRoutes(accessRoutes) // 动态添加可访问路由表
            next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
          })
        }).catch(err => {
            store.dispatch('LogOut').then(() => {
              Message.error(err)
              next({ path: '/' })
            })
          })
      } else {
        next()
      }
    }
    ...

上面这段代码的意思是:前端每个页面进行跳转时,都会进入到这个方法(获取信息、路由…),是Vue router的请求拦截器(全局路由管理器、路由前置守卫)

进一步查看GetInfo方法:

// 获取用户信息
    GetInfo({ commit, state }) {
      return new Promise((resolve, reject) => {
        getInfo().then(res => {
          const user = res.user
          const avatar = (user.avatar == "" || user.avatar == null) ? require("@/assets/images/profile.jpg") : process.env.VUE_APP_BASE_API + user.avatar;
          if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
            commit('SET_ROLES', res.roles)
            commit('SET_PERMISSIONS', res.permissions)
          } else {
            commit('SET_ROLES', ['ROLE_DEFAULT'])
          }
          commit('SET_NAME', user.userName)
          commit('SET_AVATAR', avatar)
          resolve(res)
        }).catch(error => {
          reject(error)
        })
      })
    },

发现里面还是封装了一个Promise对象,进行异步调用。

commit是对roles和permissions进行全局存储,这样之后在页面内就可以直接使用,而不用每次都进行查询。

同时里面又封装了getInfo方法:

// 获取用户详细信息
export function getInfo() {
  return request({
    url: '/getInfo',
    method: 'get'
  })
}

后端实现

这里集成了Spring Security,可以直接获取当前登陆的user。

    /**
     * 获取用户信息
     * 
     * @return 用户信息
     */
    @GetMapping("getInfo")
    public AjaxResult getInfo()
    {
        SysUser user = SecurityUtils.getLoginUser().getUser();
        // 角色集合
        Set<String> roles = permissionService.getRolePermission(user);
        // 权限集合
        Set<String> permissions = permissionService.getMenuPermission(user);
        AjaxResult ajax = AjaxResult.success();
        ajax.put("user", user);
        ajax.put("roles", roles);
        ajax.put("permissions", permissions);
        return ajax;
    }

测试获取角色和权限

若依Ruoyi-Vue学习笔记_第13张图片

查看数据库中表关系可以发现:

  • 每个用户有一个user_id
  • 每个角色对应一个role_id
  • 第三张中间表维护user_id和role_id的对应关系,多对多

2.4 获取动态菜单路由

前面分析了GetInfo,下面分析GenerateRoutes,看看Ruoyi是怎么动态获取菜单路由的。

router.beforeEach((to, from, next) => {
  NProgress.start()
  if (getToken()) {
    to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
    /* has token*/
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      if (store.getters.roles.length === 0) {
        isRelogin.show = true
        // 判断当前用户是否已拉取完user_info信息
        store.dispatch('GetInfo').then(() => {
          isRelogin.show = false
          store.dispatch('GenerateRoutes').then(accessRoutes => {
            // 根据roles权限生成可访问的路由表
            router.addRoutes(accessRoutes) // 动态添加可访问路由表
            next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
          })
        }).catch(err => {
            store.dispatch('LogOut').then(() => {
              Message.error(err)
              next({ path: '/' })
            })
          })
      } else {
        next()
      }
    }
    ...

进入GenerateRoutes方法,

  actions: {
    // 生成路由
    GenerateRoutes({ commit }) {
      return new Promise(resolve => {
        // 向后端请求路由数据
        getRouters().then(res => {
          const sdata = JSON.parse(JSON.stringify(res.data))
          const rdata = JSON.parse(JSON.stringify(res.data))
          const sidebarRoutes = filterAsyncRouter(sdata)
          const rewriteRoutes = filterAsyncRouter(rdata, false, true)
          const asyncRoutes = filterDynamicRoutes(dynamicRoutes);
          rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
          router.addRoutes(asyncRoutes);
          commit('SET_ROUTES', rewriteRoutes)
          commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes))
          commit('SET_DEFAULT_ROUTES', sidebarRoutes)
          commit('SET_TOPBAR_ROUTES', sidebarRoutes)
          resolve(rewriteRoutes)
        })
      })
    }
  }
// 获取路由
export const getRouters = () => {
  return request({
    url: '/getRouters',
    method: 'get'
  })
}

后端Controller逻辑和前面功能一样,依旧使用SpringSecurity获取用户(用户id),然后通过菜单service获取权限对应的菜单,最终通过Ajax对象返回:

    /**
     * 获取路由信息
     * 
     * @return 路由信息
     */
    @GetMapping("getRouters")
    public AjaxResult getRouters()
    {
        Long userId = SecurityUtils.getUserId();
        List<SysMenu> menus = menuService.selectMenuTreeByUserId(userId);
        return AjaxResult.success(menuService.buildMenus(menus));
    }

service:

    /**
     * 根据用户ID查询菜单
     * 
     * @param userId 用户名称
     * @return 菜单列表
     */
    @Override
    public List<SysMenu> selectMenuTreeByUserId(Long userId)
    {
        List<SysMenu> menus = null;
        if (SecurityUtils.isAdmin(userId))
        {
            menus = menuMapper.selectMenuTreeAll();
        }
        else
        {
            menus = menuMapper.selectMenuTreeByUserId(userId);
        }
        return getChildPerms(menus, 0);
    }

mapper:

    <select id="selectMenuTreeByUserId" parameterType="Long" resultMap="SysMenuResult">
		select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.component, m.`query`, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.is_cache, m.menu_type, m.icon, m.order_num, m.create_time
		from sys_menu m
			 left join sys_role_menu rm on m.menu_id = rm.menu_id
			 left join sys_user_role ur on rm.role_id = ur.role_id
			 left join sys_role ro on ur.role_id = ro.role_id
			 left join sys_user u on ur.user_id = u.user_id
		where u.user_id = #{userId} and m.menu_type in ('M', 'C') and m.status = 0  AND ro.status = 0
		order by m.parent_id, m.order_num
	select>

若依Ruoyi-Vue学习笔记_第14张图片

以上就是以树形嵌套结构查询出的结果

注意mapper层并未实现嵌套关系,而是在Java层面实现(使用递归实现;但是个人感觉使用Map存储是最快的;当然如果把这个工作交给前端来实现也是完全可以的),这里的父子菜单嵌套非常类似我之前写的博客的父子评论嵌套设计(树形结构):博客-评论系统数据库设计及实现

/**
     * 根据父节点的ID获取所有子节点
     * 
     * @param list 分类表
     * @param parentId 传入的父节点ID
     * @return String
     */
    public List<SysMenu> getChildPerms(List<SysMenu> list, int parentId)
    {
        List<SysMenu> returnList = new ArrayList<SysMenu>();
        for (Iterator<SysMenu> iterator = list.iterator(); iterator.hasNext();)
        {
            SysMenu t = (SysMenu) iterator.next();
            // 一、根据传入的某个父节点ID,遍历该父节点的所有子节点
            if (t.getParentId() == parentId)
            {
                recursionFn(list, t);
                returnList.add(t);
            }
        }
        return returnList;
    }

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

3. 数据加载

3.1 首页数据加载

前端有路由控制,登陆完成后跳转到/即index主页面:

  this.$store.dispatch("Login", this.loginForm).then(() => {
    this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
  }).catch(() => {
    this.loading = false;
    if (this.captchaOnOff) {
      this.getCode();
    }
  });

关于Vue路由的控制都在ruoyi-ui/src/router/index.js:

	// index主页面	
	{
    path: '',
    component: Layout,
    redirect: 'index',
    children: [
      {
        path: 'index',
        component: () => import('@/views/index'),
        name: 'Index',
        meta: { title: '首页', icon: 'dashboard', affix: true }
      }
    ]
  },

关于Vue的页面布局在ruoyi-ui/src/layout/index.vue:



...

3.2 用户管理(PageHelper分页)

若依Ruoyi-Vue学习笔记_第15张图片

这里省略前端的代码,直接贴后端的处理

Controller:

  1. 设置分页
  2. 进行正常的查询(PageHelper中的拦截器会拦截数据库sql查询语句,并加入分页的sql语句,完成分页)
  3. 把查询结果封装后返回
    /**
     * 获取用户列表
     */
    @PreAuthorize("@ss.hasPermi('system:user:list')")
    @GetMapping("/list")
    public TableDataInfo list(SysUser user)
    {
        startPage();	// 设置请求分页数据
        List<SysUser> list = userService.selectUserList(user);
        return getDataTable(list);	// 封装返回结果
    }
    /**
     * 响应请求分页数据
     */
    @SuppressWarnings({ "rawtypes", "unchecked" })
    protected TableDataInfo getDataTable(List<?> list)
    {
        TableDataInfo rspData = new TableDataInfo();
        rspData.setCode(HttpStatus.SUCCESS);
        rspData.setMsg("查询成功");
        rspData.setRows(list);
        rspData.setTotal(new PageInfo(list).getTotal());
        return rspData;
    }

上面代码本质与直接使用PageHelper一样,但是作者进行了非常多的抽象与封装,包括把PageHelper封装成自己的工具类PageUtils,把返回的结果封装成TableDataInfo(类似于Map)。

/**
     * 设置请求分页数据
     */
    public static void startPage()
    {
        PageDomain pageDomain = TableSupport.buildPageRequest();
        Integer pageNum = pageDomain.getPageNum();
        Integer pageSize = pageDomain.getPageSize();
        String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
        Boolean reasonable = pageDomain.getReasonable();
        PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
    }

下面封装非常多,这里就不一一分析,主要看我们是怎么获取到前端传递过来的pageNumpageSize的:从HttpServletRequest中获取(作者又在这里对Servlet的工具类进行封装)

public class TableSupport
{
    /**
     * 当前记录起始索引
     */
    public static final String PAGE_NUM = "pageNum";

    /**
     * 每页显示记录数
     */
    public static final String PAGE_SIZE = "pageSize";

    /**
     * 排序列
     */
    public static final String ORDER_BY_COLUMN = "orderByColumn";

    /**
     * 排序的方向 "desc" 或者 "asc".
     */
    public static final String IS_ASC = "isAsc";

    /**
     * 分页参数合理化
     */
    public static final String REASONABLE = "reasonable";

    /**
     * 封装分页对象
     */
    public static PageDomain getPageDomain()
    {
        PageDomain pageDomain = new PageDomain();
        pageDomain.setPageNum(Convert.toInt(ServletUtils.getParameter(PAGE_NUM), 1));
        pageDomain.setPageSize(Convert.toInt(ServletUtils.getParameter(PAGE_SIZE), 10));
        pageDomain.setOrderByColumn(ServletUtils.getParameter(ORDER_BY_COLUMN));
        pageDomain.setIsAsc(ServletUtils.getParameter(IS_ASC));
        pageDomain.setReasonable(ServletUtils.getParameterToBool(REASONABLE));
        return pageDomain;
    }

    public static PageDomain buildPageRequest()
    {
        return getPageDomain();
    }
}

3.3 部分树状图

跟菜单列表是一样的逻辑

若依Ruoyi-Vue学习笔记_第16张图片

    /**
     * 获取部门下拉树列表
     */
    @GetMapping("/treeselect")
    public AjaxResult treeselect(SysDept dept)
    {
        List<SysDept> depts = deptService.selectDeptList(dept);
        return AjaxResult.success(deptService.buildDeptTreeSelect(depts));
    }

这里特别注意buildTreeSelect方法,他把查询到的Tree中的SysDept通过stream转化成TreeSelect结构,即从后端数据转化成给前端显示的数据。

    /**
     * 构建前端所需要下拉树结构
     * 
     * @param depts 部门列表
     * @return 下拉树结构列表
     */
    @Override
    public List<TreeSelect> buildDeptTreeSelect(List<SysDept> depts)
    {
        List<SysDept> deptTrees = buildDeptTree(depts);
        return deptTrees.stream().map(TreeSelect::new).collect(Collectors.toList());
    }

从包含很多无关信息的dept
若依Ruoyi-Vue学习笔记_第17张图片

变到了只剩部门id、部名名和children的树形结构,这样前端解析就很方便和舒服

若依Ruoyi-Vue学习笔记_第18张图片

4. 用户增删查改

增删查改比较基础这里不作详细记录。

在前端的表单里,因为逻辑简单,用户新增修改是使用同一张表单,判断具体是新增还是修改的条件是是否有userId字段。

    /** 提交按钮 */
    submitForm: function() {
      this.$refs["form"].validate(valid => {
        if (valid) {
          if (this.form.userId != undefined) {
            updateUser(this.form).then(response => {
              this.$modal.msgSuccess("修改成功");
              this.open = false;
              this.getList();
            });
          } else {
            addUser(this.form).then(response => {
              this.$modal.msgSuccess("新增成功");
              this.open = false;
              this.getList();
            });
          }
        }
      });
    },

而后端的处理则是将修改和新增分开

5. 异步任务管理器

以登陆时账号密码不匹配为例:

	AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
	throw new UserPasswordNotMatchException();

通过异步任务管理器记录(登陆)日志:

  1. AsyncManager.me()获取一个AsyncManager对象(单例模式)
  2. 执行execute方法,执行任务,传入的是一个TimerTask对象
    /**
     * 执行任务
     * 
     * @param task 任务
     */
    public void execute(TimerTask task)
    {
        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
    }
  1. TimerTask对象实现了Runnable接口,是一个任务,由一个线程Thread去执行,注意这里的executors就是一个线程池
    /**
     * 异步操作任务调度线程池
     */
    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");

总结:

异步任务管理器,内部定义了一个线程池,然后根据业务,创建添加日志的任务,交给线程池来执行,这样就做到了日志和业务的抽象与解耦合。

6. 代码自动生成

B站视频

Ruoyi提供了代码自动生成功能(MyBatisPlus只能自动生成后端代码,Ruoyi生成前后端+数据库的代码),我们只需要在数据库创建数据表,在管理页面即可自动生成增删改查的代码。

从Ruoyi为我们生成的代码文件结构也可以很清楚的了解到整个项目的结构(前后端+数据库)

  1. 先在mysql数据库中创建一张实体类的表test_user
use `ruoyi-vue`;
create table test_user(
    id int primary key auto_increment,
    name varchar(20),
    password varchar(20)
);
  1. 在后台页面的系统工具->代码生成->导入新表

若依Ruoyi-Vue学习笔记_第19张图片
3. 预览代码,Ruoyi自动为我们生成了从数据库到后端到前端的代码
若依Ruoyi-Vue学习笔记_第20张图片
若依Ruoyi-Vue学习笔记_第21张图片
5. 编辑:基本信息、字段信息、生成信息

若依Ruoyi-Vue学习笔记_第22张图片 若依Ruoyi-Vue学习笔记_第23张图片
6. 点击生成代码,生成zip压缩包,解压复制到Ruoyi项目中,解压后可以看到三部分:main(Java后端), vue(Vue前端), sql(菜单的SQL语句)
7. CV导入代码,执行sql,重启(rebuild后端项目)前后端项目。可以看到“测试代码生成”页面,拥有基本的增删查改

若依Ruoyi-Vue学习笔记_第24张图片

你可能感兴趣的:(项目,SpringBoot,vue.js,spring,boot,Ruoyi,前后端分离)