使用Egg建立后台,应用mongoose对数据库进行操作,利用中间件进行用户的鉴权,将私密接口通过token鉴别分离出来。
同时,巧妙应用中间件可以从url中将数据类型和数据id等分离出来,提高后台代码整洁性
本项目中,使用了mongodb数据库,在一开始做数据模型定义时,没有考虑太多情形,导致数据模型设计得不太好,读者可以考虑更改数据模型,改成类似关系型数据库那种类型
// model/user
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const schema = new mongoose.Schema({
username: {
type: String, // 用户名用于登录注册
required: true
},
password: {
type: String,
required: true,
select: false, // 查询不会返回
set(value) {
// 设置或更改时,加密
return bcrypt.hashSync(value, 10);
}
},
imgUrl: {
type: String
},
fav: [{
type: mongoose.SchemaTypes.ObjectId,
ref: 'Article'
}]
});
module.exports = mongoose.model('User', schema);
// model/article
const mongoose = require('mongoose');
const schema = new mongoose.Schema({
title: { type: String },
isTop: {
type: Boolean,
default: false
},
summary: { type: String },
body: { type: String }, // 编译后的html内容
MdContent: { type: String }, // md值
read: {
type: Number,
default: 1
},
fav: {
type: Number,
default: 0
},
categories: [{
type: mongoose.SchemaTypes.ObjectId, // 指向 外键
ref: 'Category'
}],
comments: [{
type: mongoose.SchemaTypes.ObjectId, // 指向 外键
ref: 'Comment'
}]
}, {
timestamps: true // 自动添加 建立和更改的时间
});
module.exports = mongoose.model('Article', schema, 'articles');
以上先给出用户和文章数据的表,其他请看源码~~
然后还有连接数据库的代码:
module.exports = app => {
const mongoose = require('mongoose')
// 连接数据库
mongoose.connect('mongodb://127.0.0.1:27017', {
useNewUrlParser: true, //如果在用户遇到 bug 时,允许用户在新的解析器中返回旧的解析器
useUnifiedTopology: true, //选择使用 MongoDB 驱动程序的新连接管理引擎
dbName: 'myblog' // 数据库名
});
//__dirname: 获得当前执行文件所在目录的完整目录名
const models = require('require-all')(__dirname + '/../models')
}
首先看一下token鉴权的中间件:
const jwt = require('jsonwebtoken');
const User = require('../models/User');
module.exports = options => {
return async (ctx, next) => {
//从请求头中拿出token
const token = String(ctx.request.headers.authorization || '').split(" ").pop()
// const token = String(ctx.cookies.get('user_token', {
// encrypt: true
// }));
//console.log(token);
//console.log(ctx.request.url);
//console.log(ctx.app.config.keys.split('_')[1]);
// 拿到key
const key = ctx.app.config.keys.split('_')[1];
// 如果没有设置token,返回
if (!token) {
ctx.status = 401;
ctx.body = 'token 不存在';
return
}
try {
// 根据token解析出用户id
const { id } = jwt.verify(token, key);
// 解析token错误,返回
if (!id) {
ctx.status = 401;
ctx.body = 'token 错误';
return
}
// 根据解析出来的id,获取用户
const user = await User.findById(id);
// 找不到用户,返回
if (!user) {
ctx.status = 401;
ctx.body = 'token 错误 , 用户不存在';
return
}
} catch (e) { // 出错,返回
ctx.status = 401;
ctx.body = 'jwt token error,解析token错误'
}
await next()
}
}
然后可以看看两个从url中分理出变量的中间件:
// middleware/getId
module.exports = options => {
return async (ctx, next) => {
// 取得 id
const id = ctx.params.id;
// 赋予资源 id
ctx.resource_id = id;
await next();
}
}
// middle/resource
const inflection = require('inflection')
module.exports = options => {
return async (ctx, next) => {
//console.log(ctx.params);
// 从 params 的 resource 中获取要操作的model
const modelName = inflection.classify(ctx.params.resource);
// 绑定要操作的 Model
ctx.Model = require(`../models/${modelName}`);
await next()
}
}
然后看到路由文件,根据egg对路由的定义,如下:
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
const db = require('./db/db');
module.exports = app => {
const { router, controller } = app;
db(app);
// 引入中间件
const resource = app.middleware.resource();
const response = app.middleware.response();
const get_id = app.middleware.getId();
const login = app.middleware.login();
const auth = app.middleware.auth();
const indexlogin = app.middleware.indexlogin();
router.get('/', controller.home.index);
// 其他路由看源码~~
};
以上只是路由文件的结构,具体路由定义请 git clone
后查看源码
由于代码过多,以下只给出关键的代码
在博客操作之中,我们需要上传图片,通过查阅其他大佬的博客才解决了问题,代码如下:
async uploadMarkdownImg() {
let parts = this.ctx.multipart({ autoFields: true });
let stream
let fileUrl
while ((stream = await parts()) != null) {
if (!stream.filename) {
break;
}
let filename = (new Date()).getTime() + Math.random().toString(36).substr(2) + path.extname(stream.filename).toLocaleLowerCase();
let target = 'app/public/markdown/' + filename;
fileUrl = 'http://127.0.0.1:7001/public/markdown/' + filename
let writeStream = fs.createWriteStream(target);
await pump(stream, writeStream);
};
this.ctx.body = fileUrl
}
在前端已表单form的形式提交数据,在后面的vue讲解之中会提到
至此,后台Egg部分就先讲这些,具体的实现请下载源码来查看:
https://github.com/li-car-fei/Vue-Eggjs-Blog
先通过几张图看一下基本的功能:
vue后台使用了element-ui进行搭建,用组件库的效率提升了很多~~
后台管理系统对于所有接口都需要鉴权,所以可以设置http拦截器,当拦截到后台返回token错误,就可以跳转到登录页面进行登录:
import axios from 'axios'
import Vue from 'vue'
import router from './router/index'
const http = axios.create({
baseURL: 'http://127.0.0.1:7001/admin/api'
});
// request 拦截器,设置请求头中的token
http.interceptors.request.use((request) => {
// 获取token
const token = sessionStorage.getItem('token') || '';
if (token) {
request.headers.Authorization = 'Carfied ' + token;
}
return request
}, (error) => {
window.alert('token 错误');
return Promise.reject(error);
});
// response 拦截器 , 通过判断 status 执行操作
http.interceptors.response.use((response) => {
return response
}, (error) => {
if (error.response.status === 500) {
Vue.prototype.$message({
type: 'error',
message: error.response.data
});
return
}
if (error.response.status === 401) {
// 跳转到登录界面
router.push('/login');
return
}
// 其他错误
return Promise.reject(error);
});
export default http
路由管理利用子路由的方法管理页面,同时,通过路由参数来判定数据的编辑或者新建,如下:
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import CategoryList from '../views/CategoryList.vue'
import CategoryEdit from '../views/CategoryEdit.vue'
import ArticleList from '../views/ArticleList.vue'
import ArticleEdit from '../views/ArticleEdit.vue'
import UserList from '../views/UserList.vue'
import UserEdit from '../views/UserEdit.vue'
import CommentEdit from '@/views/CommentEdit'
import CommentList from '@/views/CommentList'
import Login from '../views/Login.vue'
Vue.use(VueRouter)
const router = new VueRouter({
// 设置 H5 history 模式
mode: 'history',
routes: [
{
path: '/',
name: 'home',
component: Home,
children: [
{ path: '/', component: CategoryList, name: 'list' },
{ path: '/categories/create', component: CategoryEdit, name: 'create' },
{ path: '/categories/list', component: CategoryList, name: 'list' },
{ path: '/categories/create/:id', component: CategoryEdit, props: true, name: 'create' },
{ path: '/articles/list', component: ArticleList, name: "articles list" },
{ path: '/articles/create', component: ArticleEdit, name: "articles create" },
{ path: '/articles/create/:id', component: ArticleEdit, props: true, name: 'articles create' },
{ path: '/users/list', component: UserList, name: 'users list' },
{ path: '/users/create', component: UserEdit, name: "users create" },
{ path: '/users/create/:id', component: UserEdit, props: true, name: "users create" },
{ path: '/comments/list', component: CommentList, name: 'comments list' },
{ path: '/comments/create', component: CommentEdit, name: "comments create" },
{ path: '/comments/create/:id', component: CommentEdit, props: true, name: "comments create" },
]
},
{
path: '/login',
component: Login,
name: "login"
},
]
});
//跳转前设置title
router.beforeEach((to, from, next) => {
window.document.title = to.name;
next();
});
export default router
主页面主要是有一个侧边栏进行路由的跳转,使用element-ui的组件进行搭建:
<template>
<div>
<el-container style="height: 100vh; border: 1px solid #eee">
<el-aside width="200px" style="background-color: rgb(238, 241, 246)">
<el-menu router :default-openeds="['1', '3']">
<el-submenu index="1">
<template slot="title">
<i class="el-icon-message">i>内容管理
template>
<el-menu-item-group>
<template slot="title">分类template>
<el-menu-item index="/categories/create">新建分类el-menu-item>
<el-menu-item index="/categories/list">分类列表el-menu-item>
el-menu-item-group>
<el-menu-item-group>
<template slot="title">文章template>
<el-menu-item index="/articles/create">新建文章el-menu-item>
<el-menu-item index="/articles/list">文章列表el-menu-item>
el-menu-item-group>
<el-menu-item-group>
<template slot="title">用户template>
<el-menu-item index="/users/create">新建用户el-menu-item>
<el-menu-item index="/users/list">用户列表el-menu-item>
el-menu-item-group>
<el-menu-item-group>
<template slot="title">评论template>
<el-menu-item index="/comments/create">新建评论el-menu-item>
<el-menu-item index="/comments/list">评论列表el-menu-item>
el-menu-item-group>
el-submenu>
el-menu>
el-aside>
<el-container>
<el-header style="text-align: right; font-size: 12px">
<el-dropdown>
<span>{{username}}span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item>
<a @click="logout">退出a>
el-dropdown-item>
el-dropdown-menu>
el-dropdown>
el-header>
<el-main>
<router-view :key="$route.path">router-view>
el-main>
el-container>
el-container>
div>
template>
<style>
.el-header {
background-color: #b3c0d1;
color: #333;
line-height: 60px;
}
.el-aside {
color: #333;
}
style>
<script>
export default {
data() {
return {
username: ""
};
},
methods: {
logout() {
sessionStorage.clear();
this.$router.push("/login");
}
},
created() {
this.username = sessionStorage.getItem("username") || "";
}
};
script>
在文章的编辑页面中,回顾前面egg对文章图片的处理,进行如下处理:
handleEditorImgAdd(pos, $file) {
var formdata = new FormData();
formdata.append("file", $file);
this.$http.post("/markdown/upload/img", formdata).then(res => {
var url = res.data;
this.$refs.md.$img2Url(pos, url); //这里就是引用ref = md 然后调用$img2Url方法即可替换地址
});
}
而由于其他编辑页面都是一样的搭建,这里给出文章编辑页面完整代码,其他页面请读者git clone
后查看
<template>
<div class="page-cat-create">
<h3>{{id ? "编辑" : "新建"}}文章h3>
<el-form label-width="80px">
<el-form-item label="标题">
<el-input v-model="model.title">el-input>
el-form-item>
<el-form-item label="摘要">
<el-input type="textarea" v-model="model.summary">el-input>
el-form-item>
<el-form-item label="阅读量">
<el-input-number v-model="model.read">el-input-number>
el-form-item>
<el-form-item label="点赞量">
<el-input-number v-model="model.fav">el-input-number>
el-form-item>
<el-form-item label="分类">
<el-select multiple v-model="model.categories" placeholder="请选择文章分类">
<el-option
v-for="item in categories"
:key="item._id"
:label="item.title"
:value="item._id"
>el-option>
el-select>
el-form-item>
<el-form-item label="评论">
<el-select multiple v-model="model.comments" placeholder="请选择评论">
<el-option
v-for="item in comments"
:key="item._id"
:label="item.content"
:value="item._id"
>el-option>
el-select>
el-form-item>
<el-form-item label="置顶">
<el-switch v-model="model.isTop" active-text="是" inactive-text="否">el-switch>
el-form-item>
<el-form-item label="正文">
<mavon-editor
:toolbars="toolbars"
@imgAdd="handleEditorImgAdd"
@save="saveDoc"
style="height:600px"
v-model="model.MdContent"
ref="md"
/>
el-form-item>
<el-form-item>
<el-button type="primary" @click="save">保存el-button>
el-form-item>
el-form>
div>
template>
<script>
export default {
props: {
id: { require: true }
},
data() {
return {
categories: [],
model: {},
comments: [],
// markdown 工具栏参数设置
toolbars: {
bold: true, // 粗体
italic: true, // 斜体
header: true, // 标题
underline: true, // 下划线
strikethrough: true, // 中划线
mark: true, // 标记
superscript: true, // 上角标
subscript: true, // 下角标
quote: true, // 引用
ol: true, // 有序列表
ul: true, // 无序列表
link: true, // 链接
imagelink: true, // 图片链接
code: false, // code
table: true, // 表格
fullscreen: true, // 全屏编辑
readmodel: true, // 沉浸式阅读
htmlcode: true, // 展示html源码
help: true, // 帮助
undo: true, // 上一步
redo: true, // 下一步
trash: true, // 清空
save: true, // 保存(触发events中的save事件)
navigation: true, // 导航目录
alignleft: true, // 左对齐
aligncenter: true, // 居中
alignright: true, // 右对齐
subfield: true, // 单双栏模式
preview: true // 预览
}
};
},
methods: {
async save() {
// 先保存markdown原本的内容以及编译后的内容
this.model.body = this.$refs.md.d_render;
let res;
if (!this.id) {
res = await this.$http.post("/article", this.model);
} else {
res = await this.$http.put(`/article/${this.id}`, this.model);
}
if (res.status === 200) {
this.$message({
type: "success",
message: res.data
});
this.$router.push("/articles/list");
}
},
async fetchDetail() {
const res = await this.$http.get(`/article/${this.id}`);
this.model = res.data;
},
async fetchCategories() {
const res = await this.$http.get("/category");
this.categories = res.data;
},
async fetchComments() {
const res = await this.$http.get("/comment");
this.comments = res.data;
},
saveDoc(value, render) {
this.model.MdContent = value;
this.model.body = render;
},
//上传图片接口pos 表示第几个图片
handleEditorImgAdd(pos, $file) {
var formdata = new FormData();
formdata.append("file", $file);
this.$http.post("/markdown/upload/img", formdata).then(res => {
var url = res.data;
this.$refs.md.$img2Url(pos, url); //这里就是引用ref = md 然后调用$img2Url方法即可替换地址
});
}
},
created() {
this.fetchComments();
this.fetchCategories();
this.id && this.fetchDetail();
}
};
script>
vue 的入口文件:
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import Element from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import http from './http'
import dayjs from 'dayjs'
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
// 定义一个对时间的过滤器
Vue.filter('date', (val, type) => {
if (!val) {
return '';
}
return dayjs(val).format(type)
})
Vue.config.productionTip = false
Vue.use(Element);
Vue.use(mavonEditor);
Vue.prototype.$http = http;
/* eslint-disable no-new */
new Vue({
router,
render: h => h(App)
}).$mount('#app')
对于登录以及用户状态的处理,本例使用了session:
async login() {
const model = {
username: this.username,
password: this.password
};
const res = await this.$http.post("/login", model);
console.log(res);
if (res.status === 200) {
// 设置token以及username
sessionStorage.setItem("token", res.data.token);
sessionStorage.setItem("username", res.data.username);
// 提示信息
this.$message({
type: "success",
message: "登录成功"
});
// 路由跳转到主页
this.$router.push("/");
}
}
Vue 搭建主页的代码结构与管理页面差不多,有分页等功能,而由于主页的请求只有部分需要鉴权,本例中使用session本地储存信息,当进行需要鉴权的请求时先判定,若有token才进行请求,若没有则提醒登录:
async login() {
const model = {
username: this.username,
password: this.password
};
const res = await this.$http.post("/login", model);
console.log(res);
if (res.status === 200) {
// 设置token以及username
sessionStorage.setItem("token", res.data.token);
//sessionStorage.setItem("user_id", res.data.user);
//sessionStorage.setItem("username", res.data.username);
// 提示信息
this.$message({
type: "success",
message: "登录成功"
});
console.log(this.islogin);
this.$router.go(0);
}
}
类似于收藏文章这样的操作:
// 检查登录状态
computed: {
// 是否登陆了
islogin() {
return !!(sessionStorage.getItem("token") || "");
}
},
// 收藏文章
async user_fav_push() {
if (!this.islogin) {
this.$message({
type: "info",
message: "请先登录再收藏文章"
});
return;
}
const res = await this.$http.put(`/article/user/fav`, {
article_id: this.$route.params.id,
user_fav_change: !this.user_fav_in
});
//console.log(res.data);
this.$message({
type: "info",
message: res.data.message
});
this.check_user_fav();
},
// 用户评论
async post_comment() {
const islogin = this.islogin;
if (islogin) {
const post_comment = {
//user: sessionStorage.getItem("user_id"),
article: this.$route.params.id,
content: this.comment
};
this.loading = true;
const res = await this.$http.post("/comment", post_comment);
this.$message({
type: "info",
message: res.data.message
});
this.detail = res.data.data;
this.comment = "";
this.loading = false;
//this.get_art_detail(this.$route.params.id);
} else {
this.$message({
type: "warning",
message: "请先登录再发表评论"
});
}
}
对于markdown文章的显示,由于上文用到的组件已经把markdown编译成html代码并且存在数据库中,我们使用标签进行展示即可,使用vue的
v-html
属性赋予标签html内容,完整的界面代码如下:
<template>
<div>
<h2 style="color:rgb(39, 147, 219)">{{detail.title}}h2>
<span style="padding-right:20px" v-for="category in detail.categories" :key="category._id">
<el-tag style="cursor:pointer">{{category.title}}el-tag>
span>
<div style="color:rgb(85, 133, 165);margin-top:8px">
<span style="padding-right:80px">阅读: {{detail.read}}span>
<span>
<span style="padding-right:80px">
<el-button type="primary" size="small" @click="favin">
点赞:
<span v-if="fav_in">{{detail.fav}} +1span>
<span v-else>{{detail.fav}}span>
el-button>
span>
<span>
<el-button type="primary" size="small" @click="user_fav_push">
<span v-if="user_fav_in">取消收藏span>
<span v-else>收藏span>
<i v-if="!user_fav_in" class="el-icon-star-off">i>
el-button>
span>
span>
div>
<div style="color:rgb(85, 133, 165);margin-top:8px">
<span style="padding-right:80px">createdAt: {{detail.createdAt|date}}span>
<span>updatedAt: {{detail.updatedAt|date}}span>
div>
<hr />
<article v-html="detail.body">article>
<hr />
<div style="color:rgb(85, 133, 165);margin-top:8px">评论区:div>
<el-card
v-for="comment in detail.comments"
:key="comment._id"
:body-style="{ background: 'rgba(39, 129, 182, 0.4)' }"
>
<time style="font-weight: 200">{{comment.createdAt|date}}time>
<div>{{comment.user.username}}说:div>
<div style="padding-left:70px">{{comment.content}}div>
el-card>
<el-divider>
<i class="el-icon-mobile-phone">i>
el-divider>
<div>
<el-input
style="margin-bottom:8px"
v-model="comment"
placeholder="在此可输入评论"
clearable
@change="post_comment"
>el-input>
<el-button type="primary" @click="post_comment" :loading="loading">发送el-button>
div>
div>
template>
<script>
export default {
data() {
return {
fav_in: false, //是否点赞
detail: {}, //内容详情
comment: "", //评论内容
loading: false, //评论过程post
user_fav_in: undefined //当前用户是否收藏了此文章
};
},
computed: {
// 是否登陆了
islogin() {
return !!(sessionStorage.getItem("token") || "");
}
},
methods: {
// 获取文章详情信息
async get_art_detail(id) {
const res = await this.$http.get(`/article/${id}`);
//console.log(res.data);
this.detail = res.data;
},
// 点赞
async favin() {
const id = this.$route.params.id;
const res = await this.$http.get(`/article/fav/${id}`);
this.fav_in = true;
//console.log(res.data);
this.$message({
type: "info",
message: res.data
});
},
// 检查此文章是否被当前用户收藏了
async check_user_fav() {
//const islogin=!!(sessionStorage.getItem('token')||"");
if (!this.islogin) {
return;
}
const article_id = this.$route.params.id;
const res = await this.$http.get(`/article/favinuser/${article_id}`);
//console.log(res.data.result);
this.user_fav_in = res.data.result;
},
// 收藏文章
async user_fav_push() {
if (!this.islogin) {
this.$message({
type: "info",
message: "请先登录再收藏文章"
});
return;
}
const res = await this.$http.put(`/article/user/fav`, {
article_id: this.$route.params.id,
user_fav_change: !this.user_fav_in
});
//console.log(res.data);
this.$message({
type: "info",
message: res.data.message
});
this.check_user_fav();
},
// 用户评论
async post_comment() {
const islogin = this.islogin;
if (islogin) {
const post_comment = {
//user: sessionStorage.getItem("user_id"),
article: this.$route.params.id,
content: this.comment
};
this.loading = true;
const res = await this.$http.post("/comment", post_comment);
this.$message({
type: "info",
message: res.data.message
});
this.detail = res.data.data;
this.comment = "";
this.loading = false;
//this.get_art_detail(this.$route.params.id);
} else {
this.$message({
type: "warning",
message: "请先登录再发表评论"
});
}
}
},
created() {
this.get_art_detail(this.$route.params.id); //获取详细信息
this.check_user_fav(); //检查是否被当前用户收藏
}
};
script>
<style>
style>
上文大概介绍了如何运用egg和vue搭建出全栈博客,还有许多功能没有实现,希望大家可以完善
如果希望查看源码或者git fork
,这是github地址:
https://github.com/li-car-fei/Vue-Eggjs-Blog
希望能给我个star~~