這個項目最初其實是fork別人的項目。當初想接觸下mongodb資料庫,找個例子學習下,後來改著改著就面目全非了。後臺和資料庫重構,前端增加了登錄註冊功能,僅保留了博客設置頁面,但是也優化了。 "線上地址" 一、更新內容 0. 資料庫重新設計,改成以用戶分組的subDocs資料庫結構 0. 應資料庫 ...
這個項目最初其實是fork別人的項目。當初想接觸下mongodb資料庫,找個例子學習下,後來改著改著就面目全非了。後臺和資料庫重構,前端增加了登錄註冊功能,僅保留了博客設置頁面,但是也優化了。
一、更新內容
- 資料庫重新設計,改成以用戶分組的subDocs資料庫結構
- 應資料庫改動,所有介面重新設計,並統一採用和立馬理財一致的介面風格
- 刪除原來游客模式,增加登錄註冊功能,支持彈窗登錄。
- 增加首頁,展示最新發佈文章和註冊用戶
- 增加修改密碼,登出,註銷等功能。
- 優化pop彈窗組件,更加智能,更多配置項,接近網易$.dialog組件。並且一套代碼僅修改了下css,實現相同介面下pc端彈窗和wap端toast功能。
- 增加移動端適配
- 優化原來代碼,修複部分bug。
更多的更新內容請移步項目CMS-of-Blog_Production和CMS-of-Blog。
二、核心代碼分析
原作者也寫過分析的文章。這裡,主要分析一下我更新的部分。
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 ?" <[email protected]>', // 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()
將這條數據刪除。更多具體用法請移步官方文檔。
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文件。
3. pop/toast組件
3.1 pop/toast組件配置參數說明
pop
: 彈窗的顯示與否, 根據content參數,有內容則為truecss
: 自定義彈窗的class, 預設為空showClose
: 為false則不顯示關閉按鈕, 預設顯示closeFn
: 彈窗點擊關閉按鈕之後的回調title
: 彈窗的標題,預設'溫馨提示', 如果不想顯示title, 直接傳空content
(required): 彈窗的內容,支持傳htmlbtn1
: '按鈕1文案|按鈕1樣式class', 格式化後為btn1Text和btn1Csscb1
: 按鈕1點擊之後的回調,如果cb1沒有明確返回true,則預設按鈕點擊後關閉彈窗btn2
: '按鈕2文案|按鈕2樣式class', 格式化後為btn2Text和btn2Csscb2
: 按鈕2點擊之後的回調,如果cb2沒有明確返回true,則預設按鈕點擊後關閉彈窗。按鈕參數不傳,文案預設'我知道了',點擊關閉彈窗init
: 彈窗建立後的初始化函數,可以用來處理複雜交互(註意彈窗一定要是從pop為false變成true才會執行)destroy
: 彈窗消失之後的回調函數wapGoDialog
: 在移動端時,要不要走彈窗,預設false,走toast
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>
<script>
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)=>{
// 回車鍵執行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()
}
}
}
}
</script>
<style lang="sass">
@import "../style/components/Pop.scss";
</style>
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。