github -- https://github.com/lenve/VBlog
一、前期工作(此处只是展示如何开启前后端工程):
1. IDEA创建Springboot工程
工程名:v-springboot
2. VSCode创建Vue-cli3工程
1) node.js安装
2) 安装vue-cli3脚手架
3) 安装vue-cli服务
4) 创建v-vue工程
二、登录模块
1. 前端工作流程
启动前端和后端,前端初始化地址:
http://localhost:8080/
首先看前端的路由地址,位于router(对用的插件--vue-router)下的index.js:
{
path: '/',
name: '登录',
hidden: true,
component: Login
}
前端是如何先到登录的这个路由的呢?如下图:
1) 前端命令
npm run dev
对应的脚本命令
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
因此对应的基本配置为:
build/webpack.dev.conf.js
看此具体配置
'use strict'
const utils = require('./utils')
const webpack = require('webpack')
const config = require('../config')
const merge = require('webpack-merge') // 合并配置的
// dev和pro通用的配置
// 1. entry: 入口,此处指定了这个程序的启动入口:./src/main.js
// 2. output: 出口,根路径下的index.js
// 3. resolve: 配置 Webpack 如何寻找模块所对应的文件,文件拓展后缀
// 4. module: 模块系统,rules:一些资源匹配的必要规则
const baseWebpackConfig = require('./webpack.base.conf')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const portfinder = require('portfinder')
// 根据 process.env.HOST 的值判断当前是什么环境
// 命令:npm run build -- test ,process.env.HOST就设置为:'test'
const HOST = process.env.HOST
// 端口号
const PORT = process.env.PORT && Number(process.env.PORT)
// 开发环境的基本配置
const devWebpackConfig = merge(baseWebpackConfig, {
module: {
// 新增的css匹配规则
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true })
},
// cheap-module-eval-source-map is faster for development
// 跨域的一些配置
devtool: config.dev.devtool,
// these devServer options should be customized in /config/index.js
devServer: {
clientLogLevel: 'warning',
historyApiFallback: true,
hot: true,
compress: true,
host: HOST || config.dev.host,
port: PORT || config.dev.port,
open: config.dev.autoOpenBrowser,
overlay: config.dev.errorOverlay
? { warnings: false, errors: true }
: false,
publicPath: config.dev.assetsPublicPath,
proxy: config.dev.proxyTable,
quiet: true, // necessary for FriendlyErrorsPlugin
watchOptions: {
poll: config.dev.poll,
}
},
plugins: [
// 设置环境变量插件,主要是dev和pro的切换
new webpack.DefinePlugin({
'process.env': require('../config/dev.env')
}),
// 热更新插件
new webpack.HotModuleReplacementPlugin(),
// HMR shows correct file names in console on update.
new webpack.NamedModulesPlugin(),
// 在编译出现错误时,使用 NoEmitOnErrorsPlugin 来跳过输出阶段。这样可以确保输出资源不会包含错误
new webpack.NoEmitOnErrorsPlugin(),
// https://github.com/ampedandwired/html-webpack-plugin
// 可以生成创建html入口文件
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
inject: true
}),
]
})
// Promise解决回调地狱问题
module.exports = new Promise((resolve, reject) => {
portfinder.basePort = process.env.PORT || config.dev.port
portfinder.getPort((err, port) => {
if (err) {
reject(err)
} else {
// publish the new Port, necessary for e2e tests
process.env.PORT = port
// add port to devServer config
devWebpackConfig.devServer.port = port
// Add FriendlyErrorsPlugin
// 识别某些类别的webpack错误,并清理,聚合和优先级
devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({
compilationSuccessInfo: {
messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`],
},
onErrors: config.dev.notifyOnErrors
? utils.createNotifierCallback()
: undefined
}))
resolve(devWebpackConfig)
}
})
})
2) 前端登录模块发出请求,后端才会根据请求地址传回数据
Login.vue:使用element-ui模板进行登录页面的开发
axios:Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中
在./utils/api.js定义Restful请求处理规则:get、post、delete、put
methods: {
submitClick: function () {
var _this = this;
this.loading = true;
postRequest('/login', {
username: this.loginForm.username,
password: this.loginForm.password
}).then(resp=> {
_this.loading = false;
if (resp.status == 200) {
//成功
var json = resp.data;
if (json.status == 'success') {
_this.$router.replace({path: '/home'});
} else {
_this.$alert('登录失败!', '失败!');
}
} else {
//失败
_this.$alert('登录失败!', '失败!');
}
}, resp=> {
_this.loading = false;
_this.$alert('找不到服务器⊙﹏⊙∥!', '失败!');
});
}
}
2. 后端接受请求进行处理并返回数据
前端输出用户名与密码后,请求地址:localhost:8081/login
先认识一下springboot中的websecurityconfig中的formLogin配置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() //禁用跨站csrf攻击防御,后面的章节会专门讲解
.formLogin()
.loginPage("/login.html")//用户未登录时,访问任何资源都转跳到该路径,即登录页面
.loginProcessingUrl("/login")//登录表单form中action的地址,也就是处理认证请求的路径
.usernameParameter("uname")///登录表单form中用户名输入框input的name名,不修改的话默认是username
.passwordParameter("pword")//form中密码输入框input的name名,不修改的话默认是password
.defaultSuccessUrl("/index")//登录认证成功后默认转跳的路径
.and()
.authorizeRequests()
.antMatchers("/login.html","/login").permitAll()//不需要通过登录验证就可以被访问的资源路径
.antMatchers("/biz1").hasAnyAuthority("biz1") //前面是资源的访问路径、后面是资源的名称或者叫资源ID
.antMatchers("/biz2").hasAnyAuthority("biz2")
.antMatchers("/syslog").hasAnyAuthority("syslog")
.antMatchers("/sysuser").hasAnyAuthority("sysuser")
.anyRequest().authenticated();
}
}
项目中的weSecurity配置如下:
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/category/all").authenticated()
.antMatchers("/admin/**","/reg").hasRole("超级管理员")///admin/**的URL都需要有超级管理员角色,如果使用.hasAuthority()方法来配置,需要在参数中加上ROLE_,如下.hasAuthority("ROLE_超级管理员")
.anyRequest().authenticated()//其他的路径都是登录后即可访问
.and().formLogin().loginPage("/login_page")
// .successHandler(new AuthenticationSuccessHandler() {
// @Override
// public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
// httpServletResponse.setContentType("application/json;charset=utf-8");
// PrintWriter out = httpServletResponse.getWriter();
// out.write("{\"status\":\"success\",\"msg\":\"登录成功\"}");
// out.flush();
// out.close();
// }
// })
// 此处这样写更容易理清逻辑
.successForwardUrl("/login_success")
// .failureHandler(new AuthenticationFailureHandler() {
// @Override
// public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
// httpServletResponse.setContentType("application/json;charset=utf-8");
// PrintWriter out = httpServletResponse.getWriter();
// out.write("{\"status\":\"error\",\"msg\":\"登录失败\"}");
// out.flush();
// out.close();
// }
// })
.failureForwardUrl("/login_error")
.loginProcessingUrl("/login")
.usernameParameter("username").passwordParameter("password").permitAll()
.and().logout().permitAll().and().csrf().disable().exceptionHandling().accessDeniedHandler(getAccessDeniedHandler());
}
当来到后端后,首先进行springSecurity校验,匹配的路由地址为/login--为表单的method
而在http.authorizeRequests()授权之前,需要认证
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return DigestUtils.md5DigestAsHex(charSequence.toString().getBytes());
}
/**
* @param charSequence 明文
* @param s 密文
* @return
*/
@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(DigestUtils.md5DigestAsHex(charSequence.toString().getBytes()));
}
});
}
userService implements UserDetailsService 并overwrite loadUserByUsername方法
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userMapper.loadUserByUsername(s);
if (user == null) {
//避免返回null,这里返回一个不含有任何值的User对象,在后期的密码比对过程中一样会验证失败
return new User();
}
//查询用户的角色信息,并返回存入user中
List roles = rolesMapper.getRolesByUid(user.getId());
user.setRoles(roles);
return user;
}
依次执行以下sql语句
// 查询用户是否存在
SELECT * FROM user WHERE username=?
// 查询用户的权限
SELECT r.* FROM roles r,roles_user ru WHERE r.`id`=ru.`rid` AND ru.`uid`=?
因此登录模块后端controller理清
@RequestMapping("/login_error")
public RespBean loginError() {
return new RespBean("error", "登录失败!");
}
@RequestMapping("/login_success")
public RespBean loginSuccess() {
return new RespBean("success", "登录成功!");
}
/**
* 如果自动跳转到这个页面,说明用户未登录,返回相应的提示即可
*
* 如果要支持表单登录,可以在这个方法中判断请求的类型,进而决定返回JSON还是HTML页面
*
* @return
*/
@RequestMapping("/login_page")
public RespBean loginPage() {
return new RespBean("error", "尚未登录,请登录!");
}
三、Home页面分析
主要使用的是Element-ui组件,该页面主要分为二大部分:
1) el-header
mounted: function () {
this.$alert('为了确保所有的小伙伴都能看到完整的数据演示,数据库只开放了查询权限和部分字段的更新权限,其他权限都不具备,完整权限的演示需要大家在自己本地部署后,换一个正常的数据库用户后即可查看,这点请大家悉知!', '友情提示', {
confirmButtonText: '确定',
callback: action => {
}
});
var _this = this;
getRequest("/currentUserName").then(function (msg) {
_this.currentUserName = msg.data;
}, function (msg) {
_this.currentUserName = '游客';
});
},
data(){
return {
currentUserName: ''
}
}
mouted--vue的生命周期的钩子函数,在加载vue的同时就执行其下面的函数,路由/currentUserName请求后端地址
后端中在登录时使用spring security,其中就保存了登录的函数
// SecurityContextHolder中持有的是当前用户的SecurityContext,而SecurityContext持有的是代表当前用户相关信息的Authentication的引用。
SecurityContextHolder.getContext().getAuthentication().getPrincipal()
因此很轻松地获得了当前用户的所有信息,包括用户的权限
@RequestMapping("/currentUserName")
public String currentUserName() {
return Util.getCurrentUser().getNickname();
}
该用户名的下拉菜单无实际意义,除了注销:
methods: {
handleCommand(command){
var _this = this;
if (command == 'logout') {
this.$confirm('注销登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(function () {
getRequest("/logout")
_this.currentUserName = '游客';
_this.$router.replace({path: '/'});
}, function () {
//取消
})
}
}
},
注销路由/logout,后端对应的spring security中logout(),清楚用户信息
2) 第二部分的左侧权限菜单
{{item.name}}
{{child.name}}
{{item.children[0].name}}
3) 第二部分右侧的主体显示部分
首页
四、文章管理模块分析
1) 文章列表
当点击文章列表时,触发路由--/articleList,并且主体部分显示ArticleList.vue模板,
生命周期钩子函数mouted:路由/isAdmin,判断是否为超级管理员来决定是否显示文章列表
mounted: function () {
var _this = this;
getRequest("/isAdmin").then(resp=> {
if (resp.status == 200) {
_this.isAdmin = resp.data;
}
})
},
@RequestMapping("/isAdmin")
public Boolean isAdmin() {
List authorities = Util.getCurrentUser().getAuthorities();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().contains("超级管理员")) {
return true;
}
}
return false;
}
文章列表显示部分向子组件传递参数[state、showEdit、showDelete、showRestore、activeName]五个属性值
这其中又引入两个模板BlogTable和BlogCfg
BlogTable模板:
① 通用搜索组件:
搜索
② 大部分通用表头
全部文章模块没有操作部分
博客配置模块:组件BlogCfg
编辑
还原
删除
③ 通用表尾
v-show="this.articles.length>0 && showDelete" 控制“批量删除”按钮是否存在
:disabled="this.selItems.length==0" 控制“批量删除”按钮是否可用
el-pagination 分页插件
④ 点击文章列表后,最初定位“已发表”通过以下来确定 :activeName绑定name属性
data() {
return {
activeName: 'all',
isAdmin: false
};
},
“已发表”数据获取
// 生命周期函数
mounted: function () {
var _this = this;
this.loading = true;
// 调用加载数据
this.loadBlogs(1, this.pageSize);
// var _this = this;
// window.bus.$on('blogTableReload', function () {
// _this.loading = true;
// _this.loadBlogs(_this.currentPage, _this.pageSize);
// })
},
loadBlogs(page, count){
var _this = this;
var url = '';
// 获取全部文章的数据
if (this.state == -2) {
url = "/admin/article/all" + "?page=" + page + "&count=" + count + "&keywords=" + this.keywords;
} else {
// 获取当前表头的数据
url = "/article/all?state=" + this.state + "&page=" + page + "&count=" + count + "&keywords=" + this.keywords;
}
getRequest(url).then(resp=> {
_this.loading = false;
if (resp.status == 200) {
_this.articles = resp.data.articles;
_this.totalCount = resp.data.totalCount;
} else {
_this.$message({type: 'error', message: '数据加载失败!'});
}
}, resp=> {
_this.loading = false;
if (resp.response.status == 403) {
_this.$message({type: 'error', message: resp.response.data});
} else {
_this.$message({type: 'error', message: '数据加载失败!'});
}
}).catch(resp=> {
//压根没见到服务器
_this.loading = false;
_this.$message({type: 'error', message: '数据加载失败!'});
})
},
当前端初始化(即全部文章)路由:/article/all?page=1&count=6&keywords=''
当前端点击"已发表":前端传递路由:/article/all?state=1&page=1&count=6&keywords=''
当前端点击"草稿箱":前端传递路由:/article/all?state=0&page=1&count=6&keywords=''
当前端点击"回收站":前端传递路由:/article/all?state=2&page=1&count=6&keywords=''
当前端点击"博客管理":前端传递路由:/article/all?state=-2&page=1&count=6&keywords=''
后端都执行以下controller:
@RequestMapping(value = "/all", method = RequestMethod.GET)
public Map getArticleByState(@RequestParam(value = "state", defaultValue = "-1") Integer state, @RequestParam(value = "page", defaultValue = "1") Integer page, @RequestParam(value = "count", defaultValue = "6") Integer count,String keywords) {
int totalCount = articleService.getArticleCountByState(state, Util.getCurrentUser().getId(),keywords);
List articles = articleService.getArticleByState(state, page, count,keywords);
Map map = new HashMap<>();
map.put("totalCount", totalCount);
map.put("articles", articles);
return map;
}
得到的数据articles和totalCount,以下实现数据的填充:具体的了解可以看el-table(这是element-ui的组件)
@selection-change="handleSelectionChange":获取勾选后的index索引值--所在列的索引值
{{ scope.row.title}}
{{ scope.row.editTime | formatDateTime}}
操作栏中各个方法:
@click="handleEdit(scope.$index, scope.row)" push动态路由:/editBlog -----> 组件PostArticle
在加载PostArticle组件时,同时执行了生命周期函数 根据文章id查询出来并显示内容
mounted: function () {
this.getCategories();
var from = this.$route.query.from;
this.from = from;
var _this = this;
if (from != null && from != '' && from != undefined) {
var id = this.$route.query.id;
this.id = id;
this.loading = true;
getRequest("/article/" + id).then(resp=> {
_this.loading = false;
if (resp.status == 200) {
_this.article = resp.data;
var tags = resp.data.tags;
_this.article.dynamicTags = []
for (var i = 0; i < tags.length; i++) {
_this.article.dynamicTags.push(tags[i].tagName)
}
} else {
_this.$message({type: 'error', message: '页面加载失败!'})
}
}, resp=> {
_this.loading = false;
_this.$message({type: 'error', message: '页面加载失败!'})
})
}
},
@RequestMapping(value = "/{aid}", method = RequestMethod.GET)
public Article getArticleById(@PathVariable Long aid) {
return articleService.getArticleById(aid);
}
编辑器插件:mavon-editor
同理:还原与删除操作也这样分析
handleRestore方法:即将改变文章的state
handleDelete方法:也是改变文章的state
@click="deleteMany" 批量删除也是改变多个文章state的操作
注意:需要修改
deleteMany(){
var selItems = this.selItems;
console.log(selItems)
for (var i = 0; i < selItems.length; i++) {
this.dustbinData.push(selItems[i].id)
this.deleteToDustBin(selItems[i].state) // 送入回收站操作应该在此处,这样才能实现批量删除操作
}
},
上一页|当前页|下一页 具体看el-pagination组件
博客配置:邮箱的更新操作---不在详述
2) 发表文章
{
path: '/postArticle',
name: '发表文章',
component: PostArticle,
meta: {
keepAlive: false
}
}
发表文章组件显示,编辑器插件
{{tag}}
+Tag
最重要的是文章增加功能
saveBlog(state){
if (!(isNotNullORBlank(this.article.title, this.article.mdContent, this.article.cid))) {
this.$message({type: 'error', message: '数据不能为空!'});
return;
}
var _this = this;
_this.loading = true;
postRequest("/article/", {
id: _this.article.id,
title: _this.article.title,
mdContent: _this.article.mdContent,
htmlContent: _this.$refs.md.d_render,
cid: _this.article.cid,
state: state,
dynamicTags: _this.article.dynamicTags
}).then(resp=> {
_this.loading = false;
if (resp.status == 200 && resp.data.status == 'success') {
_this.article.id = resp.data.msg;
_this.$message({type: 'success', message: state == 0 ? '保存成功!' : '发布成功!'});
// if (_this.from != undefined) {
window.bus.$emit('blogTableReload')
// }
if (state == 1) {
_this.$router.replace({path: '/articleList'});
}
}
}, resp=> {
_this.loading = false;
_this.$message({type: 'error', message: state == 0 ? '保存草稿失败!' : '博客发布失败!'});
})
},
前端路由:/article/ 传递的参数:
后端controller:
public int addNewArticle(Article article) {
//处理文章摘要
if (article.getSummary() == null || "".equals(article.getSummary())) {
//直接截取
String stripHtml = stripHtml(article.getHtmlContent());
article.setSummary(stripHtml.substring(0, stripHtml.length() > 50 ? 50 : stripHtml.length()));
}
if (article.getId() == -1) {
//添加操作
Timestamp timestamp = new Timestamp(System.currentTimeMillis());
if (article.getState() == 1) {
//设置发表日期
article.setPublishDate(timestamp);
}
article.setEditTime(timestamp);
//设置当前用户
article.setUid(Util.getCurrentUser().getId());
int i = articleMapper.addNewArticle(article);
//打标签
String[] dynamicTags = article.getDynamicTags();
if (dynamicTags != null && dynamicTags.length > 0) {
int tags = addTagsToArticle(dynamicTags, article.getId());
if (tags == -1) {
return tags;
}
}
return i;
} else {
Timestamp timestamp = new Timestamp(System.currentTimeMillis());
if (article.getState() == 1) {
//设置发表日期
article.setPublishDate(timestamp);
}
//更新
article.setEditTime(new Timestamp(System.currentTimeMillis()));
int i = articleMapper.updateArticle(article);
//修改标签
String[] dynamicTags = article.getDynamicTags();
if (dynamicTags != null && dynamicTags.length > 0) {
int tags = addTagsToArticle(dynamicTags, article.getId());
if (tags == -1) {
return tags;
}
}
return i;
}
}
图片的添加删除操作不在详述
五、用户管理模块分析--只有一个子路由
用户卡片形式,很漂亮 只有展示用户,没有添加用户功能 可以自己实现
六、栏目管理--不在赘述
七、数据chart 插件:vue-echarts
@RequestMapping("/dataStatistics")
public Map dataStatistics() {
Map map = new HashMap<>();
List categories = articleService.getCategories();
List dataStatistics = articleService.getDataStatistics();
map.put("categories", categories);
map.put("ds", dataStatistics);
return map;
}
/**
* 获取最近七天的日期
* @return
*/
public List getCategories() {
return articleMapper.getCategories(Util.getCurrentUser().getId());
}
/**
* 获取最近七天的数据
* @return
*/
public List getDataStatistics() {
return articleMapper.getDataStatistics(Util.getCurrentUser().getId());
}
e-charts很值得研究,以后的项目再去仔细研究 百度可视化神器