博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
React + Node.JS 巧妙实现后台管理系统の各种小技巧(前后端)
阅读量:4085 次
发布时间:2019-05-25

本文共 17019 字,大约阅读时间需要 56 分钟。

此后台系统是为了搭配我的另一个项目 School-Partners学习伴侣微信小程序而开发的。是一个采用Taro多端框架开发的跨平台的小程序。感兴趣的可以看一下之前的文章

这篇文章主要是分享一下在开发这个东东的时候,遇到的一些问题,以及一些技术的巧妙的方法分享给大家,如果对大家有帮助的话,请给我点赞一下给个star鼓励一下~无比感谢嘿嘿

希望大佬们走过路过可以给个star鼓励一下~感激不尽~

这个是小程序的介绍文章

无图无真相!先上几个图~

运行截图

1. 登录界面

2. 题库管理

3. 修改题库

技术分析

就来说一下项目中自己推敲做出来的几个算是亮点的东西吧

1. 使用Hook封装API访问工具

本项目采用的UI框架是Ant-Design框架

因为这个项目的后台对于表格有着比较大的需求,而表格加载就需要使用到Loading的状态,所以就特地封装一下便于之后使用

首先我们先新建一个文件useService.ts

然后我们先引入axios来作为我们的api访问工具

import axios from 'axios'const instance = axios.create({  baseURL: '/api',  timeout: 10000,  headers: {    'Content-Type': "application/json;charset=utf-8",  },})instance.interceptors.request.use(  config => {    const token = localStorage.getItem('token');    if (token) {      config.headers.common['Authorization'] = token;    }    return config  },  error => {    return Promise.reject(error)  })instance.interceptors.response.use(  res => {    let { data, status } = res    if (status === 200) {      return data    }    return Promise.reject(data)  },  error => {    const { response: { status } } = error    switch (status) {      case 401:        localStorage.removeItem('token')        window.location.href = './#/login'        break;      case 504:        message.error('代理请求失败')    }    return Promise.reject(error)  })

先将axios的拦截器,基本配置这些写好先

接着我们实现一个获取接口信息的方法useServiceCallback

const useServiceCallback = (fetchConfig: FetchConfig) => {  // 定义状态,包括返回信息,错误信息,加载状态等  const [isLoading, setIsLoading] = useState
(false) const [response, setResponse] = useState
(null) const [error, setError] = useState
(null) const { url, method, params = {}, config = {} } = fetchConfig const callback = useCallback( () => { setIsLoading(true) setError(null) // 调用axios来进行接口访问,并且将传来的参数传进去 instance(url, { method, data: params, ...config }) .then((response: any) => { // 获取成功后,则将loading状态恢复,并且设置返回信息 setIsLoading(false) setResponse(Object.assign({}, response)) }) .catch((error: any) => { const { response: { data } } = error const { data: { msg } } = data message.error(msg) setIsLoading(false) setError(Object.assign({}, error)) }) }, [fetchConfig] ) return [callback, { isLoading, error, response }] as const}

这样就完成了主体部分了,可以利用这个hook来进行接口访问,接下来我们再做一点小工作

const useService = (fetchConfig: FetchConfig) => {  const preParams = useRef({})  const [callback, { isLoading, error, response }] = useServiceCallback(fetchConfig)  useEffect(() => {    if (preParams.current !== fetchConfig && fetchConfig.url !== '') {      preParams.current = fetchConfig      callback()    }  })  return { isLoading, error, response }}export default useService

我们定义一个useService的方法,我们通过定义一个useRef来判断前后传过来的参数是否一致,如果不一样且接口访问配置信息的url不为空就可以开始调用useServiceCallback方法来进行接口访问了

具体使用如下:

我们先在组件内render外使用这个钩子,并且定义好返回的信息

接口返回体如下

const { isLoading = false, response } = useService(fetchConfig)const { data = {} } = response || {}const { exerciseList = [], total: totalPage = 0 } = data

因为我们这个hook是依赖fetchConfig这个对象的,这里是他的类型

export interface FetchConfig {  url: string,  method: 'GET' | 'POST' | 'PUT' | 'DELETE',  params?: object,  config?: object}

所以我们只需要再页面加载时候调用useEffect来进行更新这个fetchConfig就可以触发这个获取数据的hook啦

const [fetchConfig, setFetchConfig] = useState
({ url: '', method: 'GET', params: {}, config: {} }) ... useEffect(() => { const fetchConfig: FetchConfig = { url: '/exercises', method: 'GET', params: {}, config: {} } setFetchConfig(Object.assign({}, fetchConfig)) }, [fetchFlag])

这样就大功告成啦!然后我们再到表格组件内传入相关数据就可以啦

setCurrentPage(pageNo) }} locale={
{ emptyText: }} />

大功告成!!

2. 实现懒加载通用组件

我们这里使用的是react-loadable这个组件,挺好用的嘿嘿,搭配nprogress来进行过渡处理,具体效果参照github网站上的加载效果

我们先封装好一个组件,在components/LoadableComponent内定义如下内容

import React, { useEffect, FC } from 'react'import Loadable from 'react-loadable'import NProgress from 'nprogress'import 'nprogress/nprogress.css'const LoadingPage: FC = () => {  useEffect(() => {    NProgress.start()    return () => {      NProgress.done()    }  }, [])  return (    
)}const LoadableComponent = (component: () => Promise
) => Loadable({ loader: component, loading: () =>
,})export default LoadableComponent

我们先定义好一个组件LoadingPage这个是我们再加载中的时候需要展示的页面,在useEffect中使用nprogress的加载条进行显示,组件卸载时候则结束,而下面的div则可以由用户自己定义需要展示的样式效果

下面的LoadableCompoennt就是我们这个的主体,我们需要获取到一个组件,赋值给loader,具体的赋值方法如下,我们可以在项目内的pages部分将所有需要展示的页面引入进来,再导出,这样就可以方便的实现所有页面的懒加载了

// 引入刚刚定义的懒加载组件import { LoadableComponent } from '@/admin/components'// 定义组件,传给LoadableCompoennt组件需要的组件信息const Login = LoadableComponent(() => import('./Login'))const Register = LoadableComponent(() => import('./Register'))const Index = LoadableComponent(() => import('./Index/index'))const ExerciseList = LoadableComponent(() => import('./ExerciseList'))const ExercisePublish = LoadableComponent(() => import('./ExercisePublish'))const ExerciseModify = LoadableComponent(() => import('./ExerciseModify'))// 导出,到时候再从这个pages/index.ts中引入,即可拥有懒加载效果了export {  Login,  Register,  Index,  ExerciseList,  ExercisePublish,  ExerciseModify}

大功告成!!!

3. 使用嵌套路由

项目因为涉及到后台信息的管理,所以个人认为导航栏与主题信息栏应该一同显示,如同下图

这样可以清晰的展示出信息以及给用户提供导航效果

我们现在项目的routes/index.tsx定义一个全局通用的路由组件

import React from 'react'import {  Switch, Redirect, Route,} from 'react-router-dom'// 这个是私有路由,下面会提到import PrivateRoute from '../components/PrivateRoute'import { Login, Register } from '../pages'import Main from '../components/Main/index'const Routes = () => (  
)export default Routes

这里的意思就是,登录以及注册页面是独立开来的,而Main这个组件就是负责包裹导航条以及内容部分的组件啦

接下来看看components/Main中的内容吧

import React, { ComponentType } from 'react'import { Layout } from 'antd';import HeaderNav from '../HeaderNav'import ContentMain from '../ContentMain'import SiderNav from '../SiderNav'import './index.scss'const Main = () => (  
// 头部导航栏
// 侧边栏
// 主体内容
)export default Main as ComponentType

接下来重点就是这个ContentMain组件啦

import React, { FC } from 'react'import { withRouter, Switch, Redirect, RouteComponentProps, Route } from 'react-router-dom'import { Index, ExerciseList, ExercisePublish, ExerciseModify } from '@/admin/pages'import './index.scss'const ContentMain: FC
= () => { return (
)}export default withRouter(ContentMain)

这个就是一个嵌套路由啦,在这里面使用withRouter来包裹一下,然后在这里再次定义路由信息,这样就可以只切换主体部分的内容而不改变导航栏啦

大功告成!!!

4. 侧边栏的选中部分动态变化

通过图片我们可以看出,侧边导航栏有一个选中的内容,那么我们该如何判断不同的url页面对应哪一个选中部分呢?

const [selectedKeys, setSelectedKeys] = useState(['index'])  const [openedKeys, setOpenedKeys] = useState([''])  const { location: { pathname } } = props  const rank = pathname.split('/')  useEffect(() => {    switch (rank.length) {      case 2: // 一级目录        setSelectedKeys([pathname])        setOpenedKeys([''])        break      case 4: // 二级目录        setSelectedKeys([pathname])        setOpenedKeys([rank.slice(0, 3).join('/')])        break    }  }, [pathname])

如果是用React的没有使用到hook,则这里可以使用componentWillReceiveProps() 还有 componentDidMount()搭配使用,意思就是页面加载好之后设置一下这个选中,然后有更新也设置一下

这就是最重要的部分啦,我们通过定义几个状态selectedKeys选中的条目,openedKeys打开的多级导航栏

我们通过在页面加载时候,判断页面url路径,如果是一级目录,例如首页,就直接设置选中的条目即可,如果是二级目录,例如导航栏中内容管理/题库管理这个功能,他的url链接是/admin/content/exercise-list,所以我们的case 4就可以捕获到啦,然后设置当前选中的条目以及打开的多级导航,具体的导航信息请看下面

首页
内容管理
} >
题库管理

大功告成!!!

5. 巧妙利用Antd表单来构造自己想要的数据结构

使用过Antd表单的胖友们一定知道this.props.form.validateFields()这个方法吧嘿嘿,他是如果验证成功就返回表单的值给你,不用自己去绑定输入组件的值,很方便,来看看官方的例子

可以看到,最简单的一个登录框,然后我们就可以得到一组数据啦,不过我们可以发现,这些数据就是一个对象中的几个值。

假如我们有很多数据,想用多个对象来构造数据结构,这应该怎么办呢,就例如这样子的数据结构,我们还是举上面这个例子

假如吼,我们提交后台的数据需要是这样子的数据结构,用户名和密码在userInfo这个对象内,然后是否记住密码是在other对象里面,自己得到数据之后再构造又十分麻烦,这可怎么办呢。

在此之前,我们不如看看官方给的另一个例子,一个动态添加表单项的例子,于此我们就可以发挥想象力,然后就可以解决我们上面的问题啦

可以看到这个动态添加表单项的,是以数组形式来存储数据的,他的代码是这样的

{getFieldDecorator(`names[${k}]`, {  validateTrigger: ['onChange', 'onBlur'],  rules: [    {      required: true,      whitespace: true,      message: "Please input passenger's name or delete this field.",    },  ],})()}

Antd表单的构造数据关键就在于里面的getFieldDecorator内的第一个参数,也就是我们的propName用来指定数据叫啥,跟之后验证表单传回的值是对应的了。这就给了我们一个很大的提示啦!!

这个
propName叫什么,之后生成的数据结构里面就是什么,是
a,之后数据就对应
a,是
b,就对应
b

这里通过一个names[$k],就可以让之后得到的数据变成一个数组names:Array(2): ['1', '2']这样子的形式,那么我们稍加改造一下,就可以变成对象的形式啦!下面看看代码,其实也很简单!

{getFieldDecorator(`topicList[${index}].topicContent`, { rules: TopicContentRules, initialValue: topicList[index].topicContent})(
)}

这里我就直接举项目中题库提交的例子啦,topicList是一个列表,里面存的是每一个题目对应的数据对象

这里的propName,我指定成了topicList[$(index)]就代表,这个属于这个列表里面的第几个对象,然后后面的.topicContent就代表这个对象里面的值是什么,最后我们的出的结构就是这样子的啦!

我们如愿得到了想要的数据结构了,这里面有对象,有数组,十分方便,可以灵活根据实际情况进行使用,关键就在于getFieldDecorator()里面的propName,直接以对象的形式命名,就可以啦!就按照下面这种形式就好啦!

{getFieldDecorator(`object.itemName`, { initialValue: 'BB小天使' })(
)}

之后就可以得到对象类型的表单值啦!

大功告成!!!

6. 后台接口获取信息后填充Antd表单

因为有一个题库修改的功能,所以打算获取完接口信息之后,直接将内容通过Antd表单的setFields的方法来直接填充表格中的信息,结果控制台报错了

看了看大致意思就是说emmmm不可以在渲染之前就设置表单的值,嘶~这可难受了,这时候想到他的表单内有一个initialValue的属性,是表单项的默认值,这可好办啦,这样我们先拉取信息,存入对象中,然后再通过这个属性给表单传值,果然不出所料,真的ok了没有报错了哈哈哈,具体看下面

// 定义选项列表来存储题库的题目列表信息  const [topicList, setTopicList] = useState
([{ topicType: 1, topicAnswer: [], topicContent: '', topicOptions: [] }]) // 定义题库基本信息对象 const [exerciseInfo, setExerciseInfo] = useState
({ exerciseName: '', exerciseContent: '', exerciseDifficulty: 1, exerciseType: 1, isHot: false }) // 首先先拉取信息,这就是题库的信息啦 const { data } = await http.get(`/exercises/${id}`) const { exerciseName, exerciseContent, exerciseDifficulty, exerciseType, isHot, topicList } = data topicList.forEach((_: any, index: number) => { topicList[index].topicOptions = topicList[index].topicOptions.map((item: any) => item.option) }) // 获取信息后,设置状态 setTopicList([...topicList]) setExerciseInfo({ exerciseName, exerciseContent, exerciseDifficulty, exerciseType, isHot, })

这样我们就得到了题库信息的对象啦,待会我们就可以用来传默认值给表单啦!

// 这里就通过题库名称来做例子,就从刚才设置的信息对象中取值然后设置默认值就可以啦
{getFieldDecorator('exerciseName', { rules: ExerciseNameRules, initialValue: exerciseInfo.exerciseName })(
)}

因为题库的题目是有挺多,所以是一个列表,类似下图

所以我们实现设置好topicList这个数组来存储题目的信息,然后我们通过遍历这个列表来实现多题目编辑

{topicList && topicList.map((_: any, index: number) => { return (
第{index + 1}题
1 ? 'inline' : 'none' }} onClick={() => handleTopicDeleteClick(index)} />
{getFieldDecorator(`topicList[${index}].topicContent`, { rules: TopicContentRules, initialValue: topicList[index].topicContent })(
)}
...... 省略一堆~
) })}

例如题目内容的话,我们就设置他的initialValuetopicList[index].topicContent即可,别的属性同理,然后点击新增题目按钮,就直接往topicList内添加对象信息即可完成题目列表的增加,点击删除图标,就删除列表中某一项,是不是十分方便!!哈哈哈

大功告成!!!

7. 使用JWTToken来验证用户登录状态以及返回信息

要想使用登录注册功能,还有用户权限的问题,我们就需要使用到这个token啦!为什么我们要使用token呢?而不是用传统的cookies呢,因为使用token可以避免跨域啊还有更多的复杂问题,大大简化我们的开发效率

本项目后台采用nodeJs来进行开发

我们先在后台定义一个工具utils/token.js

// token的秘钥,可以存在数据库中,我偷懒就卸载这里面啦hhhconst secret = "zhcxk1998"const jwt = require('jsonwebtoken')// 生成token的方法,注意前面一定要有Bearer ,注意后面有一个空格,我们设置的时间是1天过期const generateToken = (payload = {}) => (  'Bearer ' + jwt.sign(payload, secret, { expiresIn: '1d' }))// 这里是获取token信息的方法const getJWTPayload = (token) => (  jwt.verify(token.split(' ')[1], secret))module.exports = {  generateToken,  getJWTPayload}

这里采用的是jsonwebtoken这个库,来进行token的生成以及验证。

有了这个token啦,我们就可以再登录或者注册的时候给用户返回一个token信息啦

router.post('/login', async (ctx) => {  const responseBody = {    code: 0,    data: {}  }  try {    if (登录成功) {      responseBody.data.msg = '登陆成功'      // 在这里就可以返回token信息给前端啦      responseBody.data.token = generateToken({ username })      responseBody.code = 200    } else {      responseBody.data.msg = '用户名或密码错误'      responseBody.code = 401    }  } catch (e) {    responseBody.data.msg = '用户名不存在'    responseBody.code = 404  } finally {    ctx.response.status = responseBody.code    ctx.response.body = responseBody  }})

这样前端就可以获取这个token啦,前端部分只需要将token存入localStorage中即可,不用担心localStorage是永久保存,因为我们的token有个过期时间,所以不用担心

/* 登录成功 */  if (code === 200) {    const { msg, token } = data    // 登录成功后,将token存入localStorage中    localStorage.setItem('token', token)    message.success(msg)    props.history.push('/admin')  }

好嘞,现在前端获取token也搞定啦,接下来我们就需要在访问接口的时候带上这个token啦,这样才可以让后端知道这个用户的权限如何,是否过期等

需要传tokne给后端,我们可以通过每次接口都传一个字段token,但是这样十分浪费成本,所以我们再封装好的axios中,我们设置请求头信息即可

import axios from 'axios'const instance = axios.create({  baseURL: '/api',  timeout: 10000,  headers: {    'Content-Type': "application/json;charset=utf-8",  },})instance.interceptors.request.use(  config => {    // 请求头带上token信息    const token = localStorage.getItem('token');    if (token) {      config.headers.common['Authorization'] = token;    }    return config  },  error => {    return Promise.reject(error)  })...export default instance

如上图所示,我们每次请求接口的时候就会带上这个请求头啦!那么接下来我们就谈谈后端如何获取这个token并且验证吧

有获取token,以及验证部分,那么就需要出动我们的中间件啦!

我们验证token的话,要是用户是访问的登录或者注册接口,那么这个时候token其实是没有作用哒,所以我们需要将它隔离一下,所以我们定义一个中间件,用来跳过某些路由,我们再middleware/verifyToken.js中定义(这里我们采用koa-jwt来验证token)

const koaJwt = require('koa-jwt')const verifyToken = () => {  return koaJwt({ secret: 'zhcxk1998' }).unless({    path: [      /login/,      /register/    ]  })}module.exports = verifyToken

这样就可以忽略这登录注册路由啦,别的路由就验证token

拦截已经成功啦,那么我们该如何捕获,然后进行处理呢?我们再middleware/interceptToken定义一个中间件,来处理捕获的token信息

const interceptToken = async (ctx, next) => {  return await next().catch((err) => {    const { status } = err    if (status === 401) {      ctx.response.status = 401      ctx.response.body = {        code: 401,        data: {          msg: '请登录后重试'        }      }    } else {      throw err    }  })}module.exports = () => (  interceptToken)

由于koa-jwt拦截的token,如果过期,他会自动抛出一个401的异常以表示该token已经过期,所以我们只需要判断这个状态status然后进行处理即可

好嘞,中间件也定义好了,我们就在后端服务中使用起来吧!

const Koa = require('koa')const Router = require('koa-router');const bodyParser = require('koa-bodyparser')const cors = require('koa2-cors');const routes = require('../routes/routes')const router = new Router()const admin = new Koa();const {  verifyToken,  interceptToken} = require('../middleware')const {  login,  info,  register,  exercises} = require('../routes/admin')admin.use(cors())admin.use(bodyParser())/* 拦截token */admin.use(interceptToken())admin.use(verifyToken())/* 管理端 */admin.use(routes(router, { login, info, register, exercises }))module.exports = admin

我们直接使用router.use()的方法就可以使用中间件啦,这里要记住!验证拦截token一定要在路由信息之前,否则是拦截不到的哟(如果在后面,路由都先执行了,还拦截啥嘛!)

大功告成!!!

8. 密码使用加密加盐的方式存储

我们在处理用户的信息的时候,需要存储密码,但是直接存储肯定不安全啦!所以我们需要加密以及加盐的处理,在这里我用到的是crypto这个库

首先我们再utils/encrypt.js中定义一个工具函数用来生成盐值以及获取加密信息

const crypto = require('crypto')// 获取随机盐值,例如 c6ab1 这样子的字符串const getRandomSalt = () => {  const start = Math.floor(Math.random() * 5)  const count = start + Math.ceil(Math.random() * 5)  return crypto.randomBytes(10).toString('hex').slice(start, count)}// 获取密码转换成md5之后的加密信息const getEncrypt = (password) => {  return crypto.createHash('md5').update(password).digest('hex')}module.exports = {  getRandomSalt,  getEncrypt}

这样我们就可以通过验证密码与数据库中加密的信息对不对得上,来判断是否登录成功等等

我们现在注册中使用上,当然我们需要两个表进行数据存储,一个是用户信息,一个是用户密码表,这样分开更加安全,例如这样

这样就可以将用户信息还有密码分开存放,更加安全,这里就不重点叙述啦

const { getRandomSalt, getEncrypt } = require('../../utils/encrypt')// 注册部分router.post('/register', async (ctx) => {  const { username, password, phone, email } = ctx.request.body  // 获取盐值以及加密后的信息  const salt = getRandomSalt()  // 数据库存放的密码是由用户输入的密码加上随机盐值,然后再进行加密所得到的的炒鸡加密密码  const encryptPassword = getEncrypt(password + salt)    // 插入用户信息,以及获取这个的id  const { insertId: user_id } = await query(INSERT_TABLE('user_info'), { username, phone, email });  // 插入用户密码信息,user_id与上面对应  await query(INSERT_TABLE('user_password'), {    user_id,    password: encryptPassword,    salt  })  ...    })

接下来再来看登录部分,登录的话,就需要从用户密码表中取出加密密码,以及盐值,然后进行对比

// 通过用户名,先获取加密密码以及盐值const { password: verifySign, salt } = await query(`select password, salt from user_password where user_id = '${userId}'`)[0]// 这个就是用户输入的密码加上盐值一起加密后的密码const sign = getEncrypt(password + salt)// 这个加密的密码与数据库中加密的密码对比,如果一样则登陆成功if (sign === verifySign) {  responseBody.data.msg = '登陆成功'  responseBody.data.token = generateToken({ username })  responseBody.code = 200} else {  responseBody.data.msg = '用户名或密码错误'  responseBody.code = 401}

大功告成!!!

结语

大部分的内容就大概这样子,这是自己开发中遇到的小问题还有解决方法,希望对大家有所帮助,大家一起成长!现在得看看面试题准备一波春招了,不然大学毕业了都找不到工作啦!有时间再继续更新这个文章!

最后还是顺便求一波star还有点赞!!!

转载地址:http://xtqni.baihongyu.com/

你可能感兴趣的文章
新版本的linux如何生成xorg.conf
查看>>
xorg.conf的编写
查看>>
启用SELinux时遇到的问题
查看>>
virbr0 虚拟网卡卸载方法
查看>>
No devices detected. Fatal server error: no screens found
查看>>
新版本的linux如何生成xorg.conf
查看>>
virbr0 虚拟网卡卸载方法
查看>>
Centos 6.0_x86-64 终于成功安装官方显卡驱动
查看>>
Linux基础教程:CentOS卸载KDE桌面
查看>>
db sql montior
查看>>
read humor_campus
查看>>
IBM WebSphere Commerce Analyzer
查看>>
my read work
查看>>
db db2 base / instance database tablespace container
查看>>
hd disk / disk raid / disk io / iops / iostat / iowait / iotop / iometer
查看>>
project ASP.NET
查看>>
db db2_monitorTool IBM Rational Performace Tester
查看>>
OS + Unix Aix telnet
查看>>
IBM Lotus
查看>>
Linux +Win LAMPP Tools XAMPP 1.7.3 / 5.6.3
查看>>