vue+node+mongodb 搭建一个完整博客

6655
1月25日 · 2018年

前言

前段时间刚把自己的个人网站写完, 于是这段时间因为事情不是太多,便整理了一下,写了个简易版的博客系统
服务端用的是 koa2框架 进行开发

技术栈

Vue + vuex + element-ui + webpack + nodeJs + koa2 + mongodb


目录结构讲解

图片描述

  • build - webpack的配置文件
  • code - 放置代码文件
  • config - 项目参数配置的文件
  • logs - 日志打印文件
  • node_modules - 项目依赖模块
  • public - 项目静态文件的入口 例如: public下的 demo.html文件, 可通过 localhost:3000/demo.html 访问
  • static - 静态资源文件
  • .babelrc - babel编译
  • postcss.config.js - css后处理器配置

build 文件讲解

图片描述

  • build.js - 执行webpack编译任务, 还有打包动画 等等
  • get-less-variables.js - 解析less文件, 赋值less全局变量
  • style-loader.js - 样式loader配置
  • vue-config.js - vue配置
  • webpack.base.conf.js - webpack 基本通用配置
  • webpack.dev.conf.js - webpack 开发环境配置
  • webpack.prod.conf.js - webpack 生产环境配置

code 文件

图片描述

1.admin - 后台管理界面源码
图片描述
src - 代码区域
1. components - 组件
2. filters - 过滤器
3. font - 字体/字体图标
4. images - 图片
5. router - 路由
6. store - vuex状态管理
7. styles - 样式表
8. utils - 请求封装
9. views - 页面模块
10. App.vue - app组件
11. custom-components.js - 自定义组件导出
12. main.js - 入口JS
index.html - webpack 模板文件

2.client - web端界面源码
跟后台管理界面的结构基本一样

3.server - 服务端源码
图片描述
1. controller: 所有接口逻辑代码
2. middleware: 所有的中间件
3. models: 数据库model
4. router: 路由/接口
5. app.js: 入口
6. config.js: 配置文件
7. index.js: babel编译
8. mongodb.js: mongodb配置

config - 项目参数配置的文件

logs - 日志文件

public - 项目静态文件的入口

static - 静态资源文件

.babelrc - babel编译

postcss.config.js - css后处理器配置


后台管理

开发中用的一些依赖模块

  • vue/vue-router/vuex - Vue全家桶
  • axios - 一个现在主流并且很好用的请求库 支持Promise
  • qs - 用于解决axios POST请求参数的问题
  • element-ui - 饿了么出品的vue2.0 pc UI框架
  • babel-polyfill - 用于实现浏览器不支持原生功能的代码
  • highlight.js / marked- 两者搭配实现Markdown的常用语法
  • js-md5 - 用于登陆时加密
  • nprogress - 顶部加载条

components

这个文件夹一般放入常用的组件, 比如 Loading组件等等

views

所有模块页面

store

vuex用来统一管理公用属性, 和统一管理接口

1. 登陆

登陆是采用 jsonwebtoken方案 来实现整个流程的

  • jwt.sign(payload, secretOrPrivateKey, [options, callback]) 生成TOKEN

  • jwt.verify(token,secretOrPublicKey,[options,callback]) 验证TOKEN

  • 获取用户的账号密码

  • 通过 jwt.sign 方法来生成token

    //server端
    import jwt from 'jsonwebtoken'
    
    let data = { //用户信息
        username,
        roles,
        ...
    }
    
    let payload = { // 可以把常用信息存进去
        id: data.userId, //用户ID
        username: data.username, // 用户名
        roles: data.roles // 用户权限
    },
    secret = 'admin_token'
    
    // 通过调用 sign 方法, 把 **用户信息**、**密钥** 生成token,并设置过期时间 
    let token = jwt.sign(payload, secret, {expiresIn: '24h'})
    
    // 存入cookie发送给前台
    ctx.cookies.set('Token-Auth', token, {httpOnly: false })复制代码
  • 每次请求数据的时候通过 jwt.verify 检测token的合法性 jwt.verify(token, secret)

2. 权限

通过不同的权限来动态修改路由表

  • 通过 vue的 钩子函数 beforeEach 来控制并展示哪些路由, 以及判断是否需要登陆
import store from '../store'
import { getToken } from 'src/utils/auth'
import { router } from './index'
import NProgress from 'nprogress' // Progress 进度条
import 'nprogress/nprogress.css' // Progress 进度条样式

const whiteList = ['/login'];
router.beforeEach((to, from, next) => {
    NProgress.start()

    if (getToken()) { //存在token
        if (to.path === '/login') { //当前页是登录直接跳过进入主页
            next('/')
        }else{
            if (!store.state.user.roles) { //拉取用户信息
                store.dispatch('getUserInfo').then( res => {
                    let roles = res.data.roles
                    store.dispatch('setRoutes', {roles}).then( () => { //根据权限动态添加路由
                        router.addRoutes(store.state.permission.addRouters)
                        next({ ...to }) //hash模式  确保路由加载完成
                    })
                })
            }else{
                next()
            }
        }
    }else{
        if (whiteList.indexOf(to.path) >= 0) { //是否在白名单内,不在的话直接跳转登录页
            next()
        }else{
            next('/login')
        }

    }    

})
router.afterEach((to, from) => {
    document.title = to.name
    NProgress.done()
})

export default router
复制代码
  • 通过调用 getUserInfo方法传入 token 获取用户信息, 后台直接解析 token 获取里面的 信息 返回给前台
getUserInfo ({state, commit}) {
    return new Promise( (resolve, reject) => {
        axios.get('user/info',{
            token: state.token
        }).then( res => {
            commit('SET_USERINFO', res.data)
            resolve(res)
        }).catch( err => {
            reject(err)
        })
    })
}复制代码
  • 通过调用 setRoutes方法 动态生成路由

    import { constantRouterMap, asyncRouterMap } from 'src/router'
    
    const hasPermission = (roles, route) => {
        if (route.meta && route.meta.role) {
            return roles.some(role => route.meta.role.indexOf(role) >= 0)
        } else {
            return true
        }
    }
    
    const filterAsyncRouter = (asyncRouterMap, roles) => {
        const accessedRouters = asyncRouterMap.filter(route => {
            if (hasPermission(roles, route)) {
                if (route.children && route.children.length) {
                    route.children = filterAsyncRouter(route.children, roles)
                }
                return true
            }
            return false
        })
        return accessedRouters
    }
    
    const permission = {
        state: {
            routes: constantRouterMap.concat(asyncRouterMap),
            addRouters: []
        },
        mutations: {
            SETROUTES(state, routers) {
                state.addRouters = routers;
                state.routes = constantRouterMap.concat(routers);
            }
        },
        actions: {
            setRoutes({ commit }, info) {
                return new Promise( (resolve, reject) => {
                    let {roles} = info;
                    let accessedRouters = [];
                    if (roles.indexOf('admin') >= 0) {
                        accessedRouters = asyncRouterMap;
                    }else{
                        accessedRouters = filterAsyncRouter(asyncRouterMap, roles)
                    }
    
                    commit('SETROUTES', accessedRouters)
                    resolve()
                })
            }
            
        }
    }
    export default permission复制代码

axios 请求封装, 统一对请求进行管理

import axios from 'axios'
import qs from 'qs'
import { Message } from 'element-ui'


axios.defaults.withCredentials = true 

// 发送时
axios.interceptors.request.use(config => {
    // 开始(LLoading动画..)
    return config
}, err => {
    return Promise.reject(err)
})

// 响应时
axios.interceptors.response.use(response => response, err => Promise.resolve(err.response))

// 检查状态码
function checkStatus(res) { 
    // 结束(结束动画..)
    if (res.status === 200 || res.status === 304) {
        return res.data
    }
    return {
        code: 0,
        msg: res.data.msg || res.statusText,
        data: res.statusText
    }
    return res
}


// 检查CODE值
function checkCode(res) {
    if (res.code === 0) {
        Message({
          message: res.msg,
          type: 'error',
          duration: 2 * 1000
        })

        throw new Error(res.msg)
    }
    
    return res
}

const prefix = '/admin_demo_api/'
export default {
    get(url, params) {
        if (!url) return
        return axios({
            method: 'get',
            url: prefix + url,
            params,
            timeout: 30000
        }).then(checkStatus).then(checkCode)
    },
    post(url, data) {
        if (!url) return
        return axios({
            method: 'post',
            url: prefix + url,
            data: qs.stringify(data),
            timeout: 30000
        }).then(checkStatus).then(checkCode)
    },
    postFile(url, data) {
        if (!url) return
        return axios({
            method: 'post',
            url: prefix + url,
            data
        }).then(checkStatus).then(checkCode)
    }
}
复制代码

面包屑 / 标签路径

  • 通过检测路由来把当前路径转换成面包屑
  • 把访问过的路径储存在本地,记录下来,通过标签直接访问
// 面包屑
getBreadcrumb() {
    let matched = this.$route.matched.filter(item => item.name);
    let first = matched[0],
        second = matched[1];
    if (first && first.name !== '首页' && first.name !== '') {
        matched = [{name: '首页', path: '/'}].concat(matched);
    }
    if (second && second.name === '首页') {
        this.levelList = [second];
    }else{
        this.levelList = matched;
    }
}

// 检测路由变化 
watch: {
    $route() {
        this.getBreadcrumb();
    }
}复制代码

上面介绍了几个主要以及必备的后台管理功能,其余的功能模块 按照需求增加就好


前台

前台展示的页面跟后台管理界面差不多, 也是用vue+webpack搭建,基本的结构都差不多,具体代码实现的可以直接在github下载便行


server端

权限

主要是通过 jsonwebtoken 的verify方法检测cookie 里面的token 验证它的合法性

import jwt from 'jsonwebtoken'
import conf from '../../config'

export default () => {
    return async (ctx, next) => {
        if ( conf.auth.blackList.some(v => ctx.path.indexOf(v) >= 0) ) { // 检测是否在黑名单内
            let token = ctx.cookies.get(conf.auth.tokenKey);
            try {
                jwt.verify(token, conf.auth.admin_secret);
            }catch (e) {
                if ('TokenExpiredError' === e.name) {
                    ctx.sendError('token已过期, 请重新登录!');
                    ctx.throw(401, 'token expired,请及时本地保存数据!');
                }
                ctx.sendError('token验证失败, 请重新登录!');
                ctx.throw(401, 'invalid token');
            }
            console.log("鉴权成功");
        }
        await next();
    }
}复制代码

日志

日志是采用 log4js 来进行管理的,
log4js 算 nodeJs 常用的日志处理模块,用起来额也比较简单

  • log4js 的日志分为九个等级,各个级别的名字和权重如下:

  • 设置 Logger 实例的类型 logger = log4js.getLogger('cheese')

  • 通过 Appender 来控制文件的 名字路径类型

  • 配置到 log4js.configure

  • 便可通过 logger 上的打印方法 来输出日志了 logger.info(JSON.stringify(currTime: 当前时间为${Date.now()}s))

//指定要记录的日志分类
let appenders = {}
appenders.all = {
    type: 'dateFile', //日志文件类型,可以使用日期作为文件名的占位符
    filename: `${dir}/all/`, //日志文件名,可以设置相对路径或绝对路径 
    pattern: 'task-yyyy-MM-dd.log', //占位符,紧跟在filename后面  
    alwaysIncludePattern: true //是否总是有后缀名 
}
let logConfig = {
    appenders,

    /**
     * 指定日志的默认配置项
     * 如果 log4js.getLogger 中没有指定,默认为 cheese 日志的配置项
     */
    categories: {
        default: {
            appenders: Object.keys(appenders),
            level: logLevel
        }
    }
}
log4js.configure(logConfig)
复制代码

定制书写规范(API)

  • 设计思路
    当应用程序启动时候,读取指定目录下的 js 文件,以文件名作为属性名,挂载在实例 app 上,然后把文件中的接口函数,扩展到文件对象上

    //other.js
    const path = require('path');
    
    module.exports = {
        async markdown_upload_img (ctx, next) {
            console.log('----------------添加图片 markdown_upload_img-----------------------');
            let opts = {
                path: path.resolve(__dirname, '../../../../public')
            }
            let result = await ctx.uploadFile(ctx, opts)
            ctx.send(result)
        },
    
        async del_markdown_upload_img (ctx, next) {
            console.log('----------------删除图片 del_markdown_upload_img-----------------------');
            let id = ctx.request.query.id
            try {
                ctx.remove(musicModel, {_id: id})
                ctx.send()
            }catch(e){
                ctx.sendError(e)
            }
            // console.log(id)
        }
    }复制代码

    读取出来的便是以下形式:
    app.controller.admin.other.markdown_upload_img 便能读取到 markdown_upload_img 方法

    async markdown_upload_img (ctx, next) {
        console.log('----------------添加图片 markdown_upload_img-----------------------');
        let opts = {
            path: path.resolve(__dirname, '../../../../public')
        }
        let result = await ctx.uploadFile(ctx, opts)
        ctx.send(result)
    }复制代码

    在把该形式的方法 赋值过去就行
    router.post('/markdown_upload_img', app.controller.admin.other.markdown_upload_img)

通过 mongoose 链接 mongodb

import mongoose from 'mongoose'
import conf from './config'
// const DB_URL = `mongodb://${conf.mongodb.address}/${conf.mongodb.db}`
const DB_URL = `mongodb://${conf.mongodb.username}:${conf.mongodb.pwd}@${conf.mongodb.address}/${conf.mongodb.db}`; // 账号登陆
mongoose.Promise = global.Promise
mongoose.connect(DB_URL, { useMongoClient: true }, err => {
    if (err) {
        console.log("数据库连接失败!")
    }else{
        console.log("数据库连接成功!")
    }
})
export default mongoose
复制代码

封装返回的send函数

export default () => {
    let render = ctx => {
        return (json, msg) => {
            ctx.set("Content-Type", "application/json");
            ctx.body = JSON.stringify({
                code: 1,
                data: json || {},
                msg: msg || 'success'
            });
        }
    }
    let renderError = ctx => {
        return msg => {
            ctx.set("Content-Type", "application/json");
            ctx.body = JSON.stringify({
                code: 0,
                data: {},
                msg: msg.toString()
            });
        }
    }
    return async (ctx, next) => {
        ctx.send = render(ctx);
        ctx.sendError = renderError(ctx);
        await next()    
    }
}复制代码

通过 koa-static 管理静态文件入口

注意事项:

  1. cnpm run server 启动服务器
  2. 启动时,记得启动mongodb数据库,账号密码 可以在 server/config.js 文件下进行配置
  3. db.createUser({user:"cd",pwd:"123456",roles:[{role:"readWrite",db:'test'}]}) (mongodb 注册用户)
  4. cnpm run dev:admin 启动后台管理界面
  5. 登录后台管理界面录制数据
  6. 登录后台管理时需要在数据库 创建 users 集合注册一个账号进行登录
db.users.insert({
    "name" : "cd",
    "pwd" : "e10adc3949ba59abbe56e057f20f883e",
    "username" : "admin",
    "roles" : [ 
        "admin"
    ]
})

// 账号: admin  密码: 123456复制代码
  1. cnpm run dev:client 启动前台页面

参考文章

基于Koa2搭建Node.js实战项目教程
手摸手,带你用vue撸后台

  • 表情
  • 预览
可使用部分markdown语法
1

666

    李穷逼

    李 17853815418
    山东省 泰安市 肥城市 东部名城
    一个穿不起衣服,在网上各种敲诈的穷逼路过!

      1856

      666

        1856

        666
        .

          前端小弟

          楼主,右下角的卡通人物是图片还是canvas写?如果是canvas有没有github,想学习下

            123

            强强强强

              123

              123

                123

                123

                  cat

                  66

                    drgon

                    mark

                      大神到访数:20412