一個基於Vue.js+Mongodb+Node.js的部落格內容管理系統

Teal發表於2019-03-04

這個專案最初其實是fork別人的專案。當初想接觸下mongodb資料庫,找個例子學習下,後來改著改著就面目全非了。後臺和資料庫重構,前端增加了登入註冊功能,僅保留了部落格設定頁面,但是也優化了。

一、功能特點

  1. 一個基本的部落格內容管理器功能,如釋出並管理文章等
  2. 每個使用者可以通過註冊擁有自己的部落格
  3. 支援markdown語法編輯
  4. 支援程式碼高亮
  5. 可以管理部落格頁面的連結
  6. 部落格頁面對移動端適配優化
  7. 賬戶管理(修改密碼)
  8. 頁面足夠大氣、酷炫嘿

二、用到的技術和實現思路:

2.1 前端:Vue全家桶

  • Vue.js
  • Vue-Cli
  • Vue-Resource
  • Vue-Validator
  • Vue-Router
  • Vuex
  • Vue-loader

2.2 後端

  • Node.js
  • mongoDB (mongoose)
  • Express

2.3 工具和語言

  • Webpack
  • ES6
  • SASS
  • Jade

2.4 整體思路:

  • Node服務端除了主頁和首頁外,不做模板渲染,渲染交給瀏覽器完成
  • Node服務端不做任何路由切換的內容,這部分交給Vue-Router完成
  • Node服務端只用來接收請求,查詢資料庫並用來返回值

所以這樣做前後端幾乎完全解耦,只要約定好restful風格的資料介面,和資料存取格式就OK啦。

後端我用了mongoDB做資料庫,並在Express中通過mongoose操作mongoDB,省去了複雜的命令列,通過Javascript操作無疑方便了很多。

三、更新內容

在原來專案的基礎上,做了如下更新:

  1. 資料庫重新設計,改成以使用者分組的subDocs資料庫結構
  2. 應資料庫改動,所有介面重新設計,並統一採用和網易立馬理財一致的介面風格
  3. 刪除原來遊客模式,增加登入註冊功能,支援彈窗登入。
  4. 增加首頁,展示最新發布文章和註冊使用者
  5. 增加修改密碼,登出,登出等功能。
  6. 優化pop彈窗元件,更加智慧,更多配置項,接近網易$.dialog元件。並且一套程式碼僅修改了下css,實現相同介面下pc端彈窗和wap端toast功能。
  7. 增加移動端適配
  8. 優化原來程式碼,修復部分bug。

更多的更新內容請移步專案CMS-of-Blog_ProductionCMS-of-Blog

四、核心程式碼分析

原作者也寫過分析的文章。這裡,主要分析一下我更新的部分。

4.1. 資料庫

對原資料庫進行重新設計,改成以使用者分組的subDocs資料庫結構。這樣以使用者為一個整體的資料庫結構更加清晰,同時也更方便操作和讀取。程式碼如下:

var mongoose =  require(`mongoose`),
    Schema =    mongoose.Schema

    articleSchema = new Schema({
        title: String,
        date: Date,
        content: String,
    }),

    linkSchema = new Schema({
        name: String,
        href: String,
        newPage: Boolean
    }),

    userSchema = new Schema({
        name: String,
        password: String,
        email: String,
        emailCode: String,
        createdTime: Number,
        articles: [articleSchema],
        links: [linkSchema]
    }),

    User = mongoose.model(`User`, userSchema);

mongoose.connect(`mongodb://localhost/platform`)
mongoose.set(`debug`, true)

var db = mongoose.connection
db.on(`error`, function () {
    console.log(`db error`.error)
})
db.once(`open`, function () {
    console.log(`db opened`.silly)
})

module.exports = {
    User: User
}
複製程式碼

程式碼一開始新定義了三個Schema:articleSchema、linkSchema和userSchema。而userSchema裡又巢狀了articleSchema和linkSchema,構成了以使用者分組的subDocs資料庫結構。Schema是一種以檔案形式儲存的資料庫模型骨架,不具備資料庫的操作能力。然後將將該Schema釋出為Model。Model由Schema釋出生成的模型,具有抽象屬性和行為的資料庫操作對。由Model可以建立的實體,比如新註冊一個使用者就會建立一個實體。

資料庫建立了之後需要去讀取和操作,可以看下注冊時傳送郵箱驗證碼的這段程式碼感受下。

router.post(`/genEmailCode`, function(req, res, next) {
    var email = req.body.email,
    resBody = {
        retcode: ``,
        retdesc: ``,
        data: {}
    }
    if(!email){
        resBody = {
            retcode: 400,
            retdesc: `引數錯誤`,
        }
        res.send(resBody)
        return
    }
    function genRandomCode(){
        var arrNum = [];
        for(var i=0; i<6; i++){
            var tmpCode = Math.floor(Math.random() * 9);
            arrNum.push(tmpCode);
        }
        return arrNum.join(``)
    }
    db.User.findOne({ email: email }, function(err, doc) {
        if (err) {
            return console.log(err)
        } else if (doc && doc.name !== `tmp`) {
            resBody = {
                retcode: 400,
                retdesc: `該郵箱已註冊`,
            }
            res.send(resBody)
        } else if(!doc){  // 第一次點選獲取驗證碼
            var emailCode = genRandomCode();
            var createdTime = Date.now();
            // setup e-mail data with unicode symbols
            var mailOptions = {
                from: `"CMS-of-Blog ?" <tywei90@163.com>`, // sender address
                to: email, // list of receivers
                subject: `親愛的使用者` + email, // Subject line
                text: `Hello world ?`, // plaintext body
                html: [
                    `<p>您好!恭喜您註冊成為CMS-of-Blog部落格使用者。</p>`,
                    `<p>這是一封傳送驗證碼的註冊認證郵件,請複製一下驗證碼填寫到註冊頁面以完成註冊。</p>`,
                    `<p>本次驗證碼為:` + emailCode + `</p>`,
                    `<p>上述驗證碼30分鐘內有效。如果驗證碼失效,請您登入網站<a href="https://cms.wty90.com/#!/register">CMS-of-Blog部落格註冊</a>重新申請認證。</p>`,
                    `<p>感謝您註冊成為CMS-of-Blog部落格使用者!</p><br/>`,
                    `<p>CMS-of-Blog開發團隊</p>`,
                    `<p>`+ (new Date()).toLocaleString() + `</p>`
                ].join(``) // html body
            };
            // send mail with defined transport object
            transporter.sendMail(mailOptions, function(error, info){
                if(error){
                    return console.log(error);
                }
                // console.log(`Message sent: ` + info.response);
                new db.User({
                    name: `tmp`,
                    password: `0000`,
                    email: email,
                    emailCode: emailCode,
                    createdTime: createdTime,
                    articles: [],
                    links: []
                }).save(function(err) {
                    if (err) return console.log(err)
                    // 半小時內如果不註冊成功,則在資料庫中刪除這條資料,也就是說驗證碼會失效
                    setTimeout(function(){
                        db.User.findOne({ email: email }, function(err, doc) {
                            if (err) {
                                return console.log(err)
                            } else if (doc && doc.createdTime === createdTime) {
                                db.User.remove({ email: email }, function(err) {
                                    if (err) {
                                        return console.log(err)
                                    }
                                })
                            }
                        })
                    }, 30*60*1000);
                    resBody = {
                        retcode: 200,
                        retdesc: ``
                    }
                    res.send(resBody)
                })
            });
        }else if(doc && doc.name === `tmp`){
            // 在郵箱驗證碼有效的時間內,再次點選獲取驗證碼(類似省略)
            ...
        }
    })
})
複製程式碼

後臺接受到傳送郵箱驗證碼的請求後,會初始化一個tmp的使用者。通過new db.User()會建立一個User的例項,然後執行save()操作會將這條資料寫到資料庫裡。如果在半小時內沒有註冊成功,通過匹配郵箱,然後db.User.remove()將這條資料刪除。更多具體用法請移步官方文件

4.2. 後臺

將所有請求分為三種:

  • ajax非同步請求,統一路徑:/web/
  • 公共頁面部分,如部落格首頁、登入、註冊等,統一路徑:/
  • 與部落格使用者id相關的部落格部分,統一路徑:/:id/

這樣每個使用者都可以擁有自己的部落格頁面,具體程式碼如下:

var express = require(`express`);
var path = require(`path`);
var favicon = require(`serve-favicon`);
var logger = require(`morgan`);
var cookieParser = require(`cookie-parser`);
var bodyParser = require(`body-parser`);
var routes = require(`./index`);
var db = require(`./db`)
var app = express();

// view engine setup
app.set(`views`, path.join(__dirname, `../`));
app.set(`view engine`, `jade`);

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, `public`, `favicon.ico`)));
app.use(logger(`dev`));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(`/public`,express.static(path.join(__dirname, `../public`)));

// 公共ajax介面(index.js)
app.use(`/web`, routes);

// 公共html頁面,比如登入頁,註冊頁
app.get(`/`, function(req, res, next) {
    res.render(`common`, { title: `CMS-blog` });
})

// 跟使用者相關的部落格頁面(路由的第一個引數只匹配與處理的相關的,不越權!)
app.get(/^/[a-z]{1}[a-z0-9_]{3,15}$/, function(req, res, next) {
    // format獲取請求的path引數
    var pathPara = req._parsedUrl.pathname.slice(1).toLocaleLowerCase()
    // 查詢是否對應有相應的username
    db.User.count({name: pathPara}, function(err, num) {
        if (err) return console.log(err)
        if(num > 0){
            res.render(`main`, { title: `CMS-blog` });
        }else{
            // 自定義錯誤處理
            res.status(403);
            res.render(`error`, {
                message: `該使用者尚未開通部落格。<a href="/#!/register">去註冊</a>`,
            });
        }
    })
})

// catch 404 and forward to error handler
app.use(function(req, res, next) {
    var err = new Error(`Not Found`);
    err.status = 404;
    next(err);
});

// error handlers

// development error handler
// will print stacktrace
if (app.get(`env`) === `development`) {
    app.use(function(err, req, res, next) {
        res.status(err.status || 500);
        res.render(`error`, {
            message: err.message,
            error: err
        });
    });
}

module.exports = app;
複製程式碼

具體的ajax介面程式碼大家可以看server資料夾下的index.js檔案。

4.3. pop/toast元件

在原專案基礎上,優化了pop彈窗元件,更加智慧,更多配置項,接近網易$.dialog元件。使並且一套程式碼僅修改了下css,實現相同介面下pc端彈窗和wap端toast功能。因為有部分格式化引數程式碼在vuex的action裡,有時間,可以將這個進一步整理成一個vue元件,方便大家使用。

4.3.1 pop/toast元件配置引數說明

  • pop: 彈窗的顯示與否, 根據content引數,有內容則為true
  • css: 自定義彈窗的class, 預設為空
  • showClose: 為false則不顯示關閉按鈕, 預設顯示
  • closeFn: 彈窗點選關閉按鈕之後的回撥
  • title: 彈窗的標題,預設`溫馨提示`, 如果不想顯示title, 直接傳空
  • content(required): 彈窗的內容,支援傳html
  • btn1: `按鈕1文案|按鈕1樣式class`, 格式化後為btn1Text和btn1Css
  • cb1: 按鈕1點選之後的回撥,如果cb1沒有明確返回true,則預設按鈕點選後關閉彈窗
  • btn2: `按鈕2文案|按鈕2樣式class`, 格式化後為btn2Text和btn2Css
  • cb2: 按鈕2點選之後的回撥,如果cb2沒有明確返回true,則預設按鈕點選後關閉彈窗。按鈕引數不傳,文案預設`我知道了`,點選關閉彈窗
  • init: 彈窗建立後的初始化函式,可以用來處理複雜互動(注意彈窗一定要是從pop為false變成true才會執行)
  • destroy: 彈窗消失之後的回撥函式
  • wapGoDialog: 在移動端時,要不要走彈窗,預設false,走toast

4.3.2 pop/toast元件程式碼

模板

<template>
    <div class="m-dialog" :class="getPopPara.css">
        <div class="dialog-wrap">
            <span class="close" @click="handleClose" v-if="getPopPara.showClose">+</span>
            <div class="title" v-if="getPopPara.title">{{getPopPara.title}}</div>
            <div class="content">{{{getPopPara.content}}}</div>
            <div class="button">
                <p class="btn" :class="getPopPara.btn1Css" @click="fn1">
                    <span>{{getPopPara.btn1Text}}</span>
                </p>
                <p class="btn" :class="getPopPara.btn2Css" @click="fn2" v-if="getPopPara.btn2Text">
                    <span>{{getPopPara.btn2Text}}</span>
                </p>
            </div>
        </div>
    </div>
</template>
複製程式碼

指令碼

import {pop}                from `../vuex/actions`
import {getPopPara}         from `../vuex/getters`
import $                    from `../js/jquery.min`

export default{
    computed:{
        showDialog(){
            return this.getPopPara.pop
        }
    },
    vuex: {
        getters: {
            getPopPara
        },
        actions: {
            pop
        }
    },
    methods: {
        fn1(){
            let fn = this.getPopPara.cb1
            let closePop = false
            //  如果cb1函式沒有明確返回true,則預設按鈕點選後關閉彈窗
            if(typeof fn == `function`){
                closePop = fn()
            }
            // 初始值為false, 所以沒傳也預設關閉
            if(!closePop){
                this.pop()
            }
            // !fn && this.pop()
        },
        fn2(){
            let fn = this.getPopPara.cb2
            let closePop = false
            //  如果cb1函式沒有明確返回true,則預設按鈕點選後關閉彈窗
            if(typeof fn == `function`){
                closePop = fn()
            }
            // 初始值為false, 所以沒傳也預設關閉
            if(!closePop){
                this.pop()
            }
            // !fn && this.pop()
        },
        handleClose(){
            // this.pop()要放在最後,因為先執行所有引數就都變了
            let fn = this.getPopPara.closeFn
            typeof fn == `function` && fn()
            this.pop()
        }
    },
    watch:{
        `showDialog`: function(newVal, oldVal){
            // 彈窗開啟時
            if(newVal){
                // 增加彈窗支援鍵盤操作
                $(document).bind(`keydown`, (event)=>{
                    // Enter鍵執行fn1,會出現反覆彈窗bug
                    if(event.keyCode === 27){
                        this.pop()
                    }
                })
                var $dialog = $(`.dialog-wrap`);
                // 移動端改成類似toast,通過更改樣式,既不需要增加toast元件,也不需要更改程式碼,統一pop方法
                if(screen.width < 700 && !this.getPopPara.wapGoDialog){
                    $dialog.addClass(`toast-wrap`);
                    setTimeout(()=>{
                        this.pop();
                        $dialog.removeClass(`toast-wrap`);
                    }, 2000)
                }
                //調整彈窗居中
                let width = $dialog.width();
                let height = $dialog.height();
                $dialog.css(`marginTop`, - height/2);
                $dialog.css(`marginLeft`, - width/2);
                // 彈窗建立的初始化函式
                let fn = this.getPopPara.init;
                typeof fn == `function` && fn();
            }else{
                // 彈窗關閉時
                // 登出彈窗開啟時註冊的事件
                $(document).unbind(`keydown`)
                // 彈窗消失回撥
                let fn = this.getPopPara.destroy
                typeof fn == `function` && fn()
            }
        }
    }
}
複製程式碼

4.3.3 pop/toast元件引數格式化程式碼

為了使用方便,我們在使用的時候進行了簡寫。為了讓元件能識別,需要在vuex的action裡對傳入的引數格式化。

function pop({dispatch}, para) {
    // 如果沒有傳入任何引數,預設關閉彈窗
    if(para === undefined){
        para = {}
    }
    // 如果只傳入字串,格式化內容為content的para物件
    if(typeof para === `string`){
        para = {
            content: para
        }
    }
    // 設定預設值
    para.pop = !para.content? false: true
    para.showClose = para.showClose === undefined? true: para.showClose
    para.title = para.title === undefined? `溫馨提示`: para.title
    para.wapGoDialog = !!para.wapGoDialog
    // 沒有傳引數
    if(!para.btn1){
        para.btn1 = `我知道了|normal`
    }
    // 沒有傳class
    if(para.btn1.indexOf(`|`) === -1){
        para.btn1 = para.btn1 + `|primary`
    }
    let array1 = para.btn1.split(`|`)
    para.btn1Text = array1[0]
    // 可能會傳多個class
    for(let i=1,len=array1.length; i<len; i++){
        if(i==1){
            // class為disabled屬性不加`btn-`
            para.btn1Css = array1[1]==`disabled`? `disabled`: `btn-` + array1[1]
        }else{
            para.btn1Css = array1[i]==`disabled`? ` disabled`: para.btn1Css + ` btn-` + array1[i]
        }
    }

    if(para.btn2){
        if(para.btn2.indexOf(`|`) === -1){
            para.btn2 = para.btn2 + `|normal`
        }
        let array2 = para.btn2.split(`|`)
        para.btn2Text = array2[0]
        for(let i=1,len=array2.length; i<len; i++){
            if(i==1){
                para.btn2Css = array2[1]==`disabled`? `disabled`: `btn-` + array2[1]
            }else{
                para.btn2Css = array2[i]==`disabled`? ` disabled`: para.btn2Css + ` btn-` + array2[i]
            }
        }
    }
    dispatch(`POP`, para)
}
複製程式碼

為了讓移動端相容pop彈窗元件,我們採用mediaQuery對移動端樣式進行了更改。增加引數wapGoDialog,表明我們在移動端時,要不要走彈窗,預設false,走toast。這樣可以一套程式碼就可以相容pc和wap。

後記

這裡主要分析了下後臺和資料庫,而且比較簡單,大家可以去看原始碼。總之,這是一個不錯的前端入手後臺和資料庫的例子。功能比較豐富,而且可以學習下vue.js。

歡迎大家star學習交流:github地址 | 我的部落格

相關文章