Vue2 + Koa2 实现后台管理系统
看了些 koa2 与 Vue2 的资料,模仿着做了一个基本的后台管理系统,包括增、删、改、查与图片上传。
工程目录:
由于 koa2 用到了 async await 语法,所以 node 的版本需要至少 v7.6.0,我目前用的是 v7.9.0
1. 根据 package.json 安装好依赖:
{ "name": "vue2.x-koa2.x", "version": "1.0.0", "description": "A Vue.js and Koa project", "author": "caihuaguang@aixuedai.com <caihuaguang@aixuedai.com>", "private": true, "scripts": { "server": "node app.js", "dev": "node build/dev-server.js", "build": "node build/build.js" }, "dependencies": { "axios": "^0.15.3", "bcryptjs": "^2.4.0", "busboy": "^0.2.14", "element-ui": "^1.2.7", "koa": "^2.2.0", "koa-bodyparser": "^4.2.0", "koa-history-api-fallback": "^0.1.3", "koa-jwt": "^1.3.1", "koa-logger": "^2.0.1", "koa-router": "^5.4.0", "koa-static": "^3.0.0", "mysql": "^2.12.0", "sequelize": "^3.30.4", "stylus": "^0.54.5", "stylus-loader": "^2.4.0", "vue": "^2.2.6", "vue-router": "^2.3.0", "vuex": "^2.2.1" }, "devDependencies": { "autoprefixer": "^6.4.0", "babel-core": "^6.24.0", "babel-loader": "^6.4.1", "babel-plugin-transform-runtime": "^6.23.0", "babel-preset-es2015": "^6.24.0", "babel-preset-stage-0": "^6.22.0", "babel-register": "^6.24.0", "chalk": "^1.1.3", "connect-history-api-fallback": "^1.1.0", "css-loader": "^0.25.0", "eventsource-polyfill": "^0.9.6", "express": "^4.13.3", "extract-text-webpack-plugin": "^1.0.1", "file-loader": "^0.9.0", "friendly-errors-webpack-plugin": "^1.1.2", "function-bind": "^1.0.2", "html-webpack-plugin": "^2.8.1", "http-proxy-middleware": "^0.17.2", "json-loader": "^0.5.4", "opn": "^4.0.2", "ora": "^0.3.0", "semver": "^5.3.0", "shelljs": "^0.7.4", "url-loader": "^0.5.7", "vue-loader": "^10.0.0", "vue-style-loader": "^1.0.0", "vue-template-compiler": "^2.1.0", "webpack": "^1.9.11", "webpack-dev-middleware": "^1.8.3", "webpack-hot-middleware": "^2.12.2", "webpack-merge": "^0.14.1" }, "engines": { "node": ">= 4.0.0", "npm": ">= 3.0.0" } }
2. mysql 数据库:可以单独安装,也可以用集成工具 WampServer,我用的是后者。
数据库用可视化工具 HeidiSQL 来操作。
建一个数据库 my_test_db,字符集选择 utf8_unicode_ci(之前没注意的时候,直接用了服务器默认的 latin1_swedish_ci,导致数据库不能保存中文)
my_test_db 中新建两张表 user(用户) 与 goods(商品)
user 表相关字段:
goods 表相关字段:
可以先手动输入一些数据。
3. 将数据库的表结构导出到 schema 文件夹
sequelize-auto -o "./schema" -d my_test_db -h 127.0.0.1 -u root -p 3306 -x 123456 -e mysql
其中,root是数据库用户名,123456是密码。sequelize-auto 具体用法参考这里。
成功后会生成两个文件 user.js 与 goods.js
module.exports = function(sequelize, DataTypes) { return sequelize.define('user', { id: { type: DataTypes.INTEGER(11), allowNull: false, primaryKey: true, autoIncrement: true }, user_name: { type: DataTypes.CHAR(50), allowNull: false }, password: { type: DataTypes.CHAR(128), allowNull: false } }, { tableName: 'user' }); };
module.exports = function(sequelize, DataTypes) { return sequelize.define('goods', { id: { type: DataTypes.INTEGER(11), allowNull: false, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.CHAR(50), allowNull: false }, description: { type: DataTypes.CHAR(200), allowNull: true }, img_url: { type: DataTypes.STRING, allowNull: true } }, { tableName: 'goods' }); };
4. 连接数据库
在 server/config 目录添加 db.js,内容如下:
const Sequelize = require('sequelize'); // 使用 url 形式连接数据库 const theDb = new Sequelize('mysql://root:123456@localhost/my_test_db', { define: { timestamps: false // 取消Sequelzie自动给数据表添加的 createdAt 和 updatedAt 两个时间戳字段 } }) module.exports = { theDb }
sequelize 具体用法查看这里。
(一)用户
5. 操作数据库
在 server/models 目录添加 user.js
const theDatabase = require('../config/db.js').theDb; const userSchema = theDatabase.import('../schema/user.js'); // 通过用户名查找 const getUserByName = async function(name) { const userInfo = await userSchema.findOne({ where: { user_name: name } }) return userInfo } // 通过用户 id 查找 const getUserById = async function(id) { const userInfo = await userSchema.findOne({ where: { id: id } }); return userInfo } const getUserList = async function() { return await userSchema.findAndCount(); // findAndCount() 用 get 路由访问,会得到 204 状态:无数据返回。改用 post 就行 } module.exports = { getUserByName, getUserById, getUserList }
findOne 与 findAndCount 都可以查询数据库,其本质是对 select 语句的封装
6. 服务端具体业务代码
在 server/controllers 目录添加 user.js
1 const userModel = require('../models/user.js'); 2 const jwt = require('koa-jwt'); 3 const bcrypt = require('bcryptjs'); 4 5 const postUserAuth = async function() { 6 const data = this.request.body; // 用 post 传过来的数据存放于 request.body 7 const userInfo = await userModel.getUserByName(data.name); 8 9 if (userInfo != null) { // 如果查无此用户会返回 null 10 if (userInfo.password != data.password) { 11 if (!bcrypt.compareSync(data.password, userInfo.password)) { 12 this.body = { // 返回给前端的数据 13 success: false, 14 info: '密码错误!' 15 } 16 } 17 } else { // 密码正确 18 const userToken = { 19 id: userInfo.id, 20 name: userInfo.user_name, 21 originExp: Date.now() + 60 * 60 * 1000, // 设置过期时间(毫秒)为 1 小时 22 } 23 const secret = 'vue-koa-demo'; // 指定密钥,这是之后用来判断 token 合法性的标志 24 const token = jwt.sign(userToken, secret); // 签发 token 25 this.body = { 26 success: true, 27 token: token 28 } 29 } 30 } else { 31 this.body = { 32 success: false, 33 info: '用户不存在!' 34 } 35 } 36 } 37 38 const getUserInfo = async function() { 39 const id = this.params.id; // 获取 url 里传过来的参数里的 id 40 const result = await userModel.getUserById(id); 41 this.body = result 42 } 43 44 const getUserList = async function() { 45 const result = await userModel.getUserList(); 46 47 this.body = { 48 success: true, 49 total: result.count, 50 list: result.rows, 51 msg: '获取用户列表成功!' 52 } 53 } 54 55 module.exports = { 56 postUserAuth, 57 getUserInfo, 58 getUserList 59 }
7. 定义接口,用于前端发送 ajax 的 url
在 server/routes 目录添加 user.js
const userController = require('../controllers/user.js'); const router = require('koa-router')(); router.post('/user', userController.postUserAuth); router.get('/user/:id', userController.getUserInfo); // 定义 url 的参数 id router.post('/user/list', userController.getUserList); module.exports = router;
这是登录的后端部分。
8. 在根目录添加 app.js
const path = require('path'), koa = new (require('koa'))(), koaRouter = require('koa-router')(), logger = require('koa-logger'), koaStatic = require('koa-static'), historyApiFallback = require('koa-history-api-fallback'), image = require('./server/routes/image.js'), user = require('./server/routes/user.js'), goods = require('./server/routes/goods.js'); koa.use(require('koa-bodyparser')()); koa.use(logger()); koa.use(historyApiFallback()); koa.use(async (ctx, next) => { let start = new Date(); await next(); let ms = new Date - start; console.log('%s %s - %s', this.method, this.url, ms); }); koa.on('error', function(err, ctx) { console.log('server error: ', err); }); // 静态文件 koaStatic 在 koa-router 的其他规则之上 koa.use(koaStatic(path.resolve('dist'))); // 将 webpack 打包好的项目目录作为 Koa 静态文件服务的目录 // 挂载到 koa-router 上,同时会让所有的 user 的请求路径前面加上 '/auth' 。 koaRouter.use('/auth', user.routes()); koaRouter.use(goods.routes()); koaRouter.use(image.routes()); koa.use(koaRouter.routes()); // 将路由规则挂载到Koa上。 koa.listen(8889, () => { console.log('Koa is listening on port 8889'); }); module.exports = koa;
在命令行中执行 npm run server,正常情况下应该会成功(前提是 mysql 已经正常启动)
9. 前端部分
src/main.js
1 import Vue from 'vue' 2 import ElementUI from 'element-ui' 3 import 'element-ui/lib/theme-default/index.css' 4 import VueRouter from 'vue-router' 5 import Axios from 'axios' 6 7 Vue.use(ElementUI); 8 Vue.use(VueRouter); 9 Vue.prototype.$http = Axios 10 11 import routes from './routes' 12 const router = new VueRouter({ 13 mode: 'history', 14 base: __dirname, 15 routes: routes 16 }) 17 18 import jwt from 'jsonwebtoken' 19 20 router.beforeEach((to, from, next) => { 21 let token = localStorage.getItem('demo-token'); 22 23 const decoded = token && jwt.verify(token, 'vue-koa-demo'); 24 if (decoded) { 25 if (decoded.originExp - Date.now() < 0) { // 已过期 26 localStorage.removeItem('demo-token'); 27 } else { 28 decoded.originExp = Date.now() + 60 * 60 * 1000; 29 token = jwt.sign(decoded, 'vue-koa-demo'); 30 localStorage.setItem('demo-token', token); 31 } 32 } 33 34 if (to.path == '/') { 35 if (token) { 36 next('/page/userHome') 37 } 38 next(); 39 } else { 40 if (token) { 41 Vue.prototype.$http.defaults.headers.common['Authorization'] = 'Bearer ' + token; // 全局设定 header 的 token 验证 42 next() 43 } else { 44 next('/') 45 } 46 } 47 }) 48 49 import Main from './components/main.vue' 50 const app = new Vue({ 51 router, 52 render: h => h(Main) 53 }).$mount('#app')
其它部分,比如路由、组件,可查看之前的文章,或下载该示例在 github 上的代码
启动前端的本地环境:npm run dev
根据我在 user 表中创建的帐户与密码,登录成功
(二):商品
服务端代码与登录功能的相似,老三样:数据模型、业务控制器、api 接口:
server/models/goods.js
1 const theDatabase = require('../config/db.js').theDb; 2 const goodsSchema = theDatabase.import('../schema/goods.js'); 3 4 const getGoodsList = async (searchVal) => { 5 return await goodsSchema.findAndCount( 6 { 7 where: { 8 name: { 9 $like: '%' + searchVal + '%' // searchVal:要搜索的商品名称 10 } 11 } 12 } 13 ); 14 } 15 16 // 根据商品 id 查找数据 17 const getGoodsDetails = async (id) => { 18 return await goodsSchema.findById(id); 19 } 20 21 // 添加商品 22 const addGoods = async (name, description, img_url) => { 23 await goodsSchema.create({ 24 name, 25 description, 26 img_url 27 }); 28 29 return true; 30 } 31 32 // 根据商品 id 修改 33 const updateGoods = async (id, name, description, img_url) => { 34 await goodsSchema.update( 35 { 36 name, 37 description, 38 img_url 39 }, 40 { 41 where: { 42 id 43 } 44 } 45 ); 46 47 return true; 48 } 49 50 // 根据商品 id 删除数据 51 const removeGoods = async (id) => { 52 await goodsSchema.destroy({ 53 where: { 54 id 55 } 56 }); 57 58 return true; 59 } 60 61 module.exports = { 62 getGoodsList, 63 getGoodsDetails, 64 addGoods, 65 updateGoods, 66 removeGoods 67 }
server/controllers/goods.js
1 const goodsModel = require('../models/goods.js'); 2 3 const getGoodsList = async function() { 4 const data = this.request.body; // post 请求,参数在 request.body 里 5 const currentPage = Number(data.currentPage); 6 const pageSize = Number(data.pageSize); 7 const searchVal = data.searchVal; 8 const result = await goodsModel.getGoodsList(searchVal); 9 10 let list = result.rows; 11 12 // 根据分页输出数据 13 let start = pageSize * (currentPage - 1); 14 list = list.slice(start, start + pageSize); 15 16 this.body = { 17 success: true, 18 list, 19 total: result.count, 20 msg: '获取商品列表成功!' 21 } 22 } 23 24 const getGoodsDetails = async function() { 25 const id = this.params.id; 26 const list = await goodsModel.getGoodsDetails(id); 27 28 this.body = { 29 success: true, 30 list: Array.isArray(list) ? list : [list], 31 msg: '获取商品详情成功!' 32 }; 33 } 34 35 const manageGoods = async function() { 36 const data = this.request.body; 37 const id = data.id; 38 const name = data.name; 39 const description = data.description; 40 const imgUrl = data.imgUrl; 41 42 let success = false; 43 let msg = ''; 44 45 if (id) { 46 if (name) { 47 await goodsModel.updateGoods(id, name, description, imgUrl); 48 success = true; 49 msg = '修改成功!'; 50 } 51 } else if (name) { 52 await goodsModel.addGoods(name, description, imgUrl); 53 success = true; 54 msg = '添加成功!'; 55 } 56 57 this.body = { 58 success, 59 msg 60 } 61 } 62 63 const removeGoods = async function() { 64 const id = this.params.id; 65 66 await goodsModel.removeGoods(id); 67 68 this.body = { 69 success: true, 70 msg: '删除成功!' 71 } 72 } 73 74 module.exports = { 75 getGoodsList, 76 getGoodsDetails, 77 removeGoods, 78 manageGoods 79 }
server/routes/goods.js
const goodsController = require('../controllers/goods.js'); const router = require('koa-router')(); router.post('/goods/list', goodsController.getGoodsList); router.get('/goods/:id', goodsController.getGoodsDetails); router.delete('/goods/:id/', goodsController.removeGoods); router.post('/goods/management', goodsController.manageGoods); module.exports = router;
商品列表界面:
点击“编辑”时,将 id 附在 url 上,通过 vue-router 跳转到商品详情页,根据 id 发送 ajax 获取详情数据:
(商品里有上传图片,但是无法显示,那是因为图片上传到了本地,路径也是本地的 F:\project\vue-demo\vue2.x-koa2.x\uploads\album\fc37b8a61133.jpg)
(三)图片
在 server/controllers/common 目录添加 file.js,作为所有文件上传的公共文件:
1 const inspect = require('util').inspect 2 const path = require('path') 3 const fs = require('fs') 4 const Busboy = require('busboy') 5 6 /** 7 * 同步创建文件目录 8 * @param {string} dirname 目录绝对地址 9 * @return {boolean} 创建目录结果 10 */ 11 function mkdirsSync( dirname ) { 12 if (fs.existsSync( dirname )) { 13 return true 14 } else { 15 if (mkdirsSync( path.dirname(dirname)) ) { 16 fs.mkdirSync( dirname ) 17 return true 18 } 19 } 20 } 21 22 /** 23 * 获取上传文件的后缀名 24 * @param {string} fileName 获取上传文件的后缀名 25 * @return {string} 文件后缀名 26 */ 27 function getSuffixName( fileName ) { 28 let nameList = fileName.split('.') 29 return nameList[nameList.length - 1] 30 } 31 32 /** 33 * 上传文件 34 * @param {object} ctx koa上下文 35 * @param {object} options 文件上传参数 36 * dir 文件目录 37 * path 文件存放路径 38 * @return {promise} 39 */ 40 function uploadFile( ctx, options) { 41 let req = ctx.req 42 // let res = ctx.res 43 let busboy = new Busboy({headers: req.headers}) 44 45 // 获取类型 46 let dir = options.dir || 'common' 47 let filePath = path.join( options.path, dir) 48 let mkdirResult = mkdirsSync( filePath ) 49 50 return new Promise((resolve, reject) => { 51 console.log('文件上传中...') 52 let result = { 53 success: false, 54 filePath: '', 55 formData: {}, 56 } 57 58 // 解析请求文件事件 59 busboy.on('file', function(fieldname, file, filename, encoding, mimetype) { 60 let fileName = Math.random().toString(16).substr(2) + '.' + getSuffixName(filename) 61 let _uploadFilePath = path.join( filePath, fileName ) 62 let saveTo = path.join(_uploadFilePath) 63 64 // 文件保存到指定路径 65 file.pipe(fs.createWriteStream(saveTo)) 66 67 // 文件写入事件结束 68 file.on('end', function() { 69 result.success = true 70 result.filePath = saveTo 71 result.message = '文件上传成功' 72 console.log('文件上传成功!') 73 }) 74 }) 75 76 // 解析表单中其他字段信息 77 busboy.on('field', function(fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) { 78 console.log('表单字段数据 [' + fieldname + ']: value: ' + inspect(val)); 79 result.formData[fieldname] = inspect(val); 80 }); 81 82 // 解析结束事件 83 busboy.on('finish', function() { 84 console.log('文件上传结束') 85 resolve(result) 86 }) 87 88 // 解析错误事件 89 busboy.on('error', function(err) { 90 console.log('文件上传出错') 91 reject(result) 92 }) 93 94 req.pipe(busboy) 95 }) 96 } 97 98 module.exports = { 99 uploadFile 100 }
在 server/controllers 目录添加 image.js,用来处理商品的图片上传业务:
const path = require('path'); const uploadFile = require('./common/file.js').uploadFile; const uploadImg = async function() { const serverFilePath = path.join( __dirname, '../../uploads' ) result = await uploadFile(this, { dir: 'album', path: serverFilePath }); this.body = result; } module.exports = { uploadImg }
在 server/routes 目录添加 image.js,定义文件上传的接口:
const imgController = require('../controllers/image.js'); const router = require('koa-router')(); router.post('/uploads/img', imgController.uploadImg) module.exports = router;
其它更多细节请见项目。