1、项目环境搭建
1.1、项目介绍
多人博客管理系统:
1、博客内容展示
2、博客管理功能
1.2、项目初始化
1、建立项目所需文件夹:
● public 静态资源
● model 数据库操作
● route 路由
● views 模板
2、初始化项目描述文件
npm init -y
3、下载项目所需的第三方模板
npm install express mongoose art-template express-art-template
4、创建网站服务器
在项目根目录下新建 app.js 文件:
// 引入 express 框架 const express = require('express'); // 创建网站服务器 const app = express(); // 监听端口 app.listen(80); console.log('网站服务器启动成功');
在命令行工具中切换到当前项目目录,执行:
nodemon app.js
5、构建模板化路由
在 route 目录下新建 home.js 文件:
// 引入 express 框架 const express = require('express'); // 创建博客展示页面路由对象 const home = express.Router(); home.get('/', (req, res) => { res.send('欢迎来到博客首页'); }); // 将路由对象做为模块成员进行导出 module.exports = home;
新建 admin.js 文件:
// 引入 express 框架 const express = require('express'); // 创建博客展示页面路由对象 const admin = express.Router(); admin.get('/', (req, res) => { res.send('欢迎来到博客管理页面'); }); // 将路由对象做为模块成员进行导出 module.exports = admin;
回到 app.js 文件中引入路由文件:
// 引入 express 框架 const express = require('express'); // 创建网站服务器 const app = express(); // 引入路由模块 const home = require('./route/home'); const admin = require('./route/admin'); // 将路由和请求路径进行匹配 app.use('/home', home); app.use('/admin', admin); // 监听端口 app.listen(80); console.log('网站服务器启动成功');
打开浏览器,输入:http://localhost/home 和 http://localhost/admin ,可以看到效果。
6、构建博客管理页面模板
1)先把静态资源复制粘贴到 public 目录下:
2)开放静态资源文件
打开 app.js 文件:
// 引入 express 框架 const express = require('express'); // 处理路径的模块 const path = require('path'); // 创建网站服务器 const app = express(); // 开放静态资源文件 app.use(express.static(path.join(__dirname, 'public'))); // 引入路由模块 const home = require('./route/home'); const admin = require('./route/admin'); // 将路由和请求路径进行匹配 app.use('/home', home); app.use('/admin', admin); // 监听端口 app.listen(80); console.log('网站服务器启动成功');
在浏览器中输入:http://localhost/home/default.html ,可以访问到静态页面了。
因为在 html 文件中要拼接后台的数据,所以要把所有的 html 文件剪切到 views 目录下对应的文件夹中,也可以都改为 .art 文件。(如果改为 .art 文件,那么下面的模板配置要写为 art)
1.3、渲染模板
当客户端访问的是 / 或 /login 的时候渲染登录页面
先进行模板配置,打开 app.js文件:
// 告诉 express 框架模板所在的位置 app.set('views', path.join(__dirname, 'views')); // 告诉 express 框架模板的默认后缀是什么 app.set('view engine', 'html'); // 渲染后缀为 html 的模板时,所使用的模板引擎是什么 app.engine('html', require('express-art-template'));
注意:如果上面 .html 文件都改为了 .art 文件,那么这里要写为:
// 告诉 express 框架模板所在的位置 app.set('views', path.join(__dirname, 'views')); // 告诉 express 框架模板的默认后缀是什么 app.set('view engine', 'art'); // 渲染后缀为 art 的模板时,所使用的模板引擎是什么 app.engine('art', require('express-art-template'));
在浏览器中输入:http://localhost/admin/login ,可以看到:登录页就被渲染出来了
1.4、模板内部外链资源的路径问题
查看页面的源代码,可以看到头部样式文件的路径是相对路径,而且这个相对路径是相对浏览器地址中的请求路径。
点击 base.css 可以看到:地址上多了一个 /admin 目录
但是这时候 css 文件可以正常显示,这是因为客户端的请求路径 /admin 和服务器端存储 css 文件的路径目录,正好是相同的;一旦不相同就会出现问题。我们可以测试下,打开 app.js 文件,把 admin 的请求路径改为 abc:
app.use('/abc', admin);
然后在浏览器中也输入:http://localhost/abc/login ,这是可以看到没有样式了:
这时可以查看到样式的路径是:http://localhost/abc/css/base.css 了。
下面我们就需要修改为绝对路径,打开 views - admin - login.art 文件:
<title>用户登录</title> <link rel="stylesheet" href="/admin/lib/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/admin/css/base.css"> <script src="/admin/lib/jquery/dist/jquery.min.js"></script> <script src="/admin/lib/bootstrap/js/bootstrap.min.js"></script>
刷新页面,可以看到:显示正常了
ok,现在可以把 abc 改回 admin 了,顺便把其他的模板文件都修改下。
注意:模板文件中的外链资源要写绝对路径。
1.5、优化模板
将模板中的公共部分,抽离到单独的文件中。
通过观察 admin 目录下的各个 .art 文件,可以把头部和侧边栏都抽离出来。
在 views - admin 目录下,新建 common 文件夹,存放公共部分文件。
新建 header.art 存放头部代码,新建 aside.art 存放侧边栏部分代码。
然后在各主文件中引用 header.art 和 aside.art 文件。
<body> <!-- 子模板的相对路径,相对的是当前文件,因为它是有模板引擎解析的,而不是浏览器 --> {{ include './common/header.art' }} <!-- 主体内容 --> <div class="content"> {{ include './common/aside.art' }} <div class="main">
打开 admin.js 文件,创建 user 路由:
// 创建用户列表路由 admin.get('/user', (req, res) => { res.render('admin/user.art', {}) });
在浏览器中打开:http://localhost/admin/user ,可以看到:
下面把其他页面也都修改下。
1.6、模板 html 的骨架抽离
在 common 目录下,新建 layout.art 文件:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <link rel="stylesheet" href="/admin/lib/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/admin/css/base.css"> {{block 'link'}} {{/block}} </head> <body> {{block 'main'}} {{/block}} <script src="/admin/lib/jquery/dist/jquery.min.js"></script> <script src="/admin/lib/bootstrap/js/bootstrap.min.js"></script> {{block 'script'}} {{/block}} </body> </html>
打开 user.art 文件,引用骨架 layout:
{{extend './common/layout.art'}} {{block 'main'}} <!-- 子模板的相对路径,相对的是当前文件,因为它是有模板引擎解析的,而不是浏览器 --> {{ include './common/header.art' }} <!-- 主体内容 --> <div class="content"> {{ include './common/aside.art' }} <div class="main"> <!-- 分类标题 --> <div class="title"> <h4>用户</h4> <span>找到1个用户</span> <a href="user-edit.html" class="btn btn-primary new">新增用户</a> </div> <!-- /分类标题 --> <!-- 内容列表 --> <table class="table table-striped table-bordered table-hover custom-table"> <thead> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>角色</th> <th>状态</th> <th>操作</th> </tr> </thead> <tbody> <tr> <td>5b9a716cb2d2bf17706bcc0a</td> <td>wangjian</td> <td>wjb19891223@163.com</td> <td>超级管理员</td> <td>正常</td> <td> <a href="user-edit.html" class="glyphicon glyphicon-edit"></a> <i class="glyphicon glyphicon-remove" data-toggle="modal" data-target=".confirm-modal"></i> </td> </tr> <tr> <td>5b9a716cb2d2bf17706bcc0a</td> <td>wangjian</td> <td>wjb19891223@163.com</td> <td>普通用户</td> <td>禁用</td> <td> <a href="user-edit.html" class="glyphicon glyphicon-edit"></a> <i class="glyphicon glyphicon-remove" data-toggle="modal" data-target=".confirm-modal"></i> </td> </tr> <tr> <td>5b9a716cb2d2bf17706bcc0a</td> <td>wangjian</td> <td>wjb19891223@163.com</td> <td>普通用户</td> <td>启用</td> <td> <a href="user-edit.html" class="glyphicon glyphicon-edit"></a> <i class="glyphicon glyphicon-remove" data-toggle="modal" data-target=".confirm-modal"></i> </td> </tr> </tbody> </table> <!-- /内容列表 --> <!-- 分页 --> <ul class="pagination"> <li> <a href="#"> <span>«</span> </a> </li> <li><a href="#">1</a></li> <li><a href="#">2</a></li> <li><a href="#">3</a></li> <li><a href="#">4</a></li> <li><a href="#">5</a></li> <li> <a href="#"> <span>»</span> </a> </li> </ul> <!-- /分页 --> </div> </div> <!-- /主体内容 --> <!-- 删除确认弹出框 --> <div class="modal fade confirm-modal"> <div class="modal-dialog modal-lg"> <form class="modal-content"> <div class="modal-header"> <button type="button" class="close" data-dismiss="modal"><span>×</span></button> <h4 class="modal-title">请确认</h4> </div> <div class="modal-body"> <p>您确定要删除这个用户吗?</p> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">取消</button> <input type="submit" class="btn btn-primary"> </div> </form> </div> </div> <!-- /删除确认弹出框 --> {{/block}}
在浏览器中刷新页面,发现没有问题。把其他几个模板文件也都修改下。
2、项目功能实现
2.1 登录与退出功能
2.1.1、创建用户集合,初始化用户
1)连接数据库
在 model 目录下新建 connect.js 文件:
// 引入 mongoose 第三方模块 const mongoose = require('mongoose'); // 连接数据库 mongoose.connect('mongodb://localhost/blog', { useNewUrlParser: true, useUnifiedTopology: true}) .then(() => console.log('数据库连接成功')) .catch(() => console.log('数据库连接失败'))
在 app.js 文件中引用:
// 数据库连接 require('./model/connect');
在命令行工具中可以看到:
2)创建用户集合
在 model 目录下新建 user.js:
// 引入 mongoose 第三方模块 const mongoose = require('mongoose'); // 创建用户集合规则 const userSchema = new mongoose.Schema({ username: { type: String, required: true, minlength: 2, maxlength: 20 }, email:{ type: String, required: true, // 唯一性,保证邮箱地址在插入数据库时不重复 unique: true }, password: { type: String, required: true }, // admin-超级管理员 normal-普通用户 role: { type: String, required: true }, // 0:启用状态 1:禁用状态 state: { type: Number, default: 0 } }); // 创建用户信息集合 const User = mongoose.model('User', userSchema); // 将用户信息集合作为模块成员进行导出 module.exports = { User }
3)初始化用户
在 user.js 文件中创建测试用户:
// 引入 mongoose 第三方模块 const mongoose = require('mongoose'); // 创建用户集合规则 const userSchema = new mongoose.Schema({ username: { type: String, required: true, minlength: 2, maxlength: 20 }, email:{ type: String, required: true, // 唯一性,保证邮箱地址在插入数据库时不重复 unique: true }, password: { type: String, required: true }, // admin-超级管理员 normal-普通用户 role: { type: String, required: true }, // 0:启用状态 1:禁用状态 state: { type: Number, default: 0 } }); // 创建用户信息集合 const User = mongoose.model('User', userSchema); User.create({ username: 'itjoe', email: 'joe@163.com', password: '123456', role: 'admin', state: 0 }).then(() => { console.log('用户创建成功') }).catch(() => { console.log('用户创建失败') }) // 将用户信息集合作为模块成员进行导出 module.exports = { User }
在 app.js 文件中引用:
// 用户集合 require('./model/user');
这时在命令行工具中可以看到:用户创建成功
打开 Compass 软件,可以看到 blog 数据库中有一个 users 集合:创建用户成功
然后把 app.js 中引入 user.js 的代码删除,并把 user.js 中创建用户部分的代码注释掉。
2.1.2、 为登录表单项设置请求地址、请求方式以及表单项name属性
打开 login.art 文件,添加代码:
<form action="/login" method="post"> <input name="email" type="email" class="form-control" placeholder="请输入邮件地址"> <input name="password" type="password" class="form-control" placeholder="请输入密码">
2.1.3.、当用户点击登录按钮时,客户端验证用户是否填写了登录表单
给表单添加 id:
<form action="/login" method="post" id="loginForm">
在页面下方添加表单的点击事件代码:
<script type="text/javascript"> //为表单添加提交事件 $('#loginForm').on('submit', function () { // 获取到表单中用户输入的内容,返回值是数组 // [{name: 'email', value:: '用户输入的内容'}] var f = $(this).serializeArray() console.log(f) // 阻止表单默认提交的行为 return false; }); </script>
打开浏览器刷新页面,随便输入一些内容,可以看到控制台打印出的数组:刚输入的内容
继续修改代码:
<script type="text/javascript"> function serializeToJson(form) { var result = {}; // serializeArray() 获取到表单中用户输入的内容,返回值是数组 // [{name: 'email', value:: '用户输入的内容'}] var f = form.serializeArray(); // 把数组转换为对象 {enail: 'zhangsan@163.com', password: '123456'} f.forEach(function(item) { result[item.name] = item.value; }); return result; } //为表单添加提交事件 $('#loginForm').on('submit', function () { var result = serializeToJson($(this)) console.log(result) // 阻止表单默认提交的行为 return false; }); </script>
回到浏览器刷新页面,重新输入内容,可以看到控制台打印出的对象:
在一个真实项目中,对表单进行处理是很常见的操作,所以我们可以把这个方法变为公共的方法。
在 public - admin - js 目录下,新建 common.js 文件,把 serializeToJson 方法剪切过来:
function serializeToJson(form) { var result = {}; // serializeArray() 获取到表单中用户输入的内容,返回值是数组 // [{name: 'email', value:: '用户输入的内容'}] var f = form.serializeArray(); // 把数组转换为对象 {enail: 'zhangsan@163.com', password: '123456'} f.forEach(function(item) { result[item.name] = item.value; }); return result; }
再在 login.art 文件中引入 common.js 文件:
<script src="/admin/js/common.js"></script>
刷新浏览器冲洗验证下方法是否可以正常使用。
我们还可以再骨架文件中引入 common.js 文件,打开 layout.art 文件:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <link rel="stylesheet" href="/admin/lib/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/admin/css/base.css"> {{block 'link'}} {{/block}} </head> <body> {{block 'main'}} {{/block}} <script src="/admin/lib/jquery/dist/jquery.min.js"></script> <script src="/admin/lib/bootstrap/js/bootstrap.min.js"></script> <script src="/admin/js/common.js"></script> {{block 'script'}} {{/block}} </body> </html>
2.1.4.、如果其中一项没有输入,阻止表单提交
继续修改 login.art 文件中的 js 代码:
<script type="text/javascript"> //为表单添加提交事件 $('#loginForm').on('submit', function () { var result = serializeToJson($(this)) // 如果用户没有输入邮件地址 if (result.email.trim().length == 0) { alert('请输入邮件地址'); // 阻止程序向下执行 return false; } // 如果用户没有输入密码 if (result.password.trim().length == 0) { alert('请输入密码'); // 阻止程序向下执行 return false; } }); </script>
浏览器刷新页面,不输入内容就提交的话,会弹出提示信息。
2.1.5、 服务器端接收请求参数,验证用户是否填写了登录表单
把表单的请求地址改为:/admin/login
<form action="/admin/login" method="post" id="loginForm">
Express 中接收 post 请求参数需要借助第三方包 body-parser
在命令行工具中下载安装:
npm install body-parser
打开 app.js 文件,引入 body-parser 模块,并进行全局的配置:
// 引入 body-parser 模块,用来处理 post 请求参数 const bodyParser = require('body-parser'); // 配置 body-parser 模块,处理 post 请求参数 app.use(bodyParser.urlencoded({ extended: false }));
extended: false 方法内部使用 querystring 模块处理请求参数的格式
然后重启启动服务器:nodemon app.js
打开 admin.js 文件,添加 /login 的 post 请求:
// 实现登录功能 admin.post('/login', (req, res) => { // 接收请求参数 res.send(req.body); }); // 先把请求参数显示到页面中看下效果
刷新页面,填写信息提交,可以看到:
下面要对请求参数进行二次验证
2.1.6、 如果其中一项没有输入,为客户端做出响应,阻止程序向下执行
继续编辑下 admin.js 的代码:
// 实现登录功能 admin.post('/login', (req, res) => { // 接收请求参数 const {email, password} = req.body; // 如果用户没有输入邮件地址或密码 if (email.trim().length == 0 || password.trim().length == 0) { return res.status(400).send('<h4>邮件地址或密码错误</h4>') } });
我们测试下,先把 login.art 文件下面的 js 代码注释掉,然后刷新浏览器,不填写信息直接提交,可以看到:服务器端的验证
可以把页面美化下,在 views 目录下新建 error.art 文件:
{{extend './common/layout.art'}} {{block 'main'}} <p class="bg-danger error">{{msg}}</p> {{/block}}
然后修改下 admin.js 文件中的代码:
// 实现登录功能 admin.post('/login', (req, res) => { // 接收请求参数 const {email, password} = req.body; // 如果用户没有输入邮件地址或密码 if (email.trim().length == 0 || password.trim().length == 0) { // return res.status(400).send('<h4>邮件地址或密码错误</h4>') return res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } });
刷新页面,重新提交下看效果。
下面我们在 error.art 文件中设置定时器,3秒后再跳回到 login 登陆页:
{{extend './common/layout.art'}} {{block 'main'}} <p class="bg-danger error">{{msg}}</p> {{/block}} {{block 'script'}} <script type="text/javascript"> setTimeout(function () { location.href = '/admin/login'; }, 3000) </script> {{/block}}
回到浏览器中刷新,OK,3秒后跳回到 login 登陆页了。
下面要根据客户端传递过来的邮箱地址,查询用户是否存在。如果用户不存在,则阻止程序继续向下执行,并为客户端做出响应,告知客户端邮箱地址或者密码错误;如果用户存在,则使用客户端传递过来的密码和从数据库中查询出来的用户信息中的密码,进行比对。若两者的密码一致,则表示登录成功;若不一致,则表示登录失败。
2.1.7、根据邮箱地址查询用户信息
继续编辑 admin.js 文件的登录功能代码:
// 导入用户集合构造函数 const { User } = require('../model/user'); // 实现登录功能 admin.post('/login', async (req, res) => { 。。。 // 根据邮箱地址查询用户信息 let user = await User.findOne({email: email.trim()}) });
2.1.8、如果用户不存在,为客户端做出响应,阻止程序向下执行
继续编辑 admin.js 文件的登录功能代码:
// 实现登录功能 admin.post('/login', async (req, res) => { 。。。 // 根据邮箱地址查询用户信息 let user = await User.findOne({email: email.trim()}) // 如果没有查询到用户,user 变量为空 if (user != null) { // 查询到了用户 } else { // 没有查询到用户 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } });
2.1.9、如果用户存在,将用户名和密码进行比对
继续编辑 admin.js 文件的登录功能代码:
// 实现登录功能 admin.post('/login', async (req, res) => { 。。。 // 根据邮箱地址查询用户信息 let user = await User.findOne({email: email.trim()}) // 如果没有查询到用户,user 变量为空 if (user != null) { // 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 if (password == user.password) { }else{ } } else { // 没有查询到用户 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } });
2.1.10、比对成功,用户登录成功
// 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 if (password == user.password) { //登录成功 res.send('登录成功'); }else{ // 登录失败 }
2.1.11、比对失败,用户登录失败
// 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 if (password == user.password) { //登录成功 res.send('登录成功'); }else{ // 登录失败 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) }
刷新浏览器,输入正确的邮件和密码,可以看到:
2.1.12、密码加密处理
项目包含的知识点:密码加密 bcrypt
在数据库中以明文的方式存储密码就是不安全的,所以要对密码进行加密处理。
哈希加密是单程加密方式,即只能加密,不能解密。
例如:1234 => abcd ,假如有个密码是1234,经过加密变成abcd,这个密码只能从1234变成abcd,不能从abcd解密成1234。
还可以在加密的密码中加入随机字符串可以增加密码被破解的难度。
genSalt 是异步 API,返回值是一个 promise 对象,可以在方法前面加上 await ,使用返回值的方式接收生成的随机字符串。
语法:加密密码
// 导入 bcrypt 模块 const bcrypt = require('bcrypt'); // 生成随机字符串 gen => generate 生成 salt 盐 let salt = await bcrypt.genSalt(10); // 使用随机字符串对密码进行加密 let pass = await bcrypt.hash('明文密码', salt);
语法:密码比对
//密码比对 let isEqual = await bcrypt.compare('明文密码', '加密密码');
bcrypt 依赖的其他环境:
1、python 2.x
python 的下载地址:https://www.python.org/downloads/windows/
下载完成后进行安装,默认安装就好。
然后把python的目录放到系统环境变量中。
2、node-gyp
在命令工具中,输入:
npm install -g node-gyp
3、windows-build-tools
在命令行工具中,输入:
npm install --global --production windows-build-tools
这个安装比较慢,请耐心等待。根据网速的快慢,我装了大概需要10分钟左右。
4、安装 bcrypt
重新开启一个命令行工具,输入:
npm install bcrypt
例子:
在项目根目录下新建 hash.js 文件:
// 导入 bcrypt 模块 const bcrypt = require('bcrypt'); async function run () { // 生成随机字符串 // genSalt 方法接收一个数值作为参数,默认值为10 // 数值越大 生成的随机字符串复杂度越高,反之复杂度越低 // 返回生成的字符串 const salt = await bcrypt.genSalt(10); console.log(salt); } run()
在命令行工具中,输入:node hash.js,可以看到结果:生成的随机字符串
进行加密处理:
// 导入 bcrypt 模块 const bcrypt = require('bcrypt'); async function run () { // 生成随机字符串 // genSalt 方法接收一个数值作为参数,默认值为10 // 数值越大 生成的随机字符串复杂度越高,反之复杂度越低 // 返回生成的字符串 const salt = await bcrypt.genSalt(10); // 对密码进行加密 // 第1个参数是要进行加密的明文,第2个参数是生成的随机字符串 // 返回值是加密后的密码 const result = await bcrypt.hash('123456', salt); console.log(salt); console.log(result); } run()
在命令行工具中,输入:node hash.js,可以看到结果:加密后的密码
这时就实现了密码加密的功能。
回到项目中,打开 model 目录下的 user.js 文件,把上次注释掉的创建用户代码放开,然后进行修改:
// 密码加密函数 async function createUser () { //生成随机字符串 const salt = await bcrypt.genSalt(10); // 进行加密 const pass = await bcrypt.hash('123456', salt); const user = await User.create({ username: 'itlili', email: 'lili@163.com', password: pass, role: 'admin', state: 0 }) } createUser();
在命令行工具中输入:node app.js
然后打开 Compass 软件,可以看到新创建的用户 lili :密码已经是加密后的形式了
把 user.js 文件中的 createUser 函数注释掉:
// createUser();
打开 admin.js 文件,修改用户登录时的密码比对功能的代码:
// 导入 bcrypt 模块 const bcrypt = require('bcrypt'); // 实现登录功能 admin.post('/login', async (req, res) => { 。。。 if (user != null) { // 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 // trie 比对成功;false 比对失败 let isValid = await bcrypt.compare(password, user.password) if (isValid) { //登录成功 res.send('登录成功'); }else{ // 登录失败 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } } 。。。 });
在命令行工具中,输入:nodemon app.js ,启动服务,然后刷新登陆页,填写正确的信息提交,可以看到:登录成功
验证登录效果:
在用户登陆成功后,将用户名存储在 req 这个请求对象中,然后在浏览器中访问 user 用户列表页,在用户列表页中从请求对象中获取用户名,将用户名显示在页面中。如果页面中能显示用户名,就说明用户真的登陆成功了,如果用户名不能显示,就说明用户的登陆是失败的。
修改 admn.js 文件:
if (isValid) { // 登录成功 // 将用户名存储在请求对象中 req.username = user.username res.send('登录成功'); }
用户列表路由添加参数:
// 创建用户列表路由 admin.get('/user', (req, res) => { res.render('admin/user.art', { msg: req.username }) });
打开 views-admin-user.art 文件,添加代码:
<h4>用户 {{msg ? msg : '用户名不存在'}}</h4>
在浏览器打开 login 页,输入正确的用户信息,登录成功后,在输入:http://localhost/admin/user 地址,可以看到:
说明登录没成功。
2.1.13、保存登陆状态
项目包含的知识点:cookie 与 session
cookie:浏览器在电脑硬盘中开辟的一块空间,主要供服务器端存储数据。
● coolie 中的数据是以域名的形式进行区分的。
● coolie 中的数据是有过期时间的,超过时间数据会被浏览器自动删除。
● coolie 中的数据会随着请求被自动发送到服务器端。
session:实际上就是一个对象,存储在服务器端的内存中,在 session 对象中也可以存储多条数据,每一条都有一个 sessionid 作为唯一标识。
在 node.js 中需要借助 express-session 实现 session 功能。
下载安装:
npm install express-session
示例代码:
const session = require('express-session');
app.use(session({ secret: 'secret key' }));
把登陆状态存储到 cookie
打开 app.js 文件,导入 express-session 模块:
// 导入 express-session 模块 const session = require('express-session'); // 配置 session app.use(session({ secret: 'secret key'}));
打开 admin.js 文件,修改代码:
if (isValid) { // 登录成功 // 将用户名存储在请求对象中 req.session.username = user.username res.send('登录成功'); }
在命令行工具中启动服务:nodemon app.js
在浏览器中刷新页面,重新输入用户信息,登录成功后,查看 Application 可以看到:
connect.sid 是 express-session 设置的默认名字,它对应的值是加密字符串,在这个加密的字符串里保存的是服务器端给客户端生成唯一的 sessionid。
接下来我们再往服务器端发送请求的时候,这个 cookie 就会被自动携带。服务器端接收到这个 cookie,并且从 cookie 中提取出对应的 sessionid,然后在 session 对象当中,根据这个 sessionid 去查找用户信息,如果查找到了,就说明用户登陆成功。
修改 admin.js 文件中的用户列表路由:
// 创建用户列表路由 admin.get('/user', (req, res) => { res.render('admin/user.art', { msg: req.session.username }) });
在浏览器刷新页面,重新登陆,登陆成功后在浏览器输入:http://localhost/admin/user ,可以看到:用户名显示出来了
下面要实现登录成功后跳转到用户列表页,同时在页面右上角把用户的信息显示出来。
在 admin.js 文件中添加重定向:
if (user != null) { // 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 // trie 比对成功;false 比对失败 let isValid = await bcrypt.compare(password, user.password) if (isValid) { // 登录成功 // 将用户名存储在请求对象中 req.session.username = user.username // 重定向到用户列表页 res.redirect('/admin/user'); // res.send('登录成功'); }
刷新浏览器重新登陆,登陆成功后自动跳转到用户列了。
通过 app.locals 把用户名显示在页面的右上角:
修改 admin.js 文件,把用户列表路由的 msg 去掉,并在登录成功后添加 app.locas :
// 创建用户列表路由 admin.get('/user', (req, res) => { res.render('admin/user.art') }); if (user != null) { // 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 // trie 比对成功;false 比对失败 let isValid = await bcrypt.compare(password, user.password) if (isValid) { // 登录成功 // 将用户名存储在请求对象中 req.session.username = user.username // res.send('登录成功'); // 在 req.app 里拿到的就是 app.js 里的app req.app.locals.userInfo = user; // 重定向到用户列表页 res.redirect('/admin/user'); }
删除掉 user.art 中的代码 : {{msg ? msg : '用户名不存在'}}
并在 header.art 文件中添加:
<span class="btn dropdown-toggle" data-toggle="dropdown"> {{userInfo.username}} <span class="caret"></span> </span>
打开浏览器回到登陆页面,重新登陆用户信息,登陆成功后可以在用户列表的右上角看到用户的用户名:
登陆拦截功能
现在在没登陆的情况下,在浏览器中输入:http://localhost/admin/user 可以看到报错信息:
这是因为在没登陆的情况下,是没有 userInfo 这个属性的,也就没有 userInfo 这个属性下的 username。
打开 header.art 文件,添加个判断:
{{ userInfo && userInfo.username }}
重新刷新页面,可以看到用户列表页:没有登陆,所以右上角的用户名为空
而我们想要的是在用户没有登陆的情况下,是不能访问到用户列表页的。
这是我们需要使用中间件进行拦截,注意:中间是有顺序的,从上到下顺序执行。所以中间件的代码要写带路由之间。
打开 app.js 文件:
// 拦截请求,判断用户登录状态 app.use('/admin', (req, res, next) => { // 判断用户访问的是否是登录页面 // 判断用户的登录状态 // 如果用户是登录的,将请求放行,向下执行;如果用户不是登录的,则将请求重定向到登录页 if (req.url != '/login' && !req.session.username) { // 重定向到登录页 res.redirect('/admin/login'); } else { // 用户是登录的,将请求放行,向下执行 next() } }) // 引入路由模块
回到浏览器刷新:http://localhost/admin/user ,发现跳转到了 http://localhost/admin/login
功能实现:只有登陆成功了,才能访问到用户列表页。
下面要对当前代码进行优化
app.js 文件中我们只想引入一些模块,做一些基础的配置,不想把具体的功能代码写带里面。所以我们要把登陆拦截的代码分离出去。
在项目根目录下新建 middleware 文件夹,存放中间件,并创建 loginGuard.js 文件:把拦截中的函数代码剪切过来
const guard = (req, res, next) => { // 判断用户访问的是否是登录页面 // 判断用户的登录状态 // 如果用户是登录的,将请求放行,向下执行;如果用户不是登录的,则将请求重定向到登录页 if (req.url != '/login' && !req.session.username) { // 重定向到登录页 res.redirect('/admin/login'); } else { // 用户是登录的,将请求放行,向下执行 next() } } module.exports = guard;
在 app.js 文件中引入:
// 拦截请求,判断用户登录状态 app.use('/admin', require('./middleware/loginGuard'));
下面我们在浏览器中重新测试验证下,发现功能还是一样的。
再来优化 admin.js 这个路由文件
在 route 目录下新建 admin 文件夹,新建 login.js 文件,把实现登录功能的代码,剪切过来,并把用到的模块引入过来:
// 导入用户集合构造函数 const { User } = require('../../model/user'); // 导入 bcrypt 模块 const bcrypt = require('bcrypt'); const login = async (req, res) => { // 接收请求参数 const {email, password} = req.body; // 如果用户没有输入邮件地址或密码 if (email.trim().length == 0 || password.trim().length == 0) { // return res.status(400).send('<h4>邮件地址或密码错误</h4>') return res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } // 根据邮箱地址查询用户信息 let user = await User.findOne({email: email.trim()}) // 如果查询到了用户,user 变量的值是对象类型 // 如果没有查询到用户,user 变量为空 if (user != null) { // 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 // trie 比对成功;false 比对失败 let isValid = await bcrypt.compare(password, user.password) if (isValid) { // 登录成功 // 将用户名存储在请求对象中 req.session.username = user.username // res.send('登录成功'); // 在 req.app 里拿到的就是 app.js 里的app req.app.locals.userInfo = user; // 重定向到用户列表页 res.redirect('/admin/user'); }else{ // 登录失败 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } } else { // 没有查询到用户 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } } module.exports = login;
再在 admin.js 文件中引入:
// 实现登录功能 admin.post('/login', require('./admin/login'));
回到浏览器在测试 验证下,功能都正常。
login.js 文件还可以简化下:
// 导入用户集合构造函数 const { User } = require('../../model/user'); // 导入 bcrypt 模块 const bcrypt = require('bcrypt'); module.exports = async (req, res) => { // 接收请求参数 const {email, password} = req.body; // 如果用户没有输入邮件地址或密码 if (email.trim().length == 0 || password.trim().length == 0) { // return res.status(400).send('<h4>邮件地址或密码错误</h4>') return res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } // 根据邮箱地址查询用户信息 let user = await User.findOne({email: email.trim()}) // 如果查询到了用户,user 变量的值是对象类型 // 如果没有查询到用户,user 变量为空 if (user != null) { // 查询到了用户,将客户端传递过来的密码与查询出用户信息中的密码进行比对 // trie 比对成功;false 比对失败 let isValid = await bcrypt.compare(password, user.password) if (isValid) { // 登录成功 // 将用户名存储在请求对象中 req.session.username = user.username // res.send('登录成功'); // 在 req.app 里拿到的就是 app.js 里的app req.app.locals.userInfo = user; // 重定向到用户列表页 res.redirect('/admin/user'); }else{ // 登录失败 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } } else { // 没有查询到用户 res.status(400).render('admin/error.art', {msg: '邮件地址或密码错误'}) } }
下面我们把其他的路由也都分离出来:
新建 loginPage.js 文件:
module.exports = (req, res) => { res.render('admin/login.art') }
新建 userPage.js 文件:
module.exports = (req, res) => { res.render('admin/user.art') }
在 admin.js 文件中引入:
// 渲染登录页面 admin.get('/login', require('./admin/loginPage')); // 实现登录功能 admin.post('/login', require('./admin/login')); // 创建用户列表路由 admin.get('/user', require('./admin/userPage'));
2.1.14、实现功能
在服务器端删除这个用户对应的 session,还要删除客户端的 cookie,这样客户端和服务器端就断开了联系,也就实现了退出。
打开 views-admin-common 目录下 header.art 文件:
<li><a href="/admin/logout">退出登录</a></li>
打开 route 目录下 admin.js 文件:创建退出功能路由
// 实现退出功能 admin.get('/logout', require('./admin/logout'));
在route-admin 目录下新建 logout.js 文件:
module.exports = (req, res) => { // 删除 session req.session.destroy(function () { // 删除 cookie res.clearCookie('connect.sid'); // 重定向到登陆页面 res.redirect('/admin/login'); }) }
回到浏览器刷新页面,登陆成功后,点击退出登陆。可以看到跳回了登录页。
但是还有个问题,当我们退出登录以后,还能看到Cookie:
这是因为看到的这个 Cookie 已经不是你登录的那个 Cookie 了。因为在 session 方法里有个配置 ,这个配置 saveUninitialized: true 保存未初始化的 Cookie,意思是:只要客户端访问服务器端,不管登没登录,都存储一个 Cookie。所以我们要把这个参数修改下。
打开 app.js 文件:修改 session 配置
// 配置 session app.use(session({ secret: 'secret key' , saveUninitialized: false}));
刷新浏览器重新登录,再退出,可以看到已经没有 Cookie 了。
还有个问题,我们在配置 session 时,没有指定 Cookie 的过期时间,如果在存储 session 的时候没去指定 Cookie 的过期时间,那么这个 Cookie 在浏览器关闭的时候,这个 Cookie 就会自动被删除掉。而我们希望的是在一天后如果不登录,那么就自动失效。
继续修改 session 配置
// 配置 session app.use(session({ secret: 'secret key' , saveUninitialized: false, cookie: { maxAge: 24 * 60 * 60 * 1000 } }));
现在我们设置的过期时间是:从登录的时间开始,一天后登录状态就自动失效了。
2.2、 新增用户
2.2.1、为用户列表页面的新增用户按钮添加链接
打开 views-admin 目录下的 user.art 文件,给新增用户按钮添加连接:
<a href="/admin/user-edit" class="btn btn-primary new">新增用户</a>
2.2.2、添加一个连接对应的路由,在路由处理函数中渲染新增用户模板
打开 route 目录下 admin.js 文件,新增 user-edit 路由:
// 创建用户编辑页面路由 admin.get('/user-edit', require('./admin/user-edit'));
在 route-admin 目录下新建 user-edit.js 文件:
module.exports = (req, res) => { res.render('admin/user-edit.art'); }
打开浏览器刷新,点击新增用户,发现:可以跳转到表单页了。
2.2.3、为新增用户表单指定请求地址、请求方式、为表单项添加name属性
打开 views-admin 目录下 user-edit.art 文件:
<form class="form-container" method="post" action="/admin/user-edit"> <input name="username" type="text" class="form-control" placeholder="请输入用户名"> <input name="email" type="email" class="form-control" placeholder="请输入邮箱地址"> <input name="password" type="password" class="form-control" placeholder="请输入密码"> <select name="role" class="form-control"> <option value="normal">普通用户</option> <option value="admin">超级管理员</option> </select> <select name="state" class="form-control"> <option value="0">启用</option> <option value="1">禁用</option> </select>
2.2.4、增加实现添加用户的功能路由
打开 route 目录下 admin.js 文件:
// 创建实现添加用户功能 admin.post('/user-edit', require('./admin/user-edit-fn'));
在 route-admin 目录下新建 user-edit-fn.js 文件:
module.exports = (req, res) => { res.send('ok'); }
回到浏览器刷新表单页面,点击提交按钮,可以看到:
说明路由创建成功了。
2.2.5、接收到客户端传递过来的请求参数
继续编辑 user-edit-fn.js 文件:
module.exports = (req, res) => { // 输出请求参数 res.send(req.body) }
刷新浏览器,随便输入一些信息,点击提交。可以看到:接收到的请求参数
项目包含的知识点:Joi
JavaScript 对象的规则描述语言和验证器。实际上就是验证 JavaScript 格式的。
下载安装:
npm install joi
示例代码:
const Joi = require('joi'); // 定义验证规则 const schema = { username: Joi.string().alphanum().min(3).max(30).required().error(new Error('错误信息')), password: Joi.string().regex(/^[a-zA-Z0-9](3, 30)$/), access_token: [Joi.string(), Joi.number()], birthyear: Joi.number().integer().min(1900).max(2013), email: Joi.string().email() }; // 使用规则验证对象(第1个参数:要验证对象,第2个参数:验证的规则) Joi.validate({ username: 'abc', birthyear: 1994}, schema);
● alphanum() 表示 username 属性只能是字母字符串或者是数字字符串,不能包含特殊字符(比如_、$等)
● required() 表示 username 属性是必选属性
● error() 方法指定错误信息
● integer() 表示必须是整数
● valid() 表示合法值
在命令行工具中输入:npm install joi 安装第三方模块。
例子:新建 joi.js 文件:
// 引入 joi 模块 const Joi = require('joi'); // 定义对象的验证规则 const schema = { username = Joi.string().alphanum().min(2).max(5) }; async function run() { try { // 实施验证(第1个参数:要验证对象,第2个参数:验证的规则) await Joi.validate({ username: 'abc'}, schema); } catch (err) { console.log(err.message); } console.log('验证通过'); } run();
在命令行工具中输入:nodemon joi.js ,可以看到:
把 username 改为 a,在看下结果:报错信息
这个英文的错误提示信息,对用户来说不够友好,我们可以自定义错误信息:
username: Joi.string().alphanum().min(2).max(5).error(new Error('username 属性没有通过验证'))
回到命令行工具可以看到:
注意:使用 Joi.validate()实施验证,这个方法返回 promise 对象,可以在后面使用 then 或者 catch 捕获错误信息;也可以通过异步函数的方式来验证对象,但是错误信息要通过 try catch 来捕获。
2.2.6、对请求参数的格式进行验证
打开 user-edit-fn.js 文件,引入 joi 模块:
// 引入 joi 模块 const Joi = require('joi'); // 引入 joi 模块 const Joi = require('joi'); module.exports = async (req, res) => { // 定义对象的验证规则 const schema = { username: Joi.string().alphanum().min(2).max(12).required().error(new Error('用户名不符合验证规则')), email: Joi.string().email().required().error(new Error('邮箱格式不符合验证规则')), password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/).required().error(new Error('密码格式不符合验证规则')), role: Joi.string().valid('normal', 'admin').required().error(new Error('角色值非法')), state: Joi.number().valid(0, 1).required().error(new Error('状态值非法')) }; try { // 实施验证 await Joi.validate(req.body, schema); }catch (err) { // 验证没有通过 // 重定向回用户添加页面 // res.redirect('/admin/user-edit?message=' + err.message); res.redirect(`/admin/user-edit?message=${err.message}`); } res.send(req.body) }
打开 route-admin 目录下的 user-edite.js 文件,添加代码:
module.exports = (req, res) => { const {message} = req.query; return res.render('admin/user-edit.art', {message: message}); }
在打开 views-admin 目录下的 user-edit.art 文件,把错误信息改为 message :
<p class="tips">{{message}}</p>
回到浏览器,重新登录后添加用户,直接点提交按钮,可以看到错误信息提示:
2.2.7、验证当前要注册的邮箱地址是否已经注册过
打开 route-admin 目录下 user-edit-fn.js 文件,导入用户集合:
// 导入用户集合构造函数 const { User } = require('../../model/user'); // 根据邮箱地址查询用户是否存在 const user = await User.findOne({email: req.body.email}) // 如果用户已经存在,那么邮箱地址已经被占用 if (user) { return res.redirect(`/admin/user-edit?message=邮箱地址已经被占用`); }
回到浏览器重新登录,提交已经存在的用户信息,发现结果:
2.2.8、对密码进行加密处理
打开 route-admin 目录下 user-edit-fn.js 文件,导入 bcrypt 模块:
// 导入 bcrypt 模块 const bcrypt = require('bcrypt'); // 对密码进行加密处理 //生成随机字符串 const salt = await bcrypt.genSalt(10); // 进行加密 const password = await bcrypt.hash(req.body.password, salt); req.body.password = password; res.send(req.body.password);
在浏览器重新提交,可以看到结果:说明密码加密成功
2.2.9、将用户信息添加到数据库中
继续编辑 user-edit-fn.js 文件:
// 将新用户信息添加到数据库中 await User.create(req.body);
2.2.10、重定向页面到用户列表页面
继续编辑 user-edit-fn.js 文件:
// 将页面重定向到用户列表页 res.redirect('/admin/user');
回到浏览器,重新添加新用户,成功后跳回到用户列表页。打开 Compass 软件,可以看到:
user-edit-fn.js 文件完整代码:
// 引入 joi 模块 const Joi = require('joi'); // 导入 bcrypt 模块 const bcrypt = require('bcrypt'); // 导入用户集合构造函数 const { User } = require('../../model/user'); module.exports = async (req, res) => { // 定义对象的验证规则 const schema = { username: Joi.string().alphanum().min(2).max(12).required().error(new Error('用户名不符合验证规则')), email: Joi.string().email().required().error(new Error('邮箱格式不符合验证规则')), password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/).required().error(new Error('密码格式不符合验证规则')), role: Joi.string().valid('normal', 'admin').required().error(new Error('角色值非法')), state: Joi.number().valid(0, 1).required().error(new Error('状态值非法')) }; try { // 实施验证(第1个参数:要验证对象,第2个参数:验证的规则) await Joi.validate(req.body, schema); }catch (err) { // 验证没有通过 // 重定向回用户添加页面 // res.redirect('/admin/user-edit?message=' + err.message); return res.redirect(`/admin/user-edit?message=${err.message}`); } // 根据邮箱地址查询用户是否存在 const user = await User.findOne({email: req.body.email}) // 如果用户已经存在,那么邮箱地址已经被占用 if (user) { return res.redirect(`/admin/user-edit?message=邮箱地址已经被占用`); } // 对密码进行加密处理 //生成随机字符串 const salt = await bcrypt.genSalt(10); // 进行加密 const password = await bcrypt.hash(req.body.password, salt); // 替换密码 req.body.password = password; // 将新用户信息添加到数据库中 await User.create(req.body); // 将页面重定向到用户列表页 res.redirect('/admin/user'); }
代码优化1:验证用户信息
分离请求参数验证的代码,在 model 目录下 user.js 文件中都是对用户数据操作相关的代码 ,所以可以放在这里。
user.js 文件
// 引入 joi 模块 const Joi = require('joi'); // 验证用户信息 const validateUser = user => { // 定义对象的验证规则 const schema = { username: Joi.string().alphanum().min(2).max(12).required().error(new Error('用户名不符合验证规则')), email: Joi.string().email().required().error(new Error('邮箱格式不符合验证规则')), password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/).required().error(new Error('密码格式不符合验证规则')), role: Joi.string().valid('normal', 'admin').required().error(new Error('角色值非法')), state: Joi.number().valid(0, 1).required().error(new Error('状态值非法')) }; // 实施验证(第1个参数:要验证对象,第2个参数:验证的规则) return Joi.validate(user, schema); } // 将用户信息集合作为模块成员进行导出 module.exports = { User, validateUser }
回到 user-edit-fn.js 中引用:
// 导入用户集合及验证用户 const { User, validateUser } = require('../../model/user'); module.exports = async (req, res) => { try { await validateUser(req.body); } 。。。
回到浏览器中验证下,ok,没问题,功能都正常。
代码优化2:错误处理
错误处理中间件是一个集中处理错误的地方。
打开 app.js 文件,定义错误处理中间件,把重定向的代码粘过来:
// 错误处理中间件 app.use((err, req, res, next) => { res.redirect(`/admin/user-edit?message=${err.message}`); })
但是这里的路由和参数不可能写死,所以在 user-edit-fn.js 中添加 next:
module.exports = async (req, res, next) => { try { await validateUser(req.body); }catch (err) { // 验证没有通过 // 重定向回用户添加页面 // return res.redirect(`/admin/user-edit?message=${err.message}`); return next() } 。。。
next() 只能传递一个参数,并且是字符串类型,而我们需要传递两个参数。所以需要传递一个参数,用对象的形式,然后再转换为字符串类型。JSON.stringify() 将对象数据类型转换为字符串数据类型
所以要修改为:
try { await validateUser(req.body); }catch (err) { // 验证没有通过 // 重定向回用户添加页面 // return res.redirect(`/admin/user-edit?message=${err.message}`); // JSON.stringify() 将对象数据类型转换为字符串数据类型 return next(JSON.stringify({path: '/admin/user-edit', message: err.message})) }
回到 app.js 文件修改错误处理中间件代码:
// 错误处理中间件 app.use((err, req, res, next) => { // 框字符串类型转换为对象类型 JSON.parse() const result = JSON.parse(err); res.redirect(`${result.path}?message=${result.message}`); })
回到浏览器中验证下:错误提示没问题
下面把 user-edit-fn.js 文件中的另一个错误处理信息也修改下:
if (user) { // return res.redirect(`/admin/user-edit?message=邮箱地址已经被占用`); return next(JSON.stringify({path: '/admin/user-edit', message: '邮箱地址已经被占用'})) }
测试下没有问题。
2.3、 用户列表
2.3.1、访问用户列表页时,在对应的路由处理函数中,将所有的用户信息从数据库总查询出来。
打开 route-admin 目录下的 userPage.js 文件:
// 导入用户集合构造函数 const { User } = require('../../model/user'); module.exports = async (req, res) => { // 将用户信息从数据库中查询处理 let users = await User.find({}) res.render('admin/user.art', { users: users }) }
2.3.2、将查询中来的数据渲染到用户列表中
打开 views-admin 目录下的 user.art 文件:
<tbody> {{each users}} <tr> <!-- @表示原文输出 --> <td>{{@$value._id}}</td> <td>{{$value.username}}</td> <td>{{$value.email}}</td> <td>{{$value.role == 'admin' ? '超级管理员' : '普通用户'}}</td> <td>{{$value.state == 0 ? '启用' : '禁用'}}</td> <td> <a href="user-edit.html" class="glyphicon glyphicon-edit"></a> <i class="glyphicon glyphicon-remove" data-toggle="modal" data-target=".confirm-modal"></i> </td> </tr> {{/each}} </tbody>
在浏览器刷新页面,可以看到:用户列表
还有找到1个用户,这里要改为动态数据:
<span>找到{{users.length}}个用户</span>
刷新浏览器:
2.4、用户数据分页
当数据库中的数据非常多是,数据需要分批次显示,这时就需要用到数据分页功能。
分页功能核心要素:
1、当前页,用户通过点击上一页或者下一页或者页码产生,客户端通过get参数方式传递到服务器端
2、总页数,根据总页数判断当前页是否为最后一页,根据判断结果做响应操作
总页数的计算方式:Math.ceil(总数据条数 / 每页显示数据条数)
2.4.1、接收客户端传递过来的当前页参数
打开 route-admin 目录下的 userPage.js 文件:
// 导入用户集合构造函数 const { User } = require('../../model/user'); module.exports = async (req, res) => { // 接收客户端传递过来的当前页参数 let page = req.query.page; res.send(page); return; // 阻止程序向下执行 // 将用户信息从数据库中查询处理 let users = await User.find({}) // 渲染用户列表模块 res.render('admin/user.art', { users: users }) }
回到浏览器中刷新页面:http://localhost/admin/user?page=1 ,可以看到:页面显示1
2.4.2、计算总页数
根据上面的公式,要准备:总数据条数 和 每页显示数据条数
继续编辑 userPage.js 代码:
// 导入用户集合构造函数 const { User } = require('../../model/user'); module.exports = async (req, res) => { // 接收客户端传递过来的当前页参数 let page = req.query.page; // 每页显示的数据条数 let pagesize = 5; // 查询用户数据的总数 let count = await User.countDocuments({}); // 计算总页数 let total = Math.ceil(count / pagesize); // 将用户信息从数据库中查询处理 let users = await User.find({}) // 渲染用户列表模块 res.render('admin/user.art', { users: users }) }
下面需要用到的方法:
limit(2) // limit 限制查询数量 传入每页显示的数据数量 skip(1) // skip 跳过多少条数据 传入显示数据的开始位置
2.4.3、每页开始查询位置的计算
数据开始查询位置=(当前页-1)* 每页显示的数据条数
// 页面对应的开始位置 let start = (page - 1) * pagesize; // 将用户信息从数据库中查询处理 let users = await User.find({}).limit(pagesize).skip(start)
如果用户没有传递页面参数,默认为:1
// 接收客户端传递过来的当前页参数,如果没有传页面,默认为1 let page = req.query.page || 1;
回到浏览器刷新用户列表页,可以看到只显示了5条数据信息:
这是我们在浏览器手动输入: http://localhost/admin/user?page=1,可以看到页面没有变化,也就说明默认显示的就是1
再输入:http://localhost/admin/user?page=2,可以看到列表数据变了:
2.4.4、在页面中生成分页器,并为分页器中的每一个按钮加入链接
打开 route-admin 目录下的 userPage.js 文件,添加传递参数:把当前页和总页数传递过去
// 渲染用户列表模块 res.render('admin/user.art', { users: users, page: page, total: total })
打开 views-admin 目录下的 user.art 文件:使用原始语法循环页码
<% for (var i = 1; i <= total; i++) { %> <li><a href="/admin/user?page=<%= i %>"><%= i %></a></li> <% } %>
回到浏览器刷新页面,可以看到3个分页,鼠标移到上面可以看到链接地址:
也就是说,我们可以点击页码实现分页切换了。
下面来实现上一页及下一页的功能:
<li> <a href="/admin/user?page=<%= page-1 %>"> <span>«</span> </a> </li> <li> <a href="/admin/user?page=<%= page+1 %>"> <span>»</span> </a> </li>
当前在第2页,点击上一页,可以跳转到第1页,但是再点击下一页的时候,却发现跳转到了第11页:
这里的 + 号运算变成了字符串连接,而 - 号运算为什么成功了?是因为- 号有隐式类型转换的功能,而 + 号没有。
这里我们要把 page 转换成数值,然后在进行 + 运算。修改为:
<a href="/admin/user?page=<%= page-0+1 %>">
利用 - 号的隐式运算,变为数值类型,然后再 + 1。刷新页面,发现下一页的功能实现了。
现在还有个问题,就是当在分页最后一页的时候,再点击下一页,还会跳转到+1的页面。这里我们需要做个判断,若当前页就是最后一页了,那么隐藏下一页的按钮;同样在第1页的时候,隐藏上一页按钮:
{{if page != 1}} <li> <a href="/admin/user?page=<%= page-1 %>"> <span>«</span> </a> </li> {{/if}} {{if page != total }} <li> <a href="/admin/user?page=<%= page-0+1 %>"> <span>»</span> </a> </li> {{/if}}
也可以写为:效果是一样的
<li style="display: <%= page-1 < 1 ? 'none': 'inline' %>"> <a href="/admin/user?page=<%= page-1 %>"> <span>«</span> </a> </li> <li style="display: <%= page-0+1 > total ? 'none': 'inline' %>"> <a href="/admin/user?page=<%= page-0+1 %>"> <span>»</span> </a> </li>
刷新页面,切换到第一个页或者最后一页,可以看到:
2.5、用户信息修改
2.5.1、根据 id 查询用户信息,并渲染到表单页
打开 views-admin 目录下 user.art 文件:给每条用户信息添加修改路由地址,并带上该用户的id
<a href="/admin/user-edit?id={{@$value._id}}" class="glyphicon glyphicon-edit"></a>
我们点击修改按钮,可以看到后面带的id参数:
而点击新增用户按钮,跳转的地址没有携带参数:
打开 route-admin 目录下 user-edit.js 文件:进行判断
module.exports = async (req, res) => { // 获取到地址栏中的 id 参数 const {message, id} = req.query; // 如果当前传递了 id 参数 if (id) { // 修改操作 } else { // 添加操作 } res.render('admin/user-edit.art', { message: message }); }
如果是修改操作,那么要根据 id 来查询用户信息,所以要引入用户集合:
// 导入用户集合构造函数 const { User } = require('../../model/user'); module.exports = async (req, res) => { // 获取到地址栏中的 id 参数 const {message, id} = req.query; // 如果当前传递了 id 参数 if (id) { // 修改操作 根据id查询用户信息 let user = await User.findOne({_id: id}) res.render('admin/user-edit.art', { message: message, user: user }); } else { // 添加操作 res.render('admin/user-edit.art', { message: message }); } }
打开 views-admin 目录下 user-edit.art 文件:
<input name="username" value="{{user.username }}" type="text" class="form-control" placeholder="请输入用户名">
在浏览器点击修改按钮,可以看到用户名渲染出来了。但是有个问题,点击新增按钮会报错。
因为我们新增用户也使用这个页面,所以要加下判断:
<input name="username" value="{{user && user.username}}" type="text" class="form-control" placeholder="请输入用户名"> <input name="email" value="{{user && user.email}}" type="email" class="form-control" placeholder="请输入邮箱地址"> <option value="normal" {{user && user.role == 'normal' ? 'selected' : ''}}>普通用户</option> <option value="admin" {{user && user.role == 'admin' ? 'selected' : ''}}>超级管理员</option> <option value="0" {{user && user.state == 0 ? 'selected' : ''}}>启用</option> <option value="1" {{user && user.state == 1 ? 'selected' : ''}>禁用</option>
注意:在修改用户信息这里是禁止修改密码的,后面有单独的地方修改密码。而且这里要输入正确的密码,进行和服务器端的密码比对,比对一致,才可以修改成功。所以这里先不处理。
下面是标题,如果是修改操作,那么标题显示该用户的 id ;如果是新增操作,那么就不显示标题。
<h4 style="display: {{user && user._id ? 'block' : 'none'}}">{{@user && user._id}}</h4>
刷新浏览器,点击修改按钮,可以看到:
打开 route-admn 目录下 user-edit.js 文件,添加跳转路由地址的参数 link:
// 如果当前传递了 id 参数 if (id) { // 修改操作 根据id查询用户信息 let user = await User.findOne({_id: id}) // 渲染用户编辑修改页面 res.render('admin/user-edit.art', { message: message, user: user, link: '/admin/user-modify',
button: '修改' }); } else { // 添加操作 res.render('admin/user-edit.art', { message: message, link: '/admin/user-edit',
button: '添加' }); }
打开 views-admin 目录下 user-edit.art 文件:
<form class="form-container" method="post" action="{{link}}"> <div class="buttons"> <input type="submit" value="{{button}}" class="btn btn-primary"> </div>
在浏览器刷新页面,点击添加或者修改按钮可以看到:
2.5.2、将要修改的用户ID传递到服务器端
打开 route-admin 目录下 user-edit.js 文件,再修改地址上添加携带参数 id:
// 渲染用户编辑修改页面 res.render('admin/user-edit.art', { message: message, user: user, link: '/admin/user-modify?id=' + id, button: '修改' });
2.5.3、建立用户信息修改功能对应的路由
打开 route 目录下 admin.js 文件:添加修改路由
// 修改用户功能 admin.post('/user-modify', require('./admin/user-modify'));
在 route-admin 目录下新建 user-modify.js 文件:
module.exports = (req, res) => { res.send('ok') }
在浏览器验证下,点击修改按钮,可以看到:
2.5.4、接收客户端表单传递过来的请求参数
打开 route-admin 目录下 user-modify.js 文件:
module.exports = (req, res) => { // 接收从客户端传递过来的请求参数 const body = req.body; // 获取到地址栏中的 id 参数 const id = req.query.id; }
2.5.5. 根据id查询用户信息,并将客户端传递过来的密码和数据库中的密码进行比对
打开 route-admin 目录下 user-modify.js 文件:导入用户集合
// 导入 bcrypt 模块 const bcrypt = require('bcrypt'); // 导入用户集合构造函数 const { User } = require('../../model/user'); module.exports = async (req, res) => { // 接收从客户端传递过来的请求参数 const body = req.body; // 获取到地址栏中的 id 参数 const id = req.query.id; // 根据 id 查询用户信息 let user = await User.findOne({_id: id}) //密码比对 let isValid = await bcrypt.compare(req.body.password, user.password); console.log(isValid) }
回到浏览器验证,修改时输入正确的密码,点击提交,可以看到命令行中:true 说明比对成功
//密码比对 let isValid = await bcrypt.compare(req.body.password, user.password); if (isValid) { // 密码比对成功 } else { // 密码比对失败 }
2.5.6、如果比对失败,对客户端做出响应
if (isValid) { // 密码比对成功 } else { // 密码比对失败 // 触发错误中间件,并重定向到修改页 let obj = {path: '/admin/user-editd', id: id, message: '密码比对错误,不能进行用户信息修改'} return next(JSON.stringify(obj)); }
注意:在 app.js 文件中定义的错误中间件,参数 message 是写死的,我们这里定义的 id 传不进去。所以要对错误中间件进行下修改。
打开 app.js 文件:
// 错误处理中间件 app.use((err, req, res, next) => { // 框字符串类型转换为对象类型 JSON.parse() const result = JSON.parse(err); // 循环参数变量 let params = []; for (var attr in result) { if (attr != 'path') { params.push(attr + '=' + result[attr]); } } // res.redirect(`${result.path}?message=${result.message}`); res.redirect(`${result.path}?${params.join('&')}`); })
回到浏览器,随便输入一个错误的密码,然后点击修改,可以看到:
2.5.7、如果密码对比成功,将用户信息更新到数据库中
编辑比对成功代码:
if (isValid) { // 密码比对成功 // 将用户信息更新数据库中 (除去密码项) await User.updateOne({_id: id}, { username: body.username, email: body.email, role: body.role, state: body.state, }) }
实际上这里可以通过结构的形式,把这些参数都结构出来,所以要修改为:
// 接收从客户端传递过来的请求参数 const {username, email, role, state, password} = req.body; //密码比对 let isValid = await bcrypt.compare(password, user.password); if (isValid) { // 密码比对成功 // 将用户信息更新数据库中 (除去密码项) await User.updateOne({_id: id}, { username: username, email: email, role: role, state: state, }) }
2.5.8、重定向到用户列表页
编辑比对成功代码:
if (isValid) { // 密码比对成功 // 将用户信息更新数据库中 (除去密码项) await User.updateOne({_id: id}, { username: username, email: email, role: role, state: state, }); // 将页面重定向到用户列表页 res.redirect('/admin/user'); }
在浏览器刷新,修改用户名提交,看效果:跳回到了用户列表页,用户名修改了
2.6、用户信息删除
2.6.1、在确认删除框中添加隐藏域用以存储要删除用户的ID值
打开 views-admin 目录下 user.art 文件,添加隐藏域:
<p>您确定要删除这个用户吗?</p> <input type="hidden" name="id">
2.6.2、为删除按钮添自定义属性用以存储要删除用户的ID值
给删除按钮添加自定义属性 data-id :
<i class="glyphicon glyphicon-remove" data-toggle="modal" data-target=".confirm-modal" data-id="{{@$value._id}}"></i>
刷新页面,查看源码可以看到:data-id 已经有了
2.6.3、为删除按钮添加点击事件,在点击事件处理函数中获取自定义属性中存储的ID值并将ID值存储在表单的隐藏域中
继续编辑 user.art 文件:
给删除图标添加样式及点击事件:
<i class="glyphicon glyphicon-remove delete" data-toggle="modal" data-target=".confirm-modal" data-id="{{@$value._id}}"></i> {{block 'script'}} <script type="text/javascript"> $('.delete').on('click', function() { alert(1) }); </script> {{/block}}
刷新浏览器,点击删除按钮,可以看到弹出的1,点击确定后,再弹出删除提示框。
下面要获取点击该用户的 id:
{{block 'script'}} <script type="text/javascript"> $('.delete').on('click', function() { // 获取用户 id var id = $(this).attr('data-id'); alert(id) }); </script> {{/block}}
刷新浏览器,点击删除按钮,可以看到:说明获取到该用户 id 了
要把获取到的 id 存储到隐藏域中,为了方便获取,给隐藏域添加 id:
<input type="hidden" name="id" id="deleteUserId"> {{block 'script'}} <script type="text/javascript"> $('.delete').on('click', function() { // 获取用户 id var id = $(this).attr('data-id'); // 将要删除的用户 id 存储在隐藏域 $('#deleteUserId').val(id); }); </script> {{/block}}
刷新页面,点击删除按钮,查看源码,可以看到在隐藏域多了一个 value 属性,存储 id 值
2.6.4、为删除表单添加提交地址以及提交方式
<form class="modal-content" action="/admin/delete" method="get">
2.6.5、在服务器端建立删除功能路由
打开 route 目录下 admin.js 文件,新建删除用户的路由:
// 删除用户功能 admin.get('/delete', require('./admin/user-delete'));
在 route-admin 目录下新建 user-delete.js 文件:
module.exports = (req, res) => { res.send('ok') }
刷新浏览器,点击删除按钮,可以看到:说明路由创建成功了
2.6.6、接收客户端传递过来的id参数
module.exports = (req, res) => { // 获取要删除的用户 id let id = req.query.id }
2.6.7、根据id删除用户
引入用户集合:
// 导入用户集合构造函数 const { User } = require('../../model/user'); module.exports = (req, res) => { // 获取要删除的用户 id let id = req.query.id await User.findOneAndDelete({_id: id}) // 将页面重定向到用户列表页 res.redirect('/admin/user'); }
回到浏览器刷新,点击删除按钮,可以看到:可以看到 lisi 已经被删除掉了
2.7、文章管理
2.7.1、添加文章列表页和文章编辑页的路由并渲染模板
打开 route 目录下 admin.js 文件:添加路由
// 文章列表页面路由 admin.get('/article', require('./admin/article')); // 文章编辑页面路由 admin.get('/article-edit', require('./admin/article-edit'));
在 route-admin 目录下新建 article.js 文件:
module.exports = (req, res) => { res.render('admin/article.art'); }
在 route-admin 目录下新建 article-edit.js 文件:
module.exports = (req, res) => { res.render('admin/article-edit.art'); }
在浏览器刷新页面,重新登录,然后输入:http://localhost/admin/article 和 http://localhost/admin/article-edit,可以访问到文章列表页面和文章编辑页面。
2.7.2、点击左侧菜单切换页面
打开 views-admin-common 目录下 aside.art 文件,链接改为路由:
<a class="item active" href="/admin/user"> <span class="glyphicon glyphicon-user"></span> 用户管理 </a> <a class="item" href="/admin/article"> <span class="glyphicon glyphicon-th-list"></span> 文章管理 </a>
在浏览器刷新页面,点击左侧菜单可以实现切换用户管理页面和文章管理页面。
但是链接的选择样式却没有改变,我们可以在路由中设置一个标识,然后通过标识来区分页面。
打开 route-admin 目录下 article.js 文件:
module.exports = (req, res) => { // 标识,标识当前访问的是文章管理页面 req.app.locals.currentLink = 'article'; res.render('admin/article.art'); }
复制一份粘贴到 article-edit.js 文件:
module.exports = (req, res) => { // 标识,标识当前访问的是文章管理页面 req.app.locals.currentLink = 'article'; res.render('admin/article-edit.art'); }
打开 route-admin 目录下 userPage.js 文件,粘贴修改为:
// 标识,标识当前访问的是用户管理页面 req.app.locals.currentLink = 'user';
复制一份粘贴到 user-edit.js 文件:
// 标识,标识当前访问的是用户管理页面 req.app.locals.currentLink = 'user';
然后再打开 views-admin 目录下 aside.art 文件,添加判断标识:
<a class="item {{currentLink == 'user' ? 'active' : ''}}" href="/admin/user"> <span class="glyphicon glyphicon-user"></span> 用户管理 </a> <a class="item {{currentLink == 'article' ? 'active' : ''}}" href="/admin/article"> <span class="glyphicon glyphicon-th-list"></span> 文章管理 </a>
刷新页面重新登录,点击左侧菜单,发现切换页面时可以实现选中样式了:
2.7.3、文章管理功能
创建文章集合
在 model 目录下创建 article.js 文件:
// 1.引入 mongoose 第三方模块 const mongoose = require('mongoose'); // 2.创建文章集合规则 const articleSchema = new mongoose.Schema({ title: { type: String, minlength: 4, maxlength: 20, required: [true, '请填写文章标题'] }, author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: [true, '请传递作者'] }, publishDate: { type: Date, default: Date.now }, cover: { type: String, default: null }, content: { type: String, } }); // 3.根据规则创建集合 const Article = mongoose.model('Article', articleSchema); // 4.将文章集合作为模块成员进行导出 module.exports = { Article }
2.8、添加文章功能
2.8.1、发布新文章的按钮添加链接
打开 views-admin 目录下 article.art 文件:
<a href="/admin/article-edit" class="btn btn-primary new">发布新文章</a>
刷新页面,点击发布新文章按钮,成功跳转到文章发布页面。
2.8.2、为发布文件的表单添加请求地址及请求方式
打开 views-admin 目录下 article-edit.art 文件:
<form class="form-container" action="/admin/article-add" method="post">
2.8.3、为每个表单项添加 name 属性
<input name="title" type="text" class="form-control" placeholder="请输入文章标题"> <input name="author" type="text" class="form-control" readonly> <input name="publishDate" type="date" class="form-control"> <input name="cover" type="file"> <textarea name="content" class="form-control" id="editor"></textarea>
注意:当前这个表单和我们之前用的表单是不一样的,当前这个表单涉及到了文件上传。
对于文件上传来说,表单的数据必须以二进制的方式传递到服务器。
怎么把表单的数据变为二进制的形式,表单有一个属性:enctype,作用是对表单数据进行编码。
默认值是:application/x-www-form-urlencoded
将表单数据编码为二进制类型:multipart/form-data
<form class="form-container" action="/admin/article-add" method="post" enctype="multipart/form-data">
2.8.4、 在服务器端建立文件添加的路由
打开 route 目录下 admin.js 文件,添加文件添加的路由:
// 文件添加路由 admin.post('/article-add', require('./admin/article-add'));
在 route-admin 目录下新建 article-add.js 文件:
module.exports = (req, res) => { res.send('ok') }
在浏览器打开发布文章页,点击提交按钮,可以看到:创建添加路由成功
2.8.5、接收客户端传递的参数
我们之前在 app.js 中配置好了 bodyParser 这个第三方模块,有了它就可以通过 req.body 来获取客户端传递过来的 post 请求参数。但是 bodyParser 模块只能处理普通表单传递过来的参数,不能处理客户端传递过来的二进制数据。
项目包含的知识点:formidable 模块
作用:解析表单,支持 get 请求参数,post 请求参数、文件上传。
下载安装:
npm install formidable
示例代码:
// 引入 formidable 模块 const formidable = require('formidable'); // 创建表单解析对象 const form = new formidable.IncomingForm(); // 设置文件上传路径 form.uploadDir = '/my/dir'; // 是否保留表单上传文件的扩展名 form.keepExtensions = false; // 对表单进行解析 form.parse(req, (err, fields, files) => { // fields 存储普通请求参数 // files 存储上传的文件信息 });
打开命令行工具,下载安装 formidable 模块:
npm install formidable
打开 route-admin 目录下 article-edit.js 文件:导入 formidable 模块
// 导入 formidable 第三方模块 const formidable = require('formidable'); const path = require('path'); module.exports = (req, res) => { // 1.创建表单解析对象 const form = new formidable.IncomingForm(); // 2.设置上传文件的存放位置 form.uploadDir = path.join(__dirname, '../', '../', 'public/uploads'); // 3.保留上传文件的扩展名 form.keepExtensions = fase; // 对表单进行解析 form.parse(req, (err, fields, files) => { // err 错误对象:如果表单解析失败,err里面存储错误信息;如果表单解析成功,err将会是null // fields 对象类型,保存普通表单数据 // files 对象类型,保存上传文件相关的数据 res.send(fields) }) // res.send('ok') }
回到浏览器刷新,随便填写一些信息提交,可以看到:
然后改为:
// 对表单进行解析 form.parse(req, (err, fields, files) => { // err 错误对象:如果表单解析失败,err里面存储错误信息;如果表单解析成功,err将会是null // fields 对象类型,保存普通表单数据 // files 对象类型,保存上传文件相关的数据 res.send(files) })
刷新浏览器,重新填写一些信息,并上传文件,然后提交,可以看到:
打开存储路径的 uploads 文件夹,可以看到上传的文件,但是没有后缀
所以上传文件要保存文件的扩展名,修改为 true:
// 3.保留上传文件的扩展名 form.keepExtensions = true;
回到浏览器刷新页面,重新提交后,可以看到有该文件:带后缀的
下面我们需要将当前登录的用户 id 显示在作者这个文本框里。
打开 route 目录下 login.js 文件,可以看到:当用户登录成功后,把用户信息存储在 app.locals 这个对象下的 userInfo 中
// 在 req.app 里拿到的就是 app.js 里的app req.app.locals.userInfo = user;
所以我们可以在文件添加模板页面中,直接拿到 userInfo_id 就可以了。
打开 views-admin 目录下 article-edit.art 文件:
<label>作者</label> <input name="author" type="text" class="form-control" readonly value="{{@userInfo._id}}">
刷新浏览器,可以看到:作者显示为当前登录用户的 id
当用户选择完封面图片后,我们希望这个图片能直接显示在页面中。
项目包含的知识点:文件读取 FileReader
示例代码:
// 创建文件读取对象 var reader = new FileReader(); // 读取二进制文件 readAsDataURL 是异步方法 reader.readAsDataURL('文件'); // reader.onload = function () { console.log(reader.result); }
打开 views-admin 目录下 article-edit.art 文件,给上传按钮控件添加id:
<input name="cover" type="file" id="file">
在页面下面添加 js 代码:
// 选择文件上传控件 var file = document.querySelector('#file'); // 当用户选择完文件以后 file.onchange = function () { // 1. 创建文件读取对象 var reader = new FileReader(); // 用户选择的文件列表 console.log(this.files) // 2. 读取文件 reader.readAsDataURL(); }
刷新浏览器,选择一个图片上传,然后打开控制台可以看到:上传图片的信息
还可以上传多张图片,需要添加 multiple:
<input name="cover" type="file" id="file" multiple>
刷新浏览器,重新提交多张图片,可以看到:
我们这里只上传一张图片就可以了,所以把 multiple 去掉:
<!-- multiple 允许用户一次性选择多个文件上传 --> <input name="cover" type="file" id="file">
由于我们一次只传一个文件,而控制台显示的是文件列表,所以要修改下:
// 用户选择的文件列表 console.log(this.files[0])
然后刷新页面重新上传一个图片,可以在控制台看到选择的图片信息,接下来需要把这个选择的文件,传递给 readAsDataURL() 方法就可以了。
继续编辑代码:
// 当用户选择完文件以后 file.onchange = function () { // 1. 创建文件读取对象 var reader = new FileReader(); // 用户选择的文件列表 // console.log(this.files[0]) // 2. 读取文件 reader.readAsDataURL(this.files[0]); // 3. 监听 onload 事件 reader.onload = function () { console.log(reader.result) } }
刷新浏览器,重新上传图片,可以在控制台中看到:图片的编码
我们只需要把这个图片的编码放在 img 标签的 src 中就可以了。
为了方便获取这个图片编码,我们给 img 标签添加个 id :
<img class="img-thumbnail" src="" id="preview">
编辑 js 代码:
// 3. 监听 onload 事件 reader.onload = function () { // console.log(reader.result) // 将文件读取的结果显示在页面中 preview.src = reader.result }
刷新浏览器,重新上传图片,可以看到:图片显示出来了
总结核心代码:
1.首先用文件选择控件 file,添加 onchange 事件,当 onchange 事件被触发时,就代表用户选择了文件。如果用户选择了文件,我们马上去创建一个文件读取对象,用来帮助我们读取文件。
2.接下来我们调用文件读取对象下面的 readAsDataURL 方法,让它帮我们来读取文件。
3.使用 this.files 来获取用户选择的文件,由于 this.files 是文件列表,而我们当前只选择一个文件,所以就使用 this.files[0] 来选择列表中的第一个文件,然后把要读取的文件给 readAsDataURL 方法。
4.由于 readAsDataURL 方法是异步方法,不同通过返回值的方式来获取结果,所以我们监听文件读取对象的 onload 事件。当文件读取完成以后,这个事件处理函数将会被自动调用。在这个事件处理函数当中,我们通过 reader.result 来获取这个文件读取的结果。
5.最后把这个结果给图片的 src 属性,图片就可以在页面中显示出来了。
2.8.6、 将客户端传递到服务器端的文章插入到数据库中
文章封面图片我们已经上传到了 uploads 文件夹中。那么数据库中的 cover 字段应该存储的是:文件上传路径及文件名称,当客户端想要看到这个图片时,通过数据库中的 cover 字段就可以知道图片的存储位置,从而可以通过这个位置访问到图片。
我们知道上传的文件信息都存储在 files 参数中,在这个参数中就包含了文件的路径。
刷新浏览器页面,重新上传一个图片,然后点击提交,可以看到:cover
path 就是文件的路径,但是这个路径是服务器电脑本机的路径,所以不能将这个路径直接存储到数据库中,我们要进行截取处理。
打开 route-admin 目录下 article-add.js 文件:进行路径的截取
// 4.对表单进行解析 form.parse(req, (err, fields, files) => { // err 错误对象:如果表单解析失败,err里面存储错误信息;如果表单解析成功,err将会是null // fields 对象类型,保存普通表单数据 // files 对象类型,保存上传文件相关的数据 res.send(files.cover.path.split('public')[1]) })
刷新浏览器,重新上传图片点击提交,可以看到:
导入文章集合:
const { Article } = require('../../model/article');
使用文章集合构造函数向文件集合中插入数据:
// 4.对表单进行解析 form.parse(req, async (err, fields, files) => { // err 错误对象:如果表单解析失败,err里面存储错误信息;如果表单解析成功,err将会是null // fields 对象类型,保存普通表单数据 // files 对象类型,保存上传文件相关的数据 // res.send(files.cover.path.split('public')[1]) // 向文件集合中插入数据 await Article.create({ title: fields.title, author: fields.author, publishDate: fields.publishDate, cover: files.cover.path.split('public')[1], content: fields.content }); // 将页面重定向到文章列表页 res.redirect('/admin/article'); })
刷新浏览器,重新填写信息提交,然后跳转到文章列表页。
打开 Compass 软件,可以看到新增加的文章数据:
2.9、文章列表页面的数据展示
2.9.1、访问文章列表页时,在对应的路由处理函数中,将所有的文章信息从数据库总查询出来。
打开 route-admin 目录下 article.js 文件:
// 导入文章集合构造函数 const { Article } = require('../../model/article'); module.exports = async (req, res) => { // 标识,标识当前访问的是文章管理页面 req.app.locals.currentLink = 'article'; // 将文章信息从数据库中查询处理 let articles = await Article.find({}); // 渲染文章列表页面模板 res.render('admin/article.art', { articles: articles }); }
2.9.2、将查询中来的数据渲染到文章列表中
打开 views-admin 目录下 article.art 文件:
{{each articles}} <tr> <td>{{@$value._id}}</td> <td>{{$value.title}}</td> <td>{{$value.publishDate}}</td> <td>{{$value.author}}</td> <td> <a href="article-edit.html" class="glyphicon glyphicon-edit"></a> <i class="glyphicon glyphicon-remove" data-toggle="modal" data-target=".confirm-modal"></i> </td> </tr> {{/each}}
刷新浏览器,可以看到文章列表了:
2.9.3、文章列表中显示作者名称
根据作者的 id 查询出作者的名称,这就需要多集合联合插叙。
打开 route-admin 目录下 article.js 文件:
// 将文章信息从数据库中查询处理 // 联合查询 populate let articles = await Article.find({}).populate('author'); res.send(articles); // 渲染文章列表页面模板 // res.render('admin/article.art', { // articles: articles // });
刷新浏览器,点击文章管理,可以看到:作者的相关数据
然后我们把测试代码注释掉,还原 res.render:
// 将文章信息从数据库中查询处理 // 联合查询 populate let articles = await Article.find({}).populate('author'); // res.send(articles); // 渲染文章列表页面模板 res.render('admin/article.art', { articles: articles });
在 article.art 文件中添加 username:
<td>{{$value.author.username}}</td>
刷新浏览器,可以看到:作者名称
2.9.4、发布时间格式
项目包含的知识点:dateformat 处理时间格式
下载安装:
npm install dateformat
然后重新开启服务:nodemon app.js
修改 app.js 文件,进行全局配置:
// 导入 art-template 模板引擎 const template = require('art-template'); // 引入处理日期的 dateformat 第三方模块 const dateFormat = require('dateformat'); // 向模板内部导入dateFormat变量 处理日期格式的方法 template.defaults.imports.dateFormat = dateFormat;
打开 views-admin 目录下 article.art 文件,添加 dateFormat 变量:
<td>{{dateFormat($value.publishDate, "yyyy-mm-dd")}}</td>
刷新浏览器,点击文章管理,可以看到:
2.10、文章数据分页
项目包含的知识点:mongoose-sex-page 第三方模块
下载安装:
npm install mongoose-sex-page
示例代码:
const pagination = require('mongoose-sex-page');
pagination(集合构造函数).page(1) .size(20) .display(8) .exex();
page: 当前页
size: 每页显示的数据条数
display: 显示的页码数量
exec: 向数据库发出查询请求
查询后返回的结果:
{ "page": 1, // 当前页 "size": 2, // 每页显示数据条数 "total": 8, // 总共的数据条数 "records": [ // 查询出来的具体数据 { "_id": "5c3ef9bb284e4714a4a1cf79", "title": "测试文章" } ], "pages": 4, // 总共的页数 "display": [1, 2, 3, 4] // 客户端显示的页码 }
2.10.1、安装使用第三方模块
在项目命令行工具中输入:
npm install mongoose-sex-page
重新启动项目服务:
nodemon app.js
打开 route-admin 目录下 article.js 文件:引入 mongoose-sex-page 模块
// 引入 mongoose-sex-page 模块 const pagination = require('mongoose-sex-page');
然后修改下代码:
// 将文章信息从数据库中查询处理 // 联合查询 populate // page 指定当前页; size 指定每页显示条数; display 指定客户端显示的页码数量 // exec 向数据库中发送查询请求 let articles = await pagination(Article).find({}).page(1).size(3).display(3).populate('author').exec(); res.send(articles); // 渲染文章列表页面模板 // res.render('admin/article.art', { // articles: articles // });
先测试下,刷新页面点击文章管理,可以看到:查询结果
把 res.send() 注释,把 res.render() 打开。
2.10.2、往模板里传递数据
打开 views-admin 目录下 article.art 文件:
文章总数量改为:
<span>找到{{articles.total}}篇文章</span>
循环数据改为:
{{each articles.records}}
页码部分:
{{each articles.display}} <li><a href="/admin/article?page={{$value}}">{{$value}}</a></li> {{/each}}
刷新页面可以看到页码:
查看源码可以看到页面上的链接地址。
2.10.3、在服务器端接收这个页码
打开 route-admin 目录下 article.js 文件:使用 req.query 来接收参数 page
// 接收客户端传递过来的当前页参数,如果没有传页面,默认为1 const page = req.query.page || 1; // page(1) 改为动态的 page(page) let articles = await pagination(Article).find({}).page(page).size(3).display(3).populate('author').exec();
刷新页面,点击页码 1 2 3 可以实现分页了。
2.10.4、实现上一页,下一页功能
{{if articles.page != 1}} <li> <a href="/admin/article?page={{articles.page - 1}}"> <span>«</span> </a> </li> {{/if}} {{if articles.page < articles.pages}} <li> <a href="/admin/article?page={{articles.page - 0 + 1}}"> <span>»</span> </a> </li> {{/if}}
刷新页面,可以看到:
2.11、文章信息修改
2.11.1、根据 id 查询文章信息,并渲染到表单页
打开 views-admin 目录下 article.art 文件:给每条文章信息添加修改路由地址,并带上该文章的id
<a href="/admin/article-edit?id={{@$value._id}}" class="glyphicon glyphicon-edit"></a>
刷新浏览器,点击修改按钮,可以看到后面带的id参数:
打开 route-admin 目录下 article-edit.js 文件:进行判断
// 获取到地址栏中的 id 参数 const {id} = req.query; if (id) { // 修改文章 } else { // 添加文章 }
如果是修改操作,那么要根据 id 来查询文章信息,所以要引入文章集合:
// 导入文章集合构造函数 const { Article } = require('../../model/article'); module.exports = async (req, res) => { // 标识,标识当前访问的是文章管理页面 req.app.locals.currentLink = 'article'; // 获取到地址栏中的 id 参数 const {id} = req.query; if (id) { // 修改文章 根据id查询文章信息 let article = await Article.findOne({_id: id}) res.render('admin/article-edit.art', { article:article }); } else { // 添加文章 res.render('admin/article-edit.art'); } }
打开 views-admin 目录下 article-edit.art 文件:
<input name="title" value="{{article.title}}" type="text" class="form-control" placeholder="请输入文章标题">
刷新浏览器后点击修改按钮,可以看到文章标题渲染出来了。但是有个问题,点击新增按钮会报错。
因为我们新增文章也使用这个页面,所以要加下判断:
<input name="title" value="{{article && article.title}}" type="text" class="form-control" placeholder="请输入文章标题"> <input name="cover" type="file" id="file"> <div class="thumbnail-waper"> <img class="img-thumbnail" src="{{article && article.cover}}" id="preview"> </div>
这里的发布时间和内容有点问题。
下面是标题,如果是修改操作,那么标题显示该文章的 id ;如果是新增操作,那么就不显示标题。
<h4 style="display: {{article && article._id ? 'block' : 'none'}}">{{@article && article._id}}</h4>
打开 route-admn 目录下 article-edit.js 文件,添加按钮参数:
if (id) { // 修改文章 根据id查询文章信息 let article = await Article.findOne({_id: id}) console.log(article); res.render('admin/article-edit.art', { article:article, button: '修改' }); } else { // 添加文章 res.render('admin/article-edit.art', { button: '添加' }); }
打开 views-admin 目录下 article-edit.art 文件:
<div class="buttons"> <input type="submit" class="btn btn-primary" value="{{button}}"> </div>
在浏览器刷新页面,点击添加或者修改按钮可以看到对应的信息。
由于上面的发布时间和内容有点问题,所以提交修改功能暂时不继续了。后面解决了再继续。
2.12、文章信息删除
2.12.1、在确认删除框中添加隐藏域用以存储要删除用户的ID值
打开 views-admin 目录下 article.art 文件,添加隐藏域:
<p>您确定要删除这篇文章吗?</p> <input type="hidden" name="id">
2.12.2、为删除按钮添自定义属性用以存储要删除用户的ID值
给删除按钮添加自定义属性 data-id :
<i class="glyphicon glyphicon-remove" data-toggle="modal" data-target=".confirm-modal"
data-id="{{@$value._id}}"></i>
刷新页面,查看源码可以看到删除按钮上:data-id 已经有了。
2.13.3、为删除按钮添加点击事件,在点击事件处理函数中获取自定义属性中存储的ID值并将ID值存储在表单的隐藏域中
继续编辑 article.art 文件:
给删除图标添加样式及点击事件:
<i class="glyphicon glyphicon-remove delete" data-toggle="modal" data-target=".confirm-modal"
data-id="{{@$value._id}}"></i> {{block 'script'}} <script type="text/javascript"> $('.delete').on('click', function() { alert(1) }); </script {{/block}}
刷新浏览器,点击删除按钮,可以看到弹出的1,点击确定后,再弹出删除提示框。
下面要获取点击该用户的 id:
{{block 'script'}} <script type="text/javascript"> $('.delete').on('click', function() { // 获取用户 id var id = $(this).attr('data-id'); alert(id) }); </script {{/block}}
刷新浏览器,点击删除按钮,可以看到:说明获取到该文章 id 了
要把获取到的 id 存储到隐藏域中,为了方便获取,给隐藏域添加 id:
<input type="hidden" name="id" id="deleteArticleId"> {{block 'script'}} <script type="text/javascript"> $('.delete').on('click', function() { // 获取用户 id var id = $(this).attr('data-id'); // 将要删除的用户 id 存储在隐藏域 $('#deleteArticleId').val(id); }); </script {{/block}}
刷新页面,点击删除按钮,查看源码,可以看到在隐藏域多了一个 value 属性,存储 id 值
2.12.4、为删除表单添加提交地址以及提交方式
<form class="modal-content" action="/admin/delete-article" method="get">
2.12.5、在服务器端建立删除功能路由
打开 route 目录下 admin.js 文件,新建删除文章的路由:
// 文章删除路由 admin.get('/delete-article', require('./admin/article-delete'));
在 route-admin 目录下新建 article-article.js 文件:
module.exports = (req, res) => { res.send('ok') }
刷新浏览器,点击删除按钮,可以看到ok:说明路由创建成功了
2.12.6、接收客户端传递过来的id参数
module.exports = (req, res) => { // 接收客户端传递的参数 id let id = req.query.id; }
2.12.7、根据id删除用户
引入文章集合,根据文章 id 删除文章:
//引入文章集合 const { Article } = require('../../model/article'); module.exports = async (req, res) => { // 接收客户端传递的参数 id let id = req.query.id; await Article.findOneAndDelete({_id: id}); // 将页面重定向到用户列表页 res.redirect('/admin/article'); }
回到浏览器刷新,点击删除按钮,可以看到:可以看到第一篇文章已经被删除掉了
3、mongoDB 数据库添加帐号
3.1、以系统管理员的方式运行 powershell
3.2、连接数据库 mongo
在命令行工具中连接数据库,输入:
mongo
3.3、查看数据库 show dbs
在命令行工具中输入:
show dbs
可以看到:有哪些数据库
3.4、切换到 admin 数据库:use admin
在命令行工具中输入:
use admin
切换到 admin 数据库:
3.5、创建超级管理员帐号 db.createUser()
在命令行工具中输入:
db.createUser({user: 'root', pwd: 'root', roles:['root']})
db.createUser 方法是用来创建用户的,这个方法它接收一个对象,对象中有一些固定属性,比如说:user代表帐号,pwd代表密码,roles代表角色。roles 属性值接收一个数组,数组里有个 root,代表超级管理员。
超级管理员帐号已经创建好了,接下来是普通帐号。
普通帐号是针对具体数据库的,要对那个数据库创建帐号,就要切换到该数据库。
3.6、切换到blog数据 use blog
在命令行中输入:切换到 blog 数据库
use blog
3.7、创建普通账号 db.createUser()
在命令行工具中输入:
db.createUser({user: 'itjoe', pwd: 'itjoe', roles: ['readWrite']})
readWrite 表示读写权限。
3.8、卸载mongodb服务
在命令工具中输入:
exit
退出数据库环境当中。
3.8.1、停止服务 net stop mongodb
在命令行工具中输入:
net stop mongodb
停止 mongodb
3.8.2、删除卸载服务 mongod --remove
在命令行工具中输入:
mongod --remove
删除卸载服务
3.9、创建mongodb服务
mongod --logpath="D:Program FilesMongoDBServer4.2logmongod.log" --dbpath="D:Program FilesMongoDBServer4.2data" --install –-auth
--logpath 指定mongoDB数据库在运行时有一些日志的输出路径
--dbpath 指定mongoDB数据库的存储路径
--install 安装mongoDB
--auth 开启验证,要登录后才能对数据库进行操作
在命令行工具中输入:
mongod --logpath="D:Program FilesMongoDBServer4.2logmongod.log" --dbpath="D:Program FilesMongoDBServer4.2data" --install –-auth
安装成功
3.10、启动mongodb服务 net start mongodb
在命令行工具中输入:
net start mongoDB
启动成功
在命令行工具中输入:
nodemon app.js
刷新浏览器,在登录页输入邮件和密码后,点击登录没反应。 这说明我们设置的权限是成功的。所以我们在连接数据库的时候需要使用帐号和密码登录,登录成功后才能对数据库进行操作。
3.11、在项目中使用账号连接数据库
mongoose.connect('mongodb://user:pass@localhost:port/database')
打开 model 目录下 connect.js 文件:
// 连接数据库 mongoose.connect('mongodb://itjoe:itjoe@localhost:27017/blog', { useNewUrlParser: true, useUnifiedTopology: true}) .then(() => console.log('数据库连接成功')) .catch(() => console.log('数据库连接失败'))
刷新浏览器,重新登录,发现可以了。
4、开发环境与生产环境
什么是开发环境与生产环境:
环境,就是指项目运行的地方,当项目处于开发阶段,项目运行在开发人员的电脑上,项目所处的环境就是开发环境。当项目开发完成以后,要将项目放到真实的网站服务器的电脑中运行,项目所处的环境就是生产环境。
为什么要区分开发环境与生产环境:
因为在不同的环境中,项目的配置是不一样的,需要在项目代码中判断当前项目运行的环境,根据不同的环境应用不同的项目配置。
如何区分开发环境与生产环境:
通过电脑操作系统中的系统环境变量区分当前是开发环境还是生成环境。
4.1、设置系统环境变量
打开环境变量,点击新建,输入:
然后点击确认。
重新启动命令行工具,在当前项目的目录下,输入:nodemon app.js 启动项目
打开 app.js 文件,获取环境变量:
// 获取系统环境变量,返回值是对象 console.log(process.env);
切换到命令行工具中,可以看到:
这个就是刚才设置的环境变量。
所以用 process.env.NODE_ENV 就可以获取到 development ,然后进行判断。
4.2、在代码中获取环境变量的值,并对其进行判断
修改 app.js 代码:
// 获取系统环境变量,返回值是对象 if (process.env.NODE_ENV == 'development') { // 当前是开发环境 console.log('当前是开发环境'); } else { // 当前是生产环境 console.log('当前是生产环境'); }
切换到命令行工具中可以看到:
再打开环境变量,把 NODE_ENV 改为:production
再重新启动命令行工具,在当前项目目录下,输入:nodemon app.js,可以看到:
这样就可以实现在不同的运行环境中,做不同的事情。比如说在开发环境中,我们可以将客户端向服务器端发送的请求信息打印到控制台,方便开发人员观察请求信息。
把环境变量修改回:development
需要用到 morgan 第三方模块。也是 express 的中间件函数。
下载安装:
npm install morgan
打开 app.js 文件,引入 morgan 模块:
// 导入 morgan 第三方模块 const morgan = require('morgan'); // 获取系统环境变量,返回值是对象 if (process.env.NODE_ENV == 'development') { // 当前是开发环境 console.log('当前是开发环境'); // 在开发环境中,将客户端发送到服务器端的请求信息打印到控制台中 app.use(morgan('dev')); } else { // 当前是生产环境 console.log('当前是生产环境'); }
回到浏览器刷新登录页面,在命令行工具中可以看到:
GET: 请求方式
/admin/login: 请求地址
304: HTTP状态码
1.453ms: 请求时间
回到浏览器点击登录,可以在命令行工具中看到:有2个请求信息
注意:打印是在开发环境中,生产环境中不打印。
5、第三方模块 config
作用:允许开发人员将不同运行环境下的应用配置信息抽离到单独的文件中,模块内部自动判断当前应用的运行环境,
并读取对应的配置信息,极大提供应用配置信息的维护成本,避免了当运行环境重复的多次切换时,手动到项目代码
中修改配置信息。
步骤:
1. 使用npm install config命令下载模块
在命令行工具中输入:
npm install config
2. 在项目的根目录下新建config文件夹
3 在config文件夹下面新建default.json、development.json、production.json文件
新建 default.json 文件:
{ "title": "博客管理系统" }
新建 development.json 文件:
{ "title": "博客管理系统----开发环境" }
新建 production.json 文件:
{ "title": "博客管理系统----生产环境" }
现在已经分别在三个 json 文件中存储了 title 属性,如果当前是开发环境,会读取 development.json 文件中的 title 属性;如果是生产环境,会读取 production.json 文件中的 title 属性;如果以上2个文件中都没有找到 title 属性,那么会读取 default.json 文件中的 title 属性。
4. 在项目中通过require方法,将模块进行导入
打开 app.js 文件:导入 config 模块
// 导入 config 模块 const config = require('config');
5. 使用模块内部提供的 get 方法获取配置信息
// config.get 获取 title 属性 console.log(config.get('title'))
在命令行工具中输入:node app.js ,可以看到:
接下来我们把环境变量中的 NODE_ENV 改为: production,再重启命令行工具,输入:node app.js,可以看到提示是:生产环境。
下面我们把 development.json文件、production.json 文件中的 title 属性删除掉,重新运行:node app.js,可以看到:
说明读取的是 default.json 文件中的 title 属性。
打开 development.json 文件,添加代码:
{ "db": { "user": "itjoe", "pwd": "itjoe", "host": "locahost", "prot": "27017", "name": "blog" } }
打开 model 目录下 connect.js 文件:获取配置文件中的配置信息
// 导入 config 模块 const config = require('config'); // 连接数据库 mongoose.connect(`mongodb://${config.get('db.user')}:${config.get('db.pwd')}@${config.get('db.host')}:${config.get('db.prot')}/${config.get('db.name')}`, { useNewUrlParser: true, useUnifiedTopology: true}) .then(() => console.log('数据库连接成功')) .catch(() => console.log('数据库连接失败'))
切换到命令行工具中,输入:nodemon app.js, 可以看到:
刷新浏览器,重新登录,可以登录成功,数据也能渲染出来。
当我们将项目代码分享给别人的时候,由于一些敏感的配置信息是直接写在文件中的,比如数据库的连接信息。当别人看到这个信息时,就可以连接我们的数据库,这么我们的数据信息就不安全了。
将敏感配置信息存储在环境变量中
1. 在 config 文件夹中建立 custom-environment-variables.json 文件 (注意名字是固定的)
在 config 目录下新建 custom-environment-variables.json 文件:
{ "db": { "pwd": "APP_PASSWORD" } }
然后把 development.json 文件中的 pwd 属性删除掉:
{ "db": { "user": "itjoe", "host": "localhost", "port": "27017", "name": "blog" } }
2. 配置项属性的值填写系统环境变量的名字
打开环境变量,点击新建,输入
记得要重启命令行工具,然后输入:nodemon app.js ,可以看到一切正常
刷新浏览器,登录操作也正常,说明没有问题。
这个数据库密码是没有写在文件中的,它存储在系统的环境变量中,这样即使把代码分享给别人,他也是看不到我们的数据库密码的,保证了数据库的安全。
6、前台展示页面
前端展示页面就首页和文章详情页面。
6.1、在服务器端增加这两个页面对应的请求路由
当客户端向服务器端发送页面请求的时候,我们需要在请求路由的处理函数中,将对应的模板响应到客户端。
6.1.1、建立前台展示页面所对应的请求地址(首页和文章详情)
打开 route 目录下 home.js 文件:发现已经有首页,在增加详情页
// 博客前台首页展示页面 home.get('/', (req, res) => { res.send('欢迎来到博客首页'); }); // 博客前台文章详情展示页面 home.get('/article', (req, res) => { res.send('欢迎来到博客文章详情页'); });
回到浏览器中,输入:http://localhost/home/ 及 http://localhost/home/article ,可以看到:
在 route 目录下新建 home 文件夹,并新建 index.js 文件:
module.exports = (req, res) => { res.send('欢迎来到博客首页'); }
新建 article.js 文件:
module.exports = (req, res) => { res.send('欢迎来到博客文章详情页'); }
回到 home.js 文件中导入:
// 博客前台首页展示页面 home.get('/', require('./home/index')); // 博客前台文章详情展示页面 home.get('/article', require('./home/article'));
6.1.2、在路由请求处理函数中渲染其对应的模板文件
把 public-home 目录下的 article.html 和 default.html ,复制粘贴到 views-home 目录下,并把后缀改为.art。
打开 route-home 目录下 index.js 文件:渲染首页模板
module.exports = (req, res) => { // res.send('欢迎来到博客首页'); res.render('home/default.art'); }
刷新浏览器可以看到:首页
打开 route-home 目录下 article.js 文件:渲染文章详情页模板
module.exports = (req, res) => { // res.send('欢迎来到博客文章详情页'); res.render('home/article.art'); }
打开浏览器,文章详情页
6.2、将模板中的公共部分抽离出来
抽离公共部分:包括模板骨架及页面中公共区域,并修改模板中的外链资源地址改为绝对路径。
6.2.1、把模板中的外链改为绝对路径
打开 views-home 目录下 default.art 文件:改为
<link rel="stylesheet" href="/home/css/base.css"> <link rel="stylesheet" href="/home/css/index.css">
打开 views-home 目录下 article.art 文件:改为
<link rel="stylesheet" href="/home/css/base.css"> <link rel="stylesheet" href="/home/css/article.css">
回到浏览器刷新页面,都没有问题。
6.2.2、抽离模板中的骨架
在 views-home 目录下新建 common 文件夹,并新建 layout.art 文件:存放抽离的骨架代码
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <link rel="stylesheet" href="/home/css/base.css"> {{block 'link'}} {{/block}} </head> <body> {{block 'main'}} {{/block}} {{block 'script'}} {{/block}} </body> </html>
打开 default.art 文件,继承骨架:
{{extend './common/layout.art'}} {{block 'link'}} <link rel="stylesheet" href="/home/css/index.css"> {{/block}} {{block 'main'}} 、、、、、 {{/block}}
打开 article.art 文件,继承骨架:
{{extend './common/layout.art'}} {{block 'link'}} <link rel="stylesheet" href="/home/css/article.css"> {{/block}} {{block 'main'}} 、、、、、、 {{/block}}
回到浏览器刷新页面,发现没有问题。
6.2.3、抽离模板中的公共部分
在 views-home-common 目录下新建 header.art 文件:存放公共头部代码
<!-- 头部框架开始 --> <div class="header"> <div class="w1100"> <!-- 网站logo开始 --> <h1 class="logo fl"> <a href="default.html"><img src="images/logo.png" alt="黑马程序员"></a> </h1> <!-- 网站logo结束 --> <!-- 网站导航开始 --> <ul class="navigation fr"> <li> <a class="active" href="index.html">首页</a> </li> <li> <a href="#">登录</a> </li> </ul> <!-- 网站导航结束 --> </div> </div> <!-- 头部框架结束 -->
打开 default.art 文件引用包含头部文件:
{{extend './common/layout.art'}} {{block 'link'}} <link rel="stylesheet" href="/home/css/index.css"> {{/block}} {{block 'main'}} {{include './common/header.art'}}
同样打开 article.art 文件引用包含头部文件:
{{extend './common/layout.art'}} {{block 'link'}} <link rel="stylesheet" href="/home/css/article.css"> {{/block}} {{block 'main'}} {{include './common/header.art'}}
回到浏览器刷新页面,发现没有问题。
6.3、查询文章数据并展示到页面
6.3.1、当用户访问博客首页的时候,我们需要在博客首页对于的路由请求处理函数中,将文章数据从文章集合中查询出来,并展示在页面上。
打开 route-home 目录下 index.js 文件:导入文章集合构造函数
const { Article } = require('../../model/article'); module.exports = async (req, res) => { let result = await Article.find({}); // 测试下在浏览器显示 res.send(result); return; // res.send('欢迎来到博客首页'); res.render('home/default.art'); }
刷新浏览器可以看到:
可以看到文章数据已经查询出来了,但是 author 这个字段存储的是作者 id ,我们要进一步进行查询操作。
6.3.2、多集合联合查询: populate
const { Article } = require('../../model/article'); module.exports = async (req, res) => { // 联合查询 populate let result = await Article.find({}).populate('author'); res.send(result); return; // res.send('欢迎来到博客首页'); res.render('home/default.art'); }
刷新浏览器可以看到:
6.3.3、实现分页数据:通过 mongoose-sex-page 第三方模块
引入 mongoose-sex-page 第三方模块:
const { Article } = require('../../model/article'); // 引入 mongoose-sex-page 模块 const pagination = require('mongoose-sex-page'); module.exports = async (req, res) => { // 联合查询 populate // page 指定当前页; size 指定每页显示条数; display 指定客户端显示的页码数量 // exec 向数据库中发送查询请求 let result = await pagination(Article).find({}).page(1).size(4).display(5).populate('author').exec(); res.send(result); return; // res.send('欢迎来到博客首页'); res.render('home/default.art'); }
刷新浏览器可以看到:
现在数据已经查询出来了,下面我们需要把查询出来的数据传递到模板中,并展示到页面上。
6.3.4、展示查询出的数据
res.render('home/default.art', {
result:result
});
打开 views-home 目录下 default.art 文件:
发现循环 li 的样式名称有 fl 和 fr,所以我们要进行下判断:
{{each result.records}} <li class="{{ $index % 2 == 0 ? 'fl' : 'fr'}}"> 、、、 </li> {{/each}}
接下来展示数据:
{{each result.records}} <li class="{{ $index % 2 == 0 ? 'fl' : 'fr'}}"> <a href="article.html" class="thumbnail"> <img src="{{$value.cover}}"> </a> <div class="content"> <a class="article-title" href="article.html">{{$value.title}}</a> <div class="article-info"> <span class="author">{{$value.author.username}}</span> <span>{{$value.publishDate}}</span> </div> <div class="brief"> {{$value.content}} </div> </div> </li> {{/each}}
刷新浏览器可以看到渲染出来的数据:
6.3.5、转换数据格式
发布时间:直接调用 dateFormat 方法
<span>{{dateFormat($value.publishDate, "yyyy-mm-dd")}}</span>
刷新页面可以看到时间格式已经变了:
文章简介:实际上显示的是文章的内容,所以在这里我们要进行处理
1、去除内容中所有的 HTML 标签
使用 replace 方法对字符串进行替换
<div class="brief"> {{$value.content.replace(/<[^>]+>/g, '')}} </div>
^表示:除了; ^>表示:除了>; +表示:出现1次到多次; /g表示:全局匹配
刷新浏览器可以看到:
2、对内容进行截取
使用substr 方法对字符串进行截取,第一个参数是开始位置,第二个参数是截取的字符数量。
因为当前文章内容是一些测试数据,但是当文章内容很多的时候,li 是不是就被撑大了,所以我们要对文章内容进行截取。
<div class="brief"> {{$value.content.replace(/<[^>]+>/g, '').substr(0, 100) + '...'}} </div>
0表示:开始截取的位置; 100表示:截取的字符数量; ...表示:省略
刷新浏览器可以看到:
还有个问题当有空格的时候显示 &nlsp; 我们需要原文输出
原文输出:
<div class="brief"> {{@$value.content.replace(/<[^>]+>/g, '').substr(0, 100) + '...'}} </div>
现在内容的格式就都处理好了。
6.4、文章列表下面的页码
6.4.1、将页码显示在页面中
打开 views-home 目录下 default.art 文件:循环页码
<div class="page w1100"> <a href="#">上一页</a> {{each result.display}} <a href="#">{{$value}}</a> {{/each}} <a href="#">下一页</a> </div>
刷新浏览器可以看到:页码显示出来了
6.4.2、给 a 标签添加连接
<a href="/home/?page={{$value}}" >{{$value}}</a>
打开 route-home 目录下 index.js 文件:接收客户端传递过来的当前页参数
const { Article } = require('../../model/article'); // 引入 mongoose-sex-page 模块 const pagination = require('mongoose-sex-page'); module.exports = async (req, res) => { // 接收客户端传递过来的当前页参数,如果没有传页码,默认为1 const page = req.query.page || 1; // 联合查询 populate // page 指定当前页; size 指定每页显示条数; display 指定客户端显示的页码数量 // exec 向数据库中发送查询请求 let result = await pagination(Article).find({}).page(page).size(4).display(5).populate('author').exec(); // res.send('欢迎来到博客首页'); res.render('home/default.art', { result:result }); }
刷新浏览器,点击分页连接,已经可以实现分页功能:
6.4.3、上一页 与下一页
{{if result.page != 1}} <a href="/home/?page={{result.page - 1}}">上一页</a> {{/if}} {{if result.page < result.pages}} <a href="/home/?page={{result.page - 0 + 1}}">下一页</a> {{/if}}
刷新浏览器,可以实现上一页,下一页功能。当前页是第一页时隐藏“上一页”,同样最后一页时隐藏“下一页”。
6.4.4、当前页高亮状态样式
当前页的页码添加高亮样式选中状态:
{{each result.display}} <a href="/home/?page={{$value}}" class="{{result.page == $value ? 'active' : ''}}">{{$value}}</a> {{/each}}
刷新浏览器可以看到:当前页的高亮选中状态
6.5、点击列表中的文章进入详情页
6.5.1、给文章添加连接 跳转到详情页
在跳转的过程中,把当前文章的 id 通过 get 参数的方式,传递到服务器端;在服务器端接收这个 id,然后通过 id 把文章的详情信息查询出来;再通过 res.render 方法渲染到模板,并把文章详情信息传递到模板,最后通过模板语法展示出来。
打开 views-home 目录下 default.art 文件:添加跳转文章详情页连接
<a href="/home/article?id={{@$value._id}}" class="thumbnail"> <img src="{{$value.cover}}"> </a> <div class="content"> <a class="article-title" href="/home/article?id={{@$value._id}}">{{$value.title}}</a> <div class="article-info"> <span class="author">{{$value.author.username}}</span> <span>{{dateFormat($value.publishDate, "yyyy-mm-dd")}}</span> </div> <div class="brief"> {{@$value.content.replace(/<[^>]+>/g, '').substr(0, 100) + '...'}} </div> </div>
刷新浏览器,随便点击一个文章,可以看到跳转到了文章详情页,并携带id值:
6.5.2、接收客户端传递过来的 id
打开 route-home 目录下 article.js 文件:
// 接收客户端传递过来的文章 id const id = req.query.id;
6.5.3、根据 id 查询文章详细信息
引入文章集合的构造函数:
const { Article } = require('../../model/article'); module.exports = async (req, res) => { // 接收客户端传递过来的文章 id const id = req.query.id; // 根据 id 查询文章详细信息 const article = await Article.findOne({_id: id}); res.send(article); return; res.render('home/article.art'); }
刷新浏览器可以看到查询出来的文章信息:
6.5.4、联合查询作者信息
// 联合查询 populate const article = await Article.findOne({_id: id}).populate('author');
刷新浏览器可以看到:
6.5.5、把查询出来的信息传递给客户端
const { Article } = require('../../model/article'); module.exports = async (req, res) => { // 接收客户端传递过来的文章 id const id = req.query.id; // 联合查询 populate const article = await Article.findOne({_id: id}).populate('author'); res.render('home/article.art', { article: article }); }
6.5.6、渲染文章信息
打开 views-home 目录下 article.art 文件:
<div class="article-header"> <h3 class="article-title">{{article.title}}</h3> <div class="article-info"> <span class="author">{{article.author.username}}</span> <span>{{dateFormat(article.publishDate, 'yyyy-mm-dd')}}</span> </div> </div> <div class="article-content"> {{article.content}} </div>
回到浏览器刷新页面可以看到:
文章内容带有 html 标签,所以要修改下:进行原文输出
<div class="article-content"> {{@article.content}} </div>
刷新浏览器:文章内容正常了
返回到文章列表页,点击其他文章,也可以进入对应的详情页了。
6.6、文章评论
注意:文章评论不是存储在文章集合,所以我们要创建评论集合。
6.6.1、创建评论集合
在 model 目录下新建 comment.js 文件:引入 mongoose 第三方模块,并创建评论集合
// 1.引入 mongoose 第三方模块 const mongoose = require('mongoose'); // 2.创建评论集合规则 const commentSchema = new mongoose.Schema({ // 文章 id aid: { type: mongoose.Schema.Types.ObjectId, ref: 'Article' }, // 用户 id uid: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, // 评论时间 time: { type: Date }, // 评论内容 content: { type: String, } }); // 3.根据规则创建集合 const Comment = mongoose.model('Comment', commentSchema); // 4.将评论集合构造函数作为模块成员进行导出 module.exports = { Comment }
6.6.2、判断用户是否登录,如果用户登录成功,再允许用户提交评论表单
由于评论功能需要登录,所以我们需要在用户登录成功后判断当前登录用户的角色,如果当前登录的用户是超级管理员,就跳到博客后台管理系统;如果是当前登录的是普通用户,就跳到博客首页。
打开 route-admin 目录下 login.js 文件:登录成功后添加角色判断
if (isValid) { // 登录成功 // 将用户名存储在请求对象中 req.session.username = user.username // res.send('登录成功'); // 在 req.app 里拿到的就是 app.js 里的app req.app.locals.userInfo = user; if (user.role == 'admin') { // 重定向到用户列表页 res.redirect('/admin/user'); } // 重定向到博客首页 res.redirect('/home/'); }
刷新浏览器,可以实现超级管理员登录成功后跳转到后台管理系统,普通用户登录成功后跳转到博客首页。
但是还有个问题,当普通用户登录成功跳转到博客首页后,在浏览器中输入:http://localhost/admin/user,还是一样可以访问到管理后台系统,实际上这个是不允许的。所以我们还需要修改下登录拦截:
打开 route-admin 目录下 login.js 文件:把用户的角色也存储在 session 对象中
// 将用户角色存储在 session 对象中 req.session.role = user.role;
打开 app.js 文件:可以看到
// 拦截请求,判断用户登录状态 app.use('/admin', require('./middleware/loginGuard'));
打开 middleware 目录下 loginGuard.js 文件:添加登录用户角色判断
const guard = (req, res, next) => { // 判断用户访问的是否是登录页面 // 判断用户的登录状态 // 如果用户是登录的,将请求放行,向下执行;如果用户不是登录的,则将请求重定向到登录页 if (req.url != '/login' && !req.session.username) { // 重定向到登录页 res.redirect('/admin/login'); } else { // 判断当前登录的是普通用户 if (req.session.role == 'normal') { // 重定向到博客首页 return res.redirect('/home/'); } // 用户是登录的,将请求放行,向下执行 next() } } module.exports = guard;
刷新页面,登录一个普通会员账号,成功后在浏览器输入:http://localhost/admin/user,发现还是博客首页。说嘛拦截器成功了。
当用户没有登录的时候,评论表单我们让它隐藏掉。取而代之的是用户的提示,提示用户如果要评论,需要进行登录操作。
在模板文件中判断用户是否登录
因为我们在登录成功后已经把用户信息存储到了 session,所以我们在模板中可以直接判断 userInfo 是否存在。
打开 views-home 目录下 article.art 文件:进行判断用户是否登录
{{if userInfo}} <h4>评论</h4> <form class="comment-form"> <textarea class="comment"></textarea> <div class="items"> <input type="submit" value="提交"> </div> </form> {{else}} <div><h2>请先进行登录,再对文章进行评论</h2></div> {{/if}}
刷新浏览器,可以看到用户没登陆时的页面,和用户登录后的页面:
已经实现我们想要的功能了。
但是这里还有个问题,就是当我们推出登录以后,刷新文章详情页面,发现评论表单还是存在的,并没有被隐藏掉。因为 userInfo 是存储在 req.app.locals 中,当用户退出以后,我们仅仅是删除了服务器端的筛选和客户端的 cookie,并没有对 userInfo 对象进行清空处理。
当用户退出时,将 userInfo 置空:
打开 route-admin 目录下 logout.js 文件:
module.exports = (req, res) => { // 删除 session req.session.destroy(function () { // 删除 cookie res.clearCookie('connect.sid'); // 重定向到登陆页面 res.redirect('/admin/login'); // 清除模板中的用户信息 req.app.locals.userInfo = null; }) }
刷新浏览器,重新登录,刷新页面显示评论表单;再退出登录,刷新页面,发现评论表单已经隐藏了。
实现评论添加功能:为评论表单添加请求地址及请求方式;为表单控件添加 name 属性,方便服务器端接收表单的请求参数;还要在评论表单中添加2个隐藏域,分别存储文章 id 及用户 id;在服务器端添加评论请求地址对应的路由,在路由请求处理函数中,接收客户端传递过来的评论信息,将评论信息存储在数据库中。
打开 views-home 目录下 article.art 文件:
<form class="comment-form" action="/home/comment" method="post"> <textarea class="comment" name="content"></textarea> <input type="hidden" name="uid" value="{{@userInfo._id}}"> <input type="hidden" name="aid" value="{{@article._id}}"> <div class="items"> <input type="submit" value="提交"> </div> </form>
刷新页面查看源码可以看到:
6.6.3、在服务器端创建文章评论功能对应的路由
打开 route 目录下 home.js 文件:创建评论功能路由
// 评论功能路由 home.post('/comment', require('./home/comment'));
在 route-home 目录下新建 comment.js 文件:
module.exports = (req, res) => { res.send('评论功能') };
刷新浏览器,随便输入一些评论信息,点击提交,可以看到:说明路由创建成功了
6.6.4、在路由请求处理函数中,接收客户端传递过来的评论信息
通过 req.body 可以接收客户端传递过来的信息。
module.exports = (req, res) => {
res.send(req.body)
};
刷新浏览器,可以看到传递过来的信息:
6.6.5、将评论信息存储在评论集合中
当前对象中只有3个属性信息,但是我们在创建评论规则的时候还有个评论时间属性,所以需要进行添加:
// 导入评论集合构造函数 const { Comment } = require('../../model/comment'); module.exports = async (req, res) => { // 接收客户端传递过来的请求参数 const { content, aid, uid } = req.body; // 将评论信息存储到评论集合中 await Comment.create({ content: content, aid: aid, uid: uid, time: new Date() }); };
6.6.6、将页面重定向回文章详情页面
// 导入评论集合构造函数 const { Comment } = require('../../model/comment'); module.exports = async (req, res) => { // 接收客户端传递过来的请求参数 const { content, aid, uid } = req.body; // 将评论信息存储到评论集合中 await Comment.create({ content: content, aid: aid, uid: uid, time: new Date() }); // 重定向到文章详情页面 res.redirect('/home/article?id=' + aid); };
刷新浏览器,输入评论信息,点击提交,可以看到还跳回到了该文章详情页。
打开 Compass 软件,可以看到刚添加的评论信息:
6.6.7、在文章详情页面路由中,获取文章评论信息,并展示在页面中
打开 route-home 目录下 article.js 文件:导入评论集合构造函数
// 导入文章集合构造函数 const { Article } = require('../../model/article'); // 导入评论集合构造函数 const { Comment } = require('../../model/comment'); module.exports = async (req, res) => { // 接收客户端传递过来的文章 id const id = req.query.id; // 联合查询 populate const article = await Article.findOne({_id: id}).populate('author'); // 查询当前文章所对应的评论信息 const comment = await Comment.find({aid: id}).populate('uid'); res.send(comment); return; res.render('home/article.art', { article: article, }); }
刷新浏览器,可以看到:
然后传递给客户端模板页面:
// 导入文章集合构造函数 const { Article } = require('../../model/article'); // 导入评论集合构造函数 const { Comment } = require('../../model/comment'); module.exports = async (req, res) => { // 接收客户端传递过来的文章 id const id = req.query.id; // 联合查询 populate const article = await Article.findOne({_id: id}).populate('author'); // 查询当前文章所对应的评论信息 const comments = await Comment.find({aid: id}).populate('uid'); res.render('home/article.art', { article, comments }); }
打开 views-home 目录下 article.art 文件:添加循环
<div class="comment-list"> {{each comments}} <div class="mb10"> <div class="article-info"> <span class="author">{{$value.uid.username}}</span> <span>{{dateFormat($value.time, 'yyyy-mm-dd')}}</span> <span>{{$value.uid.email}}</span> </div> <div class="comment-content"> {{$value.content}} </div> </div> {{/each}} </div>
刷新页面可以看到:
我们换个用户登录,再添加一条评论信息,可以看到:
现在评论功能就完成了。
6.7、头部导航区域
打开 views-home-comm 目录下 header.art 文件:
<ul class="navigation fr"> <li> <a class="active" href="/home/">首页</a> </li> {{if userInfo}} <li> <a>{{ userInfo && userInfo.username }}</a> </li> <li> <a href="/admin/logout">退出</a> </li> {{else}} <li> <a href="/admin/login">登录</a> </li> {{/if}} </ul>
刷新浏览器可以看到:
此时博客项目的功能基本都实现了。