vuejs、eggjs、mqtt全栈式开发简单设备管理系统

业余时间用eggjs、vuejs开发了一个设备管理系统,通过mqtt协议上传设备数据至web端实时展现,包含设备参数分析、发送设备报警等模块。收获还是挺多的,特别是vue的学习,这里简单记录一下:

源码地址:https://github.com/caiya/vuejs-admin,写文不易,有帮助的话麻烦给个star,感谢!

技术栈

前端:vue、vuex、vue-router、element-ui、axios、mqttjs

后端:eggjs、mysql、sequlize、restful、oauth2.0、mqtt、jwt

  • 用户模块(用户管理,用户增删改查)
  • 设备模块(设备管理、设备参数监控、设备参数记录、设备类别管理、参数管理等)
  • 授权模块(引入OAuth2.0授权服务,方便将接口以OAuth提供第三方)
  • 消息模块(用户申请帮助消息、设备参数告警消息等)

效果图(对一个后端css永远是内伤)

登录页:

主页:

设备页:

设备参数监控页:

前台

项目结构

前端使用vue-cli脚手架构建,基本目录结构如下:

main.js入口

vue项目的入口文件,这里主要是引入iconfont、element-ui、echarts、moment、vuex等模块。

// 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 { axios } from './http/base' import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css' import './assets/fonts/iconfont.css' import ECharts from 'vue-echarts/components/ECharts'
// import ECharts modules manually to reduce bundle size
import 'echarts/lib/chart/line'
import 'echarts/lib/component/tooltip' // register component to use
Vue.component('chart', ECharts) import store from './store' import moment from 'moment'
Vue.prototype.$moment = moment Vue.use(ElementUI) // 引入mqtt
import './mq' Vue.config.productionTip = false // 挂载到prototype上面,确保组件中可以直接使用this.axios
// Vue.prototype.axios = axios /* eslint-disable no-new */
new Vue({
el: '#app',
router,
store,
components: { App },
template: '<App/>'
})
注意:
1、引入比较大的模块比如echarts时,尽量手动按需进行模块导入,节省打包文件大小
2、一般通过将模块比如moment挂载到Vue的prototype上面,这样就可以在任意vue组件中使用*this.$moment*进行moment操作了
3、iconfont是阿里的图标样式,下载下来后放入assets中再引入即可

vuex引入

vuex引入的时候采用了模块话引入,入口文件代码为:

import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import devArgsMsg from './modules/devArgsMsg' Vue.use(Vuex) export default new Vuex.Store({
modules: {
user,
devArgsMsg
}
})

其中user、devArgsMsg为两个独立模块,这样分模块引入可以避免项目过大结构不清晰的问题。其中user.js模块代码:

import * as TYPES from '../mutation.types'

const state = {
userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}'),
token: localStorage.getItem('token') || ''
} const actions = { } const mutations = {
[TYPES.LOGIN]: (state, loginData) => {
state.userInfo = loginData.user
state.token = loginData.token
localStorage.setItem('userInfo', JSON.stringify(loginData.user))
localStorage.setItem('token', loginData.token)
},
[TYPES.LOGOUT]: state => {
state.userInfo = {}
state.token = ''
localStorage.removeItem('userInfo')
localStorage.removeItem('token')
}
} const getters = { } export default {
state,
actions,
mutations,
getters
}

关于mutations.type.js:

// 各种mutation类型

// 用户模块
export const LOGOUT = 'LOGOUT'
export const LOGIN = 'LOGIN' // 设备模块
export const SETDEVARGSMSG = 'setDevArgsMsg'
注意:
1、mutations的名称定义时遵循官方,一般定义为常量
2、state的数据只有通过mutation才能操作,不能直接在组件中设置state,否则无效
3、mutation中的操作都是同步操作,异步操作或网络请求或同时多个mutation操作可以放入action中进行
4、用户信息、登录token一般放入h5的localStorage,这样刷新页面保证关键数据不丢失
5、vuex中的getters相当于state的计算属性,监听state数据变动时可以使用getters

vue-router路由模块

路由模块基本使用:

import Vue from 'vue'
import Router from 'vue-router' import store from '../store' Vue.use(Router) const router = new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'Login',
component: resolve => require(['@/views/auth/Login'], resolve)
},
{
path: '', // 默认地址为登录页
name: '',
component: resolve => require(['@/views/auth/Login'], resolve)
},
{
path: '/main',
name: '',
component: resolve => require(['@/views/Main'], resolve),
meta: {
requireAuth: true, // 添加该字段,表示进入这个路由是需要登录的
nav: '欢迎页'
},
children: [{
path: 'user',
component: resolve => require(['@/views/user/List'], resolve),
name: 'UserList',
meta: {
requireAuth: true,
nav: '用户管理',
activeItem: '1-1'
},
}, {
path: 'user/setting/:userId?',
name: 'UserSetting',
component: resolve => require(['@/views/user/Setting'], resolve),
meta: {
requireAuth: true,
nav: '资料设置',
activeItem: '1-2'
},
}, {
path: 'device',
component: resolve => require(['@/views/device/List'], resolve),
name: 'Device',
meta: {
requireAuth: true,
nav: '设备列表',
activeItem: '3-1'
},
},{
path: 'device/edit/:devId?',
component: resolve => require(['@/views/device/Edit'], resolve),
name: 'DeviceEdit',
meta: {
requireAuth: true,
nav: '设备编辑',
activeItem: '3-1'
},
},{
path: 'device/type',
component: resolve => require(['@/views/devType/List'], resolve),
name: 'DevTypeList',
meta: {
requireAuth: true,
nav: '设备类别',
activeItem: '3-2'
},
}, {
path: 'device/arg',
component: resolve => require(['@/views/devArg/List'], resolve),
name: 'DevArgList',
meta: {
requireAuth: true,
nav: '设备参数',
activeItem: '3-3'
},
},{
path: 'device/monitor',
component: resolve => require(['@/views/device/Monitor'], resolve),
name: 'DevMonitor',
meta: {
requireAuth: true,
nav: '设备监控',
activeItem: '3-4'
},
}, {
path: '', // 后台首页默认页
component: resolve => require(['@/views/common/Welcome'], resolve),
name: 'Welcome',
meta: {
requireAuth: true,
nav: '欢迎页'
},
}]
}
]
})

其中,每个路由的meta元数据中加入requireAuth字段,以便识别该路由是否需要授权,再在router.beforeEach的钩子函数中作相应判断:

router.beforeEach((to, from, next) => {
if (to.path === '/' && store.state.user.token) {
return next('/main')
}
if (to.meta.requireAuth) { // 如果需要拦截
if (store.state.user.token) {
next()
} else {
next({
path: '/',
query: {
redirect: to.fullPath
}
})
}
} else {
next()
}
}) export default router

其中store.state.user.token为用户登录成功后写入vuex中的token数据,这里用来判断是否已登录,已登录过的再次访问首页(登录页)则直接跳转至后台主页,否则重定向至登录页。

axios发送http请求

axios是vue官方推荐的xmlhttprequest类库,使用起来比较方便:

/*
* @Author: cnblogs.com/vipzhou
* @Date: 2018-02-22 21:29:32
* @Last Modified by: mikey.zhaopeng
* @Last Modified time: 2018-02-22 21:48:40
*/
import axios from 'axios' import router from '../router'
import store from '../store' // axios 配置
axios.defaults.timeout = 10000
axios.defaults.baseURL = '/api/v1' // 请求拦截器
axios.interceptors.request.use(config => {
if (store.state.user.token) { // TODO 判断token是否存在
config.headers.Authorization = `Bearer ${store.state.user.token}`
}
return config
}, err => {
return Promise.reject(err)
}) axios.interceptors.response.use(response => {
return response
}, err => {
if (err.response) {
switch (err.response.status) {
case 401:
store.commit('LOGOUT')
router.replace({ path: '/', query: { redirect: router.currentRoute.fullPath } })
break
case 403:
store.commit('LOGOUT')
router.replace({ path: '/', query: { redirect: router.currentRoute.fullPath } })
break
}
}
return Promise.reject(new Error(err.response.data.error || err.message))
}) /**
* @param {string} url
* @param {object} params={}
*/
const fetch = (url, params = {}) => {
return new Promise((resolve, reject) => {
axios.get(url, {
params
}).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
}
/**
* @param {string} url
* @param {object} data={}
*/
const post = (url, data = {}) => {
return new Promise((resolve, reject) => {
axios.post(url, data).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
} /**
* @param {string} url
* @param {object} data={}
*/
const put = (url, data = {}) => {
return new Promise((resolve, reject) => {
axios.put(url, data).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
}
/**
* @param {string} url
* @param {object} params={}
*/
const del = (url) => {
return new Promise((resolve, reject) => {
axios.delete(url, {}).then(res => {
resolve(res.data)
}).catch(err => {
reject(err)
})
})
} export { axios, fetch, post, put, del }

封装完基本http请求之后,其余模块在改基础上封装即可,比如用户user.js的http:

/*
* @Author: cnblogs.com/vipzhou
* @Date: 2018-02-22 21:30:19
* @Last Modified by: vipzhou
* @Last Modified time: 2018-02-24 00:12:00
*/ import * as http from './base' /**
* 登陆
* @param {object} data
*/
const login = (data) => {
return http.post('/users/login', data)
} /**
* 获取用户列表
* @param {object} params
*/
const getUserList = params => {
return http.fetch('/users', params)
}
/**
* 删除用户
* @param {object} params
*/
const deleteUserById = id => {
return http.del(`/users/${id}`)
}
/**
* 获取用户详情
* @param {id} id
*/
const getUserDetail = id => {
return http.fetch(`/users/${id}`, {})
} /**
* 保存用户信息
* @param {object} user
*/
const updateUserInfo = user => {
if (!user.id) {
return Promise.reject(new Error(`arg id can't be null`))
}
return http.put(`/users/${user.id}`, user)
} /**
* 添加用户
* @param {user对象} user
*/
const addUser = user => {
return http.post('/users', Object.assign({
password: '123456'
}, user))
} /**
* 退出登陆
* @param {email} email
*/
const logout = email => {
return http.post('/users/logout', {
email
})
} export { login, getUserList, deleteUserById, getUserDetail, updateUserInfo, addUser, logout }
注意:
1、通过baseURL配置项可以配置接口的基础path
2、通过request的interceptors,可以实现任意请求前先判断本地有无token,有的话写入header或query等地方,从而实现token发送
3、通过response的interceptors可以对响应数据做进一步处理,比如401或403跳转至登录页、报错时直接reject返回err信息等
4、基本的rest请求方式代码封装基本一致,只是method不同而已

关于mqtt模块

mqtt是一种传输协议,转为IOT物联网模块而生,特点是长连接、轻量级等,nodejs使用mqtt模块作为客户端,每个mqtt都有一个server端(mqtt broker),这里使用公共broker:ws://mq.tongxinmao.com:18832/web

mqtt采用简单的发布订阅模式,消息发布者(一般是设备端)发布设备相关消息至某个topic(topic支持表达式写法),消费者(一般是各个应用程序)接收消息并持久化处理等。

import mqtt from "mqtt"
import Vue from "vue"
import store from '../store' import { Notification } from 'element-ui' let client = null // 开启订阅(登录成功后调用)
export const startSub = () => {
client = mqtt.connect("ws://mq.tongxinmao.com:18832/web")
client.on("connect", () => {
client.subscribe("msgNotice") // 订阅消息类通知主题
client.subscribe("/devices/#") // 订阅所有设备相关主题
console.log("链接mqtt成功,并已订阅相关主题")
}).on('error', err => {
console.log("链接mqtt报错", err)
client.end()
client.reconnect()
}).on("message", (topic, message) => {
console.log('topic', topic);
// message is Buffer
if (topic + '' === 'msgNotice') { // 消息类通知主题
Notification({
title: '通知',
type: "success",
message: JSON.parse(message.toString()).msg
})
} else { // 设备相关主题,这里将各个模块消息写入各个模块的vuex state中,然后各个模块再getter取值
const devId = topic.substring(9);
const arg = {
devId,
msg: message.toString()
}
console.log('收到设备上传消息:', arg);
store.commit('setDevArgsMsg', arg);
}
}) Vue.prototype.$mqtt = client // 方便在vue组件中可以直接使用this.$mqtt -> client
} // 关闭订阅(退出登录时调用)
export const closeSub = () => {
client && client.end()
}
注意:
1、前台应用作为一个mqtt客户端,后台也作为一个客户端,所有的实时设备消息前后端都能接收到,前端负责展现层、后端负责持久层
2、前后端只需监听/devices/#主题即可,所有的设备消息都发送到/devices/设备id,这样前后端获取topic名称即可判断当前消息来源于哪个设备
3、mqtt链接error时采用client.reconnect()进行重连操作
4、mqtt还负责用户登录、退出之类的消息推送,收到消息直接调用element-ui中的Notification提示即可
5、设备参数实时消息mqtt接收到后存入vuex的state中,各个组件再使用getters监听取值再实时图表展示

关于mqtt实时推送

设备端发送的实时参数消息发送至主题/devices/设备id,消息格式为:参数名1:参数实时值1|参数名2:参数实时值2|参数名3:参数实时值3...

浏览器端mqtt收到的实时消息通过store.commit('setDevArgsMsg', arg);放入vuex中,其中arg格式为:

{
devId, // 当前设备id
msg: message.toString() // 报警消息
}

vuex中的写法为:

const mutations = {
[TYPES.SETDEVARGSMSG]: (state, {msg = '', devId = ''}) => {
const time = moment().format('YYYY/MM/DD HH:mm:ss')
const argValues = msg.split('|')
argValues.forEach(item => {
state.msgs.push({
name: time,
value: [time, item.split(':')[1], item.split(':')[0], devId],
})
})
}
} const getters = {
doneMsg: state => {
return state.msgs
}
}

拿到实时消息遍历取出存入state中,这里声明doneMsg这个getters,方便在监控页面直接监听,监控页面写法:

前端遇到的问题

主页左侧菜单栏页面刷新时高亮丢失

解决办法是:在每个router的meta中定义activeItem字段,表示当前路由对应高亮的左侧菜单:

面包屑导航动态改变

解决办法是:监听$route路由对象,重新设置导航内容:

后端

后端接口使用restful风格,提供OAuth2授权,基于eggjs、mysql开发:

Eggjs中使用koa2中间件

其实只需要在config.default.js中设置中间件:

// add your config here
config.middleware = ['errorHandler', 'auth'];

然后再在app/middleware目录下建立一个同名文件,比如:err_handler.js,然后写入中间件内容即可。

使用koa2中间件,直接引入:

module.exports = require('koa-jwt')

使用自定义中间件,写法如下:

module.exports = () => {
return (ctx, next) => {
return next().catch (err => {
console.log('err: ', err)
// 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
ctx.app.emit('error', err, ctx); const status = err.status || 500;
// 生产环境时 500 错误的详细错误内容不返回给客户端,因为可能包含敏感信息
const error = status === 500 && ctx.app.config.env === 'prod'
? 'Internal Server Error'
: err.message; // 从 error 对象上读出各个属性,设置到响应中
ctx.body = { error };
if (status === 422) {
ctx.body.error_description = err.errors;
}
ctx.status = status;
})
}
};

关于路由

项目路由不算复杂,rest风格路由定义也比较简单:

'use strict';

/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app; // OAuth controller
app.get('/oauth2', controller.oauth.authorize);
app.all('/oauth2/token', app.oAuth2Server.token(), 'oauth.token'); // 获取token
app.all('/oauth2/authorize', app.oAuth2Server.authorize()); // 获取授权码
app.all('/oauth2/authenticate', app.oAuth2Server.authenticate(), 'oauth.authenticate'); // 验证请求 // rest接口
router.post('/api/v1/users/login', controller.v1.users.login);
router.post('/api/v1/users/logout', controller.v1.users.logout);
router.post('/api/v1/tools/upload', controller.v1.tools.upload);
router.resources('users', '/api/v1/users', controller.v1.users);
...其它接口省略
};

Jwt验证

前后端接口统一采用jwt验证,用户登录成功时调用jwt sign服务生成token返回:

const ctx = this.ctx
ctx.validate(users_rules.loginRule)
const {email, password} = ctx.request.body
const user = await ctx.model.User.getUserByArgs({email}, '')
if (!user) {
ctx.throw(404, 'email not found')
}
if (!(ctx.service.user.compareSync(password, user.hashedPassword))) {
ctx.throw(404, 'password wrong')
}
delete user.dataValues.hashedPassword // 发送登录通知
msgNoticePub({msg: `用户${user.email}在${moment().format('YYYYMMDD hh:mm:ss')}登录系统,点击查看用户信息`, type: 'login'}) ctx.body = {
user,
token: await ctx.service.auth.sign(user) // 生成jwt token
}

这里的auth.sign的service写法如下:

const Service = require('egg').Service;
const jwt = require('jsonwebtoken') class AuthService extends Service {
sign(user) {
let userToken = {
id: user.id
}
const token = jwt.sign(userToken, this.app.config.auth.secret, {expiresIn: '7d'})
return token
} } module.exports = AuthService;

Postal.js发布订阅

使用postal.js发布订阅,确保代码模块清晰,postal的发布订阅模式简单如下:

postal.publish({    // 動態讓客戶端訂閲
channel: "msg",
topic: "item.notice",
data: {...data} // 发送的消息 {msg: "xxx设备掉线了...."}
})
// 动态给前端推送消息
postal.subscribe({
channel: "msg",
topic: "item.notice",
callback: function (data, envelope) {
client.publish('msgNotice', JSON.stringify(data)) // 向前端发布消息
console.log('向前端推送消息成功:', JSON.stringify(data))
}
})

Model模型定义

eggjs下定义数据库数据模型比较简单,在app/model目录下新建任意文件,如下是定义一个role模型:

'use strict'

module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize; const Role = app.model.define('role', {
role: {type: STRING, allowNull: false, unique: true}, // 角色名英文
roleName: {type: STRING, allowNull: false, unique: true}, // 角色名称(中文)
pid: TEXT, // 权限id集合
permission: TEXT // 权限url集合
}, {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
freezeTableName: true
}); return Role;
};

关于部署

eggjs还是比较nice的一个框架,部署时可以摆脱pm2,egg-cluster也比较稳定,适合直接线上部署,直接上线后:

npm start   // 启动应用
npm stop // 停止应用

nginx部署前端也比较简单就不说明了,简单记录就这么多,有机会再分享。

vuejs、eggjs全栈式开发设备管理系统的更多相关文章

  1. vuejs、eggjs、mqtt全栈式开发设备管理系统

    vuejs.eggjs.mqtt全栈式开发简单设备管理系统 业余时间用eggjs.vuejs开发了一个设备管理系统,通过mqtt协议上传设备数据至web端实时展现,包含设备参数分析.发送设备报警等模块 ...

  2. 基于NodeJS的全栈式开发

    前言 为了解决传统Web开发模式带来的各种问题,我们进行了许多尝试,但由于前/后端的物理鸿沟,尝试的方案都大同小异.痛定思痛,今天我们重新思考了“前后端”的定义,引入前端同学都熟悉的 NodeJS,试 ...

  3. (转)也谈基于NodeJS的全栈式开发(基于NodeJS的前后端分离)

    原文链接:http://ued.taobao.org/blog/2014/04/full-stack-development-with-nodejs/ 随着不同终端(pad/mobile/pc)的兴起 ...

  4. 也谈基于NodeJS的全栈式开发(基于NodeJS的前后端分离)

    前言 为了解决传统Web开发模式带来的各种问题,我们进行了许多尝试,但由于前/后端的物理鸿沟,尝试的方案都大同小异.痛定思痛,今天我们重新思考了“前后端”的定义,引入前端同学都熟悉的NodeJS,试图 ...

  5. 基于NodeJS的全栈式开发(基于NodeJS的前后端分离)

    也谈基于NodeJS的全栈式开发(基于NodeJS的前后端分离) 前言 为了解决传统Web开发模式带来的各种问题,我们进行了许多尝试,但由于前/后端的物理鸿沟,尝试的方案都大同小异.痛定思痛,今天我们 ...

  6. 大数据全栈式开发语言 – Python

    前段时间,ThoughtWorks在深圳举办一次社区活动上,有一个演讲主题叫做“Fullstack JavaScript”,是关于用JavaScript进行前端.服务器端,甚至数据库(MongoDB) ...

  7. 为什么说Python 是大数据全栈式开发语言

    欢迎大家访问我的个人网站<刘江的博客和教程>:www.liujiangblog.com 主要分享Python 及Django教程以及相关的博客 交流QQ群:453131687 原文链接 h ...

  8. 全栈式JavaScript

    如今,在创建一个Web应用的过程中,你需要做出许多架构方面的决策.当然,你会希望做的每一个决定都是正确的:你想要使用能够快速开发的技术,支持持续的迭代,最高的工作效率,迅速,健壮性强.你想要精益求精并 ...

  9. 全栈式框架的选择:MEAN or MEANS?

    说明:个人博客地址为edwardesire.com,欢迎前来品尝.本博客作为备份和引流 这两个月一直在进行sails后端开发,其中遇到的问题不断.放在研究用户访问控制矸例程上的时间太多,最后也没用弄出 ...

随机推荐

  1. 【编程技巧】NSDate,NSDateFormatter,NSTimeInterval

    //获取日期 todaysDate=[NSDate date]; //显示日期和时间 dateFormat = [[NSDateFormatter alloc] init];//NSDate没有自己的 ...

  2. List Set Map 的区别 用法以及特点(转载)

    List,Set,Map是否继承自Collection接口?  答:List,Set是,Map不是. Collection是最基本的集合接口,一个Collection代表一组Object,即Colle ...

  3. php实现监控在线服务应用程序小栗子

    下面我就给大家举个栗子(例子) 某单位需要实现监控服务器状态,和监控服务器应用网站,还有需要监控服务器的中间件,数据库状态监控.听到这个任务是不是恨透头疼,这想起来是不是头疼.还好有系统可用,但是我现 ...

  4. 04_Javascript初步第三天

    事件 内联模型.脚本模型,DOM2级模型 <!--内联模型--> <input type="button" value="bt1" oncli ...

  5. phpmailer发送邮件服务

    获取开源的phpmail类 开启stmp服务登录126/163邮箱 ->设置->POPS/SMTP/IMAP(开启需要的服务,并点击保存,初次使用会要求设置一个授权码) 测试 <?p ...

  6. iOS-cocoapods安装与使用以及常见错误

    前言 CocoaPods是一个负责管理iOS项目中第三方开源代码的工具. 二.安装由于网上的教程基本都大同小异,但细节之处还不是很完善,所以借机会在这里补充下:注:要使用CocoaPods,那就要下载 ...

  7. unity getcomponentsinchildren 翻船

    今天使用GetComponentsInChildren, 老司机翻船.因为一直以来我使用这个函数,下意识的从来所有的相同component都是放在子节点下,本身节点肯定不会放一个相同的componen ...

  8. viim命令行模式查找替换

    1.查找 /   向上查找 ? 向下查找 2.替换 1.:s/vivian/sky/ 替换当前行第一个 vivian 为 sky :s/vivian/sky/g 替换当前行所有 vivian 为 sk ...

  9. js和java中使用正则表达式校验邮箱

    问题:经常在项目中要校验邮箱? 邮箱格式:首位必须为字母,必须包含一个@符号,并且@之后有个名字,之后还有个.,再有一个后缀名 例如:wyp55023@163.com 一.java中代码如下: Str ...

  10. ABP官方文档翻译 7.2 Hangfire集成

    Hangfire集成 介绍 ASP.NET Core集成 ASP.NET MVC 5.x集成 面板授权 介绍 Hangfire是一个综合的后台job管理器.你可以 把它集成到ABP,用来取代默认的后台 ...