VBlog

前言

这个项目看起来比较详细,接下来我会按照流程学习一下这个项目。
在这里感谢 Evan-Nightly的教程
https://learner.blog.csdn.net/article/details/88925013
https://github.com/Antabot/White-Jotter

项目简介

我之前也学习过前后端的一些知识,但是一直没能将知识串起来做出一个小的实现
项目,这个教程就是使用最新的前后端分离技术,这个项目的名字叫做白卷,按照
Evan的话说,这是一个简陋的带后台的门户网站

应用架构

用户界面层是Vue负责,剩下的内容都是在SpringBoot中完成

  • 用户界面层 用户看到的页面
  • 服务层 向服务器发出请求后台接收请求
  • 业务逻辑层 针对具体问题的操作
  • 数据访问层 对数据库的操作
  • 数据存储 数据库

技术架构

我学习的内容是Vue+SpringBoot+mybatis开发一个前后端分离项目

第一部分

  • 如何从 0 开始搭建 Web 项目?
  • 什么是前后端分离?如何实现前后端分离?
  • 单页面应用有哪些特点?
  • 如何在 Web 项目中使用数据库并利用网页实现增删改查?
  • 在开发中如何利用各种辅助手段?
  • Vue.js 的基本概念与用法
  • 简单的前端页面设计
  • 如何部署 Web 应用?

首页 图书页面展示

第二部分

  • 后台管理模块的常见功能与布局(内容管理、用户\权限管理、运维监控)
  • 用户身份验证、授权、会话管理与信息加密存储
  • Shiro 框架的使用
  • 实现不同粒度的访问控制(动态菜单、功能控制、数据控制)
  • 结合内容管理,实现文章的编写与展示

后台基本结构 后台页面效果 图书管理
用户管理 文章列表 文章详情

第三部分

第三部分是在前面的基础上分析项目存在的不足,并对其进行由点及面的优化,
这一部分作者还未更新

构建Vue.js项目

具体构建过程可以参考我的博客:《vue总结》

index.html

首页文件的初始化代码,viewpoint是用户网页的可视区域,用于适配手机浏览
index.html描述首页的内容,Vue项目中只有一个html文件,这是一个单页面
应用,之后所有开发的页面都会在这个单页面内部显示

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

App.vue

这是一个根组件,所有组件都包含在这个组件中。vue文件是一种自定义文
件类型,类似于html。srcipt中的内容是js代码,就是将这个组件整体导
出,这里的id=”app”与index.html中的id没有关系,只是与css对应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div id="app">
<!-- 这是一个容器,叫做路由视图,当前路由(URL)指向
的内容都会显示在这个容器中,所以即使其他组件拥有自己的
路由,表面上是一个单独的页面,实际上还是在App.vue内部-->
<router-view/>
</div>
</template>
<script>
<!-- 将组件整体导出,之后就可以通过import引入这个组件 -->
export default {
name: 'App'
}
</script>
<style>
</style>

main.js

在这个js文件中创建了一个vue实例,App.vue组件挂载在main.js中的vue上
项目启动时会加载main.js,在main.js中会实例化vue,实例化vue的时候会
指定路由、模板、组件以及挂载点的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Vue from 'vue'
//引入App.vue
import App from './App.vue'
import router from './router'
import store from './store'
//阻止Vue在启动时产生提示信息
Vue.config.productionTip = false
//提供一个在页面上已存在的 DOM 元素作为 Vue 对象的挂载目标,这是最新写法
new Vue({
//router 代表该对象包含 Vue Router,并使用项目中定义的路由
router,
store,
//传入App.vue组件
render: h => h(App)
}).$mount('#app')

前后端结合测试

后端项目创建

可以参考我的SpringBoot博客

关于前后端结合

在开发的时候,前端用前端的服务器Nginx,后端用后端的服务器Tomcat,当
开发前端内容的时候,可以把前端的请求通过前端服务器传递给后端(称为反
向代理),这样就可以实时观察结果,而不需要知道后端如何实现

前端页面开发

首先开发登录组件,在src/components文件夹下创建Login.vue组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<div>
用户名:<input type="text" v-model=
"loginForm.username" placeholder="请输入用户名"/>
<br><br>
密码: <input type="password" v-model=
"loginForm.password" placeholder="请输入密码"/>
<br><br>
<button @click="login" >登录</button>
</div>
</template>

<script>
export default {
name: 'Login',
data () {
return {
loginForm: {
username: '',
password: ''
},
responseResult: []
}
},
methods: {
login () {
this.$axios
.post('/hellologin',{
username: this.loginForm.username,
password: this.loginForm.password
})
.then(successResponse => {
console.log(successResponse)
if (successResponse.data.code === 200) {
this.$router.replace({path: '/index'})
}
})
.catch(failResponse => {
})
}
}
}
</script>

开发首页界面,在src/components文件夹下创建home文件夹,在home文件夹下
创建AppIndex.vue,也就是登录成功后跳转到的页面

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div>
Hello World!
</div>
</template>
<script>
export default {
name: 'AppIndex'
}
</script>
<style scoped>
</style>

前端相关的配置

修改src/main.js文件,设置反向代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
// 设置反向代理,前端请求默认发送到 http://localhost:8081/api
var axios = require('axios')
axios.defaults.baseURL = 'http://localhost:8081/api'
// 全局注册,之后可在其他组件中通过 this.$axios 发送数据
Vue.prototype.$axios = axios
Vue.config.productionTip = false
Vue.use(ElementUI)

new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

配置页面路由

之前编写了Login和APPIndex文件,可以为这两个文件配置路由,router中
有一个index.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const routes = [
{
path: '',
name: 'App',
component: App,
//默认跳转到的路由
redirect: Login
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/index',
name: 'AppIndex',
component: AppIndex
}
]

解决跨域问题

在根目录下创建 vue.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
module.exports = {
/* 部署生产环境和开发环境下的URL:可对当前环境进行区分,baseUrl
从 Vue CLI 3.3 起已弃用,要使用publicPath */
/* baseUrl: process.env.NODE_ENV === 'production' ? './' : '/' */
publicPath: process.env.NODE_ENV === 'production' ? '/public/' : './',
/* 输出文件目录:在npm run build时,生成文件的目录名称 */
outputDir: 'dist',
/* 放置生成的静态资源 (js、css、img、fonts) 的 (相对于 outputDir 的) 目录 */
assetsDir: "assets",
/* 是否在构建生产包时生成 sourceMap 文件,false将提高构建速度 */
productionSourceMap: false,
/* 默认情况下,生成的静态资源在它们的文件名中包含了
hash 以便更好的控制缓存,你可以通过将这个选项设为 false
来关闭文件名哈希。(false的时候就是让原来的文件名不改变) */
filenameHashing: false,
/* 代码保存时进行eslint检测 */
lintOnSave: true,
/* webpack-dev-server 相关配置 */
devServer: {
/* 自动打开浏览器 */
open: true,
/* 设置为0.0.0.0则所有的地址均能访问 */
host: '0.0.0.0',
port: 8080,
https: false,
hotOnly: false,
/* 使用代理 */
proxy: {
'/api': {
/* 目标代理服务器地址 */
target: 'http://localhost:8081',
/* 允许跨域 */
changeOrigin: true,
},
},
},
}

后端开发

User类,根据前端发送给后端的数据,后端需要创建相应的数据对象

1
2
3
4
.post('/hellologin', {
username: this.loginForm.username,
password: this.loginForm.password
})
1
2
3
4
5
6
7
package com.atguigu.mybatis.entity;
public class User {
int id;
String username;
String password;
...
}

Result类是为了构造response,主要是响应码

1
2
3
4
5
6
7
8
9
10
package com.atguigu.mybatis.result;

public class Result {
//响应码
private int code;
public Result(int code) {
this.code = code;
}
...
}

controller是对响应进行处理的部分,设定账号admin,密码是1223456,分别
与接收到的User信息进行比较,根据结果返回不同的Result

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
public class Logincontroller {
//处理post请求
@PostMapping(value="api/hellologin")
//将java对象转换为json格式的数据,写入到response对象的body区
public Result login(@RequestBody User requestUser)
{
// 对 html 标签进行转义,防止 XSS 攻击,比如< > ?
String username = requestUser.getUsername();
username = HtmlUtils.htmlEscape(username);
if (!Objects.equals("admin", username) ||
!Objects.equals("123456", requestUser.getPassword())) {
String message = "账号密码错误";
System.out.println("test");
return new Result(400);
} else {
return new Result(200);
}
}
}

数据库的引入

我用的是MySQL和MySQL Workbench。在application.yml中配置

1
2
3
4
5
6
7
spring:
#数据库连接配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/white_jotter?serverTimezone=UTC
username: root
password: 19991005

为这个项目创建一个数据库,并创建一个登录信息数据表

1
2
3
4
5
6
7
8
create database white_jotter;
use white_jotter;
create table user(
id int(11) auto_increment primary key,
username varchar(255) default null,
password varchar(255) default null,
)ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
insert into user values(1,'admin','123456');

使用数据库验证登录

在controller层中进行验证,验证的逻辑如下

  1. 获取前端发来的用户名和密码信息
  2. 查询数据库中是否存在同一对用户名和密码
  3. 如果存在返回200,否则返回400,这里的200不是返回信息中的200,如果
    成功返回信息那么response中的状态码一定是200

项目相关的配置

在pom.xml中配置所有的依赖,可以参考我在SpringBoot教程中的配置

登录控制器

应该建立的结构如下,接下来完善登录控制器
首先application.yml中配置mybatis

1
2
3
4
5
6
7
8
#mybatis的相关配置
mybatis:
#mapper配置文件
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.atguigu.mybatis.entity
#开启驼峰命名
configuration:
map-underscore-to-camel-case: true
  • entity 这个包中应该存放对应于数据库中表的实体类

    1
    2
    3
    4
    5
    6
    7
    package com.atguigu.mybatis.entity;
    public class User {
    int id;
    String username;
    String password;
    ...
    }
  • mapper 这个包下的接口用于操纵数据库对象,与数据库直接交互,这种操作
    可以认为具有原子性

    1
    2
    3
    4
    5
    6
    7
    8
    package com.atguigu.mybatis.mapper;
    @Repository
    public interface Usermapper {
    //通过用户名查询
    User findByUsername(String username);
    //通过用户名和密码查询
    User getByUsernameAndPassword(String username, String password);
    }

    通过mapper.xml文件创建表和类映射关系,通过id值区分标签,resultType
    是返回类型,namespace放映射文件的路径,parameterType是动态传入的参
    数类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.atguigu.mybatis.mapper.Usermapper">
    <select id="findByUsername" parameterType="java.lang.String"
    resultMap="userresult">
    select * from user where username = #{username}
    </select>
    <select id="getByUsernameAndPassword" parameterType="HashMap"
    resultMap="userresult">
    select * from user where username=#{username} and password =#{password}
    </select>
    <resultMap id="userresult" type="com.atguigu.mybatis.entity.User">
    <!--对应分为主键和非主键,在数据库中将id变为主键 -->
    <!--主键用id,非主键用result -->
    <id property="id" column="id" />
    <result property="username" column="username" />
    <result property="password" column="password" />
    </resultMap>
    </mapper>
  • service 负责业务逻辑,与功能相关的代码。一般来说在mapper中定义最
    基本的增删查改等操作,具体的操作由service完成,可以认为由多个原子
    组合

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package com.atguigu.mybatis.service;
    @Service
    public class Userservice {
    @Autowired
    Usermapper usermapper;
    public boolean isExist(String username) {
    User user = getByName(username);
    return null!=user;
    }
    public User getByName(String username) {
    return usermapper.findByUsername(username);
    }
    public User get(String username, String password){
    return usermapper.getByUsernameAndPassword(username, password);
    }
    }
  • controller 登录控制器的核心部分,使用Userservice提供的方法查询
    数据库

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @RestController
    public class Logincontroller {
    @Autowired
    Userservice userservice;
    //处理post请求
    @PostMapping(value="api/hellologin")
    public Result login(@RequestBody User requestUser)
    {
    String username = requestUser.getUsername();
    //对html标签进行转义,防止XSS攻击
    username = HtmlUtils.htmlEscape(username);
    User user=userservice.get(username,requestUser.getPassword());
    if(user==null)
    return new Result(400);
    else
    return new Result(200);
    }
    }

    分别启动前端和后端项目,输入正确的用户名和密码,得到正确结果,这一
    部分主要是在跨域的时候出现问题,参考我的跨域解决方式

使用ElementUI辅助前端开发

这个工具是后端开发人员的福音!可以帮助我们开发前端界面

引入ElementUI

参照我的ElementUi博客

优化登录页面

可以在Element中查找需要的样式,比如我们要做一个登录表格就在Form中寻找
具体的代码我放在ElementUi中,名字是Form表单。此时已经完成了登录页面的
开发,但是存在风险,用户可以绕过登录页面直接访问别的页面,接下来还要开
发一个拦截器

前端路由和登录拦截器

使用History模式

将Vue中配置的路由从默认的hash模式切换到history模式,在router.index.js
中修改

1
2
3
4
const router = new VueRouter({
routes,
mode: 'history'
})

后端登录拦截器只有将前后端项目整合在一起才会生效,暂时先不使用这种方式

Vuex与前端登录拦截器

实现前端登录拦截器需要在前端判断用户的状态,可以像之前一样在组件的
data属性中设置一个状态标志,但是登录状态应该是一个全局属性,不应该
写入某一个组件中。这里可以引入Vuex,专门为Vue开发的状态管理方案
在store/index.js中设置我们需要的状态变量和方法,我们需要一个记录
用户信息的变量,这个变量是一个对象,同时设置一个方法触发这个方法
的时候会为这个用户变量赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
user: {
username: window.localStorage.getItem('user' || '[]') == null ? '' :
JSON.parse(window.localStorage.getItem('user' || '[]')).username
}
},
mutations: {
login (state, user) {
state.user = user
window.localStorage.setItem('user', JSON.stringify(user))
}
}
})

localStorage是本地存储,在项目打开的时候会判断本地存储中是否有user这
个对象,如果存在就取出并获得username值,否则就把username设置为空,这
样只要不清除缓存登录的状态就会一直保存

修改路由配置

为了区分页面是否需要拦截,在需要拦截的路由中加一条元数据

1
2
3
4
5
6
7
path: '/index',
name: 'AppIndex',
component: AppIndex,
meta: {
//需要进行登录验证
requireAuth: true
}

使用钩子函数判断是否需要拦截

钩子函数就是在某些时机会被调用的函数,在src/main.js中添加对store的引
用,首先判断访问的路径是否需要登录,如果需要判断store里有没有存储user
的信息,如果存在则放行否则跳转到登录页面,并存储访问的登录路径(以便在
登录后跳转到访问页)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import store from './store'
//这个函数在访问每一个路由前调用
router.beforeEach((to, from, next) => {
//这个路由是否需要登录验证
if (to.meta.requireAuth) {
if (store.state.user.username) {
next()
} else {
next({
//还未登录所以先跳转到登录页面
path: 'login',
//保存该路由,如果登录成功就会跳转到这个路由
query: {redirect: to.fullPath}
})
}
} else {
next()
}
}
)
new Vue({
router,
//引用store
store,
render: h => h(App)
}).$mount('#app')

修改Login.vue

之前的登录组件只需要判断后端返回的状态码然后跳转到首页,修改后的逻辑

  1. 点击登录时向后端传送数据
  2. 接收到后端返回的成功代码时触发store的login方法,把loginForm对象
    传递给store中的user对象
  3. 获取登录前页面的路径并跳转,如果没有则跳转到首页
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
login () {
//在then方法中的this不等于下面的this,所以要先保存this
var _this = this
console.log(this.$store.state)
this.$axios
.post('/login', {
username: this.loginForm.username,
password: this.loginForm.password
})
.then(successResponse => {
if (successResponse.data.code === 200) {
// var data = this.loginForm
_this.$store.commit('login', _this.loginForm)
//登录前页面的路径
var path = this.$route.query.redirect
this.$router.replace({path: path === '/' || path === undefined ?
'/index' : path})
}
})
.catch(failResponse => {
})
}

当我直接访问index页面的时候会跳转到登录页面

导航栏与图书页面设计

导航栏的实现

这个项目是一个单页面应用,当表面有多个页面功能,比如首页、图书馆
和笔记本等,为了实现在这些页面之间能够相互切换,可以使用导航栏

  1. 能够在每个页面显示
  2. 比较美观,即使是专门的后端程序员也要注意前端页面写好看点

路由配置

实现第一个要求,即需要把导航栏放在其他页面的父页面,App.vue是所
有组件的父组件,其他组件的内容都会在这里显示,有一个问题,在登录
的时候不应该显示导航栏,在components目录下新建一个组件,命名为
Home.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div>
<!--Home组件的子组件可以在这里显示-->
<router-view/>
</div>
</template>
<script>
export default {
name: 'Home'
}
</script>
<style scoped>
</style>

接下来建立路由的父子关系,注意在一个组件中通过导入引入了其它组件,
可以称为父子组件。但是要通过router-view控制子组件的显示还要进行
路由的相关配置,在router/index.js中修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
path: '/home',
name: 'Home',
component: Home,
//不需要访问Home,事实上Index的内容就嵌套在Home组件中
redirect: '/index',
children: [
{
//如果子路由加/就说明路径前无/home,而是直接使用/index
path: '/index',
name: 'AppIndex',
component: AppIndex,
meta: {
requireAuth: true
}
}
]
}

使用NavMenu组件

在Element文档中找到NavMenu,在components中新建一个文件夹common,
里面放公共的组件。新建NavMenu组件,具体代码放在ElementUi。接下来
修改Home.vue的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div>
<!--这样除了登录页面其余页面都会显示Home父组件这个导航栏 -->
<nav-menu></nav-menu>
<router-view/>
</div>
</template>
<script>
import NavMenu from './common/NavMenu.vue'
export default {
name: 'Home',
components: {NavMenu}
}
</script>
<style scoped>

</style>

当路由是/index的时候,由于/index是/home的子路由,所以/index的内容
实际是包含在/home的内部

图书页面管理

这是一个核心页面,先设计功能然后具体实现,以下这些实现组件配置的路由
都会是Home的子路由,先把之后完成的结果展示出来

  1. 图书展示区域 也就是右边的部分
  2. 分类导航栏 左侧导航栏
  3. 搜索栏 在图书内容的上方
  4. 页码 在最下方

LibraryIndex

在components中新建文件夹Library,新建LibraryIndex.vue作为图书
页面的根组件,代码放在ElementUi中的LibraryIndex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
path: '/home',
name: 'Home',
component: Home,
redirect: '/index',
children: [
{
path: '/index',
name: 'AppIndex',
component: AppIndex,
meta: {
requireAuth: true
}
},
{
path: '/library',
name: 'Library',
component: LibraryIndex,
meta: {
requireAuth: true
}
}
]
}

SideMenu

编写一个侧边栏组件,放在library文件夹中,然后在LibraryIndex.vue中
引入这个组件,代码放在ElementUi中的SideMenu中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<el-container>
<el-aside style="width: 200px;margin-top: 20px">
<switch></switch>
<SideMenu></SideMenu>
</el-aside>
<el-main>
<!--<books></books>-->
</el-main>
</el-container>
</template>

<script>
import SideMenu from './SideMenu.vue'
export default {
name: 'AppLibrary',
components: {SideMenu}
}
</script>

Books

最后用一个组件来显示图书,这部分是主要显示内容,代码放在ElementUi中的
Books,最后将Books组件放在LibraryIndex.vue中,这是主要区域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<el-container>
<el-aside style="width: 200px;margin-top: 20px">
<switch></switch>
<SideMenu></SideMenu>
</el-aside>
<el-main>
<books class="books-area"></books>
</el-main>
</el-container>
</template>
<script>
import SideMenu from './SideMenu.vue'
import Books from './Books.vue'
export default {
name: 'AppLibrary',
components: {SideMenu,Books}
}
</script>
<style scoped>
.books-area {
width: 990px;
margin-left: auto;
margin-right: auto;
}
</style>

数据库设计与增删查改

增删查改也有技术含量,不要小看它

数据库设计

在设计图书界面之后也需要设计一个相应的数据表,具体的需求如下

  • 展示书籍的信息,包括封面、标题、作者、出版日期、出版社、摘要和分类
  • 维护分类信息

这里需要构建三张表user book category。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#user表已经建立
#book中的cid与category中的id对应,代表图书类型
create table category
(
id int(11) primary key,
name varchar(255) not null
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
create table book
(
#每本书都有一个唯一的id
id int(11) auto_increment,
cover varchar(255) default '',
title varchar(255) default '',
author varchar(255) default '',
data varchar(20) default '',
press varchar(255) default '',
abs varchar(255) default '',
cid int(11),
primary key(id),
#建立索引
KEY fk_book_category_on_cid (cid),
constraint fk_book_category_on_cid foreign key(cid) references
category(id) ON DELETE SET NULL ON UPDATE CASCADE
)ENGINE=InnoDB AUTO_INCREMENT=102 DEFAULT CHARSET=utf8;

在Evan的github仓库中有相应的数据库信息,可以从那上面导入数据
https://github.com/Antabot/White-Jotter/blob/master/src/main/resources/data.sql

增删查改

需求如下

  • 查 查询书籍信息
  • 增 上传书籍信息
  • 改 修改书籍信息
  • 删 删除书籍信息

新建两个映射数据库的类 Book Category

1
2
3
4
5
6
7
8
9
10
11
public class Book {
int id;
String cover;
String title;
String author;
String date;
String press;
String abs;
int cid;
..
}

DAO层

在mapper文件夹下新建 Bookmapper和Categorymapper

1
2
3
4
5
6
7
8
9
@Repository
public interface Bookmapper {
List<Book> findall();
void add(Book book);
void update(Book book);
void deletebyid(int id);
List<Book> findAllByCid(int cid);
List<Book> findAllByTitleLikeOrAuthorLike(String title, String author);
}
1
2
3
4
5
@Repository
public interface Categorymapper {
Category findbyid(int id);
List<Category> findall();
}

因为我用的是mybatis,所以创建Bookmapper.xml文件和Categorymapper.xml文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.atguigu.mybatis.mapper.Bookmapper">
<select id="findAllByCid" parameterType="int"
resultType="com.atguigu.mybatis.entity.Book">
select * from book where cid=#{id}
</select>
<select id="findAllByTitleLikeOrAuthorLike" parameterType="HashMap"
resultMap="com.atguigu.mybatis.entity.Book">
select * from book where title like '%${title}%' or author
like '%${title}%'
</select>
<select id="findall" resultType="com.atguigu.mybatis.entity.Book">
select * from book
</select>
<select id="add" parameterType="com.atguigu.mybatis.entity.Book">
insert into book values
(null,#{cover},#{title},#{author},#{date},#{press},#{abs},#{cid})
</select>
<select id="update" parameterType="com.atguigu.mybatis.entity.Book">
update book set cover=#{cover},title=#{title},author=#{author},
date=#{date},press=#{press},abs=#{abs},cid=#{cid} where id=#{id}
</select>
<select id="deletebyid" parameterType="int">
delete from book where id=#{id};
</select>
<resultMap id="bookresult" type="com.atguigu.mybatis.entity.Book">
<id column="id" property="id" />
<result column="cover" property="cover" />
<result column="title" property="title" />
<result column="author" property="author" />
<result column="date" property="date" />
<result column="press" property="press" />
<result column="abs" property="abs" />
<result column="cid" property="cid" />
<association property="id" column="id"
javaType="com.atguigu.mybatis.entity.Category"
select="com.atguigu.mybatis.mapper.Bookmapper.
findAllByCid" />
</resultMap>
</mapper>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.atguigu.mybatis.mapper.Categorymapper">
<select id="findbyid" parameterType="int"
resultMap="category">
select * from category where id=#{id}
</select>
<select id="findall" resultType="com.atguigu.mybatis.entity.Category">
select * from category
</select>
<resultMap id="category" type="com.atguigu.mybatis.entity.Category">
<id column="id" property="id" />
<result column="name" property="name" />
</resultMap>
</mapper>

Service层

创建Categoryservice和Bookservice类

1
2
3
4
5
6
7
8
9
@Service
public class Categoryservice {
@Autowired
Categorymapper categorymapper;
public List<Category> list()
{
return categorymapper.findall();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Service
public class Bookservice {
@Autowired
Bookmapper bookmapper;
@Autowired
Categoryservice categoryservice;
public List<Book> findall()
{
return bookmapper.findall();
}
public void add(Book book)
{
bookmapper.add(book);
}
public void update(Book book)
{
bookmapper.update(book);
}
public void deletebyid(int id)
{
bookmapper.deletebyid(id);
}
public List<Book> listbycategory(int cid)
{
return bookmapper.findAllByCid(cid);
}
public List<Book> search(String keywords)
{
return bookmapper.findAllByTitleLikeOrAuthorLike('%'+keywords+'%',
'%'+keywords+'%');
}
}

Controller

创建Librarycontroller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@RestController
public class Librarycontroller {
@Autowired
Bookservice bookservice;
@GetMapping("api/books")
public List<Book> list()
{
return bookservice.findall();
}
@PostMapping("api/books")
public Book addorupdate(@RequestBody Book book)
{
//这里应该通过id来查找,因为只有id是独一无二的
//List<Book> list=bookservice.search(book.getId());
//注意如果是增加图书那么一开始是没有id的,所以还是应该按照名字
List<Book> list=bookservice.search(book.getTitle());
if(list==null)
bookservice.add(book);
else
bookservice.update(book);
return book;
}
@PostMapping("api/delete")
public void delete(@RequestBody Book book)
{
bookservice.deletebyid(book.getId());
}
@GetMapping("api/categories/{cid}/books")
public List<Book> listbycategory(@PathVariable("cid") int cid)
{
if(cid!=0)
return bookservice.listbycategory(cid);
else
return list();
}
@GetMapping("api/search")
public List<Book> searchResult(@RequestParam("keywords") String keywords)
{
if("".equals(keywords))
return list();
else
return bookservice.search(keywords);
}
}

核心功能的前端实现

之前已经在后端完成了增删查改的功能实现,现在完善前端相应的实现

EditForm

这个组件是增加或者修改图书的弹出表单,同样放在Library文件夹下
代码放在ElementUi中的EditForm

这个组件是用于搜索的搜索框,代码放在ElementUi中的SearchBar

Books

继续修改Books.vue

  • 添加搜索框
  • 添加增加删除按钮
  • 完善分页功能
  • 构造增删查改对应的请求

LibraryIndex

这里的修改主要是实现分类查询

SideMenu

侧边分类导航栏的修改主要是实现了点击分类引发查询事件

要点讲解

之前的内容非常繁多,说实话我一时半会无法消化,但是问题不解决就一直
是问题,问题解决了就不是问题,现在我们一起解决一下

查询功能的实现

项目中应用查询的地方有三处

  • 打开页面,默认查询出所有图书并显示
  • 点击左侧分类栏,按照分类显示图书
  • 在搜索栏输入作者或书名,可以模糊查询出所有书籍

页面的初始化

第一个功能是打开页面显示所有图书,即在打开页面时就自动触发相应代码
发送请求并渲染页面,可以使用钩子函数mounted,即已挂载,挂载就是在
我们写的Vue代码被转换为HTML并替换相应的DOM这个过程,这个过程结束
时就会指定mounted里的代码,以下内容在Books.vue中

1
2
3
4
5
6
7
8
9
10
11
12
13
mounted: function () {
//loadBooks写在methods方法里,页面渲染时就显示所有图书
this.loadBooks()
}
//这个就是获取所有图书信息
loadBooks () {
var _this = this
this.$axios.get('/books').then(resp => {
if (resp && resp.status === 200) {
_this.books = resp.data
}
})
}

分类显示

分类这个功能的前端实现逻辑是,点击左侧导航栏,向后端发送一个带有参数的
get请求,然后同样是修改data里的数据以实现动态渲染
这个方法在LibraryIndex.vue中

1
2
3
4
5
6
7
8
9
10
listByCategory () {
var _this = this
var cid = this.$refs.sideMenu.cid
var url = 'categories/' + cid + '/books'
this.$axios.get(url).then(resp => {
if (resp && resp.status === 200) {
_this.$refs.booksArea.books = resp.data
}
})
}

SideMenu中有一个方法,接下来就是组件之间的通信,在LibraryIndex组件中
的方法需要获取SideMenu组件中的数据。知道SideMenu组件是LibraryIndex
组件的子组件

1
2
3
4
5
handleSelect (key, keyPath) {
//这里的key应该就是index的值
this.cid = key
this.$emit('indexSelect')
}

我们用ref属性设置一个引用名,这样就可以通过_this.$ref.sideMenu来引用
侧面导航栏的实例并获取data,而且 @indexSelect=”listByCategory” 为
listByCategory方法设置了触发事件。在SideMenu中有一个$emit方法,emit
就是触发的意思,在子组件中使用$emit,即可触发在父组件中定义的事件,而
这个handleSelect方法则是由@select事件触发

1
<SideMenu @indexSelect="listByCategory" ref="sideMenu"></SideMenu>

总结一下,当你点击侧边导航栏的一个标签后内部的操作如下

  1. 触发el-menu组件的@select事件,执行handselect方法
  2. handselect方法触发indexSelect事件,并把key也就是le-menu-item标签
    的index属性的值赋给data中定义的属性,即分类码
  3. 父组件收到指令,执行时间对应的方法,即listByCategory
  4. 发送请求,后端执行查询代码,返回数据,再通过refs修改Books组件的data
    以动态渲染页面

最后需要注意url的构造方式。后端通过@PathVariable接收

1
var url = 'categories/' + cid + '/books'

搜索栏查询

在后端实现关键字查询的接口

1
2
3
4
5
//根据标题或者作者进行模糊查询
public List<Book> search(String keywords)
{
return bookmapper.findAllByTitleLikeOrAuthorLike(keywords,keywords);
}

控制层对应的方法如下,通过@RequestParam来接收参数

1
2
3
4
5
6
7
8
@GetMapping("api/search")
public List<Book> searchResult(@RequestParam("keywords") String keywords)
{
if("".equals(keywords))
return list();
else
return bookservice.search(keywords);
}

前端对应的代码放在ElementUI中的SearchBar。点击搜索会触发父组件Books
中的方法,具体内容看ElementUI中修改后的Books

增加、修改、删除

增删改操作需要向后端发送Post请求对数据库进行操作,发送完请求后有
两个选择,一是在接收后端返回的成功代码后直接利用前端的数据刷新显
示,二是直接进行查询以显示修改后的数据。前一种做法如果代码不够严
谨可能出现未按期望修改数据库却返回成功代码的情况,会造成数据的
不一致。后一种做法有两种方式,一是直接刷新页面,二是查询对应的
Ajax请求,利用双向绑定更新显示,但是会有明显的卡顿

增加和修改

增加和修改使用的是同一个表单,不同的是修改需要先查询原来的信息,然后
对数据库进行更新操作,而增加是直接执行插入操作

1
<i class="el-icon-circle-plus-outline"  @click="dialogFormVisible = true"></i>
  • el-dialog 通过该组件的 :visble.sync 属性控制它的显示
  • 在EditForm组件中实现了一个clear方法,目的是在关闭输入框时清空原来
    的数据
  • onSumbit 提交数据,并触发父组件中定义的 onSubmit 事件,而这个事件
    对应的方法则是 loadBooks(),即查询出所有的书籍

注意Evan在前端页面使用了category这个值,我用cid直接替换

图片上传与项目的打包部署

之前的内容问题都不多,接下来的学习教程问题越来越多。。。

图片上传

之前的封面保存在网上的图床中,如果网址改变那么就无法访问到图片,就比如
Melody博客的封面就是网上的图片。上传文件的逻辑是:前端向后端发送Post
请求,后端对接收到的数据进行处理(压缩、格式转换、重命名等),并保存
到服务器中指定的位置,再把该位置对应的URL返回给前端即可

前端部分

利用Element提供的组件el-upload可以轻松搞定前端,新建一个ImgUpload组件
代码放在ElementUi中的ImgUpload。EditForm对应的有两处需要修改,一是在
表单中封面的位置添加该组件

1
2
3
<el-form-item label="出版社" :label-width="formLabelWidth" prop="press">
<el-input v-model="form.press" autocomplete="off"></el-input>
</el-form-item>

上述代码改为

1
2
3
4
5
<el-form-item label="封面" :label-width="formLabelWidth" prop="cover">
<el-input v-model="form.cover" autocomplete="off" placeholder="图片 URL">
</el-input>
<img-upload @onUpload="uploadImg" ref="imgUpload"></img-upload>
</el-form-item>

第二处是在method中添加对应的方法

1
2
3
uploadImg () {
this.form.cover = this.$refs.imgUpload.url
}

后端部分

后端主要解决如下两个问题

  • 如何接收前端传来的图片数据并保存
  • 如何避免重名(图片资源的名字可能重复,如不修改可能出现问题)

现在先在后端建立utils包,创建一个工具类StringUtils并编写生成对应随机
字符串的方法

1
2
3
4
5
6
7
8
9
10
11
12
public class StringUtils {
public static String getRandomString(int length) {
String base = "abcdefghijklmnopqrstuvwxyz0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < length; i++) {
int number = random.nextInt(base.length());
sb.append(base.charAt(number));
}
return sb.toString();
}
}

在Librarycontroller中添加PostMapping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@PostMapping("api/covers")
//接收前端传来的一个文件
public String coversUpload(MultipartFile file) throws Exception {
String folder = "D:/workspace/img";
File imageFolder = new File(folder);
File f = new File(imageFolder, StringUtils.getRandomString(6) +
file.getOriginalFilename()
.substring(file.getOriginalFilename().length() - 4));
if (!f.getParentFile().exists())
f.getParentFile().mkdirs();
try {
file.transferTo(f);
//向前端发送图片的地址
String imgURL = "http://localhost:8081/api/file/" + f.getName();
return imgURL;
} catch (IOException e) {
e.printStackTrace();
return "";
}
}

这里涉及到对文件的操作,对接收到的文件重命名,但保留原始的格式。可以进一步
做一下压缩,或者校验前端传来的数据是否为指定格式,这个URL的前缀是我们自己
构建的,还需要把它跟我们设置的图片资源文件夹,即D:/workspace/img对应起来
在CrosConfig中添加如下代码

1
2
3
4
5
6
@Override
//静态资源拦截
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/api/file/**").addResourceLocations(
"file:" + "d:/workspace/img/");
}

部署项目到服务器

前后端分离模式开发在部署的时候有两种选择

  1. 把前端项目部署在 web 服务器中,把后端项目部署在应用服务器中
  2. 把前端项目打包作为后端项目的静态文件,再把后端项目部署在应用服务器中

Web服务器的好处如下

  • 可以实现反向代理,提高网站的安全性,也即是外网也无法访问内部
  • 方便维护,一些小的修改不必同时协调前后端开发人员
  • 对静态资源的加载速度更快

用户角色权限管理模式设计

之前是第一个部分,接下来是第二个部分,前一个部分我个人觉得对Vue知识的要
求比较高,这个部分主要就是后端的知识了

模块设计

用户角色权限管理是各类后台管理系统的重要组成部分

  1. 用户管理
  • 用户的信息 显示用户的基本信息(昵称、联系方式、角色、部门等)
  • 组织架构 显示、配置(增删改)组织架构、一般为树结构
  • 用户操作 为用户分配角色(多对多)、组织架构(多对多),删除用户
  • 用户黑白名单 对特殊用户进行特别控制
  1. 角色管理
  • 角色信息显示角色的基本信息(名称、权限)
  • 角色操作 根据需要增删角色、为角色分配权限(多对多,按不同粒度分配,
    并实现权限的互斥性检验)
  1. 权限管理
    权限一般有如下三种粒度
  • 菜单权限
  • 操作/功能权限 进行某一操作或使用某一功能的权限(如删除用户的权限)
  • 数据权限 访问某种数据的权限,或对可操作数据量的控制
  1. UI设计
    使用纵向导航布局,这样比较贴切后台管理的设计思想

技术分析

从开发的角度考虑,该模块的技术要点如下

  • 用户、角色、权限、组织架构表结构设计
  • 用户身份验证、授权、会话管理、用户信息的加密存储
  • 不同粒度权限的具体实现

接下来会使用shiro这个安全框架简化后端开发

访问控制及其实现思路

之前讲解了功能的实现逻辑,接下来讲解RBAC这种模式,下一篇博客的开头
就是RBAC的具体讲解

基础知识

  1. RBAC
    用户-角色-权限管理是访问控制的一种实现方式,也叫RBAC,基于角色的
    权限访问控制。RABC支持三个原则
  • 最小权限原则
  • 责任分离原则
  • 数据抽象原则

重点不是原则,而是为什么要在用户和权限管理之间加一个角色,而不是直接
将权限赋给用户。如果要修改用户权限的话,只有几个用户的时候不难操作,
但是如果有上千级别的用户数量需要同时获取或去除同一个权限,便会变得
非常麻烦,加入角色其实就是解耦合的思想,牵一发而动全身

  1. Shiro
    Java Web可以选用两个安全框架 Spring Security 和 Shiro。前者比较复
    杂一点,所以这个项目先使用Shiro吧。Shiro可以实现身份验证、授权、加密
    和会话管理等功能,关于Shiro可以参考我相关的博客

实现思路

  1. 菜单权限
    对菜单的控制权限是最常见的,在后台管理中,这个菜单通常表现为一个单
    独的页面,拥有自己的URL或路由,以这个项目为例,如果想要控制用户对
    不同页面的访问思路如下
  • 使用全局前置守卫,在导航触发时向后端发送一个包含用户信息的请求
  • 后端查询数据库中该用户可以访问的菜单(也就是Vue的路由信息)并返回
  • 前端把接收到的数据添加到路由里,并根据新的路由表动态地渲染出导航
    栏,使不同用户登录后看到不同的菜单。同时路由表也是按需加载的所以用
    户无法通过URL访问没有权限的页面

系统管理员登录时加载所有的导航
普通用户登录时不加载用户管理模块
这是前后端分离的思路,传统项目实现更为简单

  • 利用过滤器,如果用户有权限则返回需要访问的页面,没有则返回为授权
    的页面
  • 菜单的动态渲染利用模板的常规功能即可实现

功能权限

所谓功能,反映在前端就是一个组件,比如按钮或者图表,在后端就是一个接
口,其实现逻辑与对菜单的控制类似,只是触发的时机不同

  1. 第一种思路是按权限加载组件,即在渲染页面前向后端发送请求,获取有
    权限使用的组件并动态渲染,并在需要调用后端接口时进行判断,防止用户
    通过自行构造请求的方式绕过限制
  2. 第二种思路就是先将前端组件全部加载出来,当需要调用后端接口时进行
    判断,如果无权限则弹出相应提示,这种适合对按钮的控制,图表直接不加
    载数据就显得不友好

拥有权限的用户可正常使用添加角色功能
无权限的用户点击按钮会弹出提示

数据权限

数据权限也有两个层次,一是对可访问性的控制,二是对数据量的控制,可
访问性可以针对表、字段或满足某些条件的数据。针对表、字段的控制,主要
依靠在业务逻辑执行前进行判断,比如在调用对收支信息表的查询前判断当
前用户是否具有财务权限,而访问特定数据,可以直接通过 SQL 语句来实
现,比如当前用户只能查询出自身拥有的书籍,就可以通过类似SELECT *
FROM book WHERE uid = #{uid} 的语句来实现。
对数据量的控制多样,比如普通用户一天只能访问2000条数据,可以通过引
用计数器来实现,此外,还有需要对一次的访问量进行控制、对某段时间能
够处理的数据量进行控制等应用场景等等

Author: 高明
Link: https://skysea-gaoming.github.io/2020/04/06/VBlog/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.