React + TypeScript + Node.JS实现一个后台管理系统

目前因学业任务比较重,没有好好的完善,如今比较完善的只有题库管理,新增题库,修改题库以及登陆的功能,但搭配小程序使用,主体功能已经实现了css

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

但愿大佬们走过路过能够给个star鼓励一下~感激涕零~前端

github.com/zhcxk1998/S…node

这个是小程序的介绍文章
小程序介绍文章,使劲戳!react

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

运行截图

1. 登陆界面

2. 题库管理

3. 修改题库

技术分析

就来讲一下项目中本身推敲作出来的几个算是亮点的东西吧git

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

本项目采用的UI框架是Ant-Design框架
由于这个项目的后台对于表格有着比较大的需求,而表格加载就须要使用到Loading的状态,因此就特意封装一下便于以后使用github

首先咱们先新建一个文件useService.ts 而后咱们先引入axios来做为咱们的api访问工具web

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的拦截器,基本配置这些写好先面试

接着咱们实现一个获取接口信息的方法useServiceCallbacktypescript

const useServiceCallback = (fetchConfig: FetchConfig) => {
  // 定义状态,包括返回信息,错误信息,加载状态等
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [response, setResponse] = useState<any>(null)
  const [error, setError] = useState<any>(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<FetchConfig>({
    url: '', method: 'GET', params: {}, config: {}
  })
  
  ...
  
  useEffect(() => {
    const fetchConfig: FetchConfig = {
      url: '/exercises',
      method: 'GET',
      params: {},
      config: {}
    }
    setFetchConfig(Object.assign({}, fetchConfig))
  }, [fetchFlag])
复制代码

这样就大功告成啦!而后咱们再到表格组件内传入相关数据就能够啦

<Table
          rowSelection={rowSelection}
          dataSource={exerciseList}
          columns={columns}
          rowKey="exerciseId"
          scroll={{
            y: "calc(100vh - 300px)"
          }}
          loading={{
            spinning: isLoading,
            tip: "加载中...",
            size: "large"
          }}
          pagination={{
            pageSize: 10,
            total: totalPage,
            current: currentPage,
            onChange: (pageNo) => setCurrentPage(pageNo)
          }}
          locale={{
            emptyText: <Empty
              image={Empty.PRESENTED_IMAGE_SIMPLE}
              description="暂无数据" />
          }}
        />
复制代码

大功告成!!

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 (
    <div className="load-component" />
  )
}

const LoadableComponent = (component: () => Promise<any>) => Loadable({
  loader: component,
  loading: () => <LoadingPage />,
})

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 = () => (
  <Switch>
    <Route exact path="/login" component={Login} />
    <Route exact path="/register" component={Register} />
    <PrivateRoute component={Main} path="/admin" />

    <Redirect exact from="/" to="/admin" />
  </Switch>
)

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 = () => (
  <Layout className="index__container">
    // 头部导航栏
    <HeaderNav />
    <Layout>
      // 侧边栏
      <SiderNav />
      <Layout>
        // 主体内容
        <ContentMain />
      </Layout>
    </Layout>
  </Layout>
)

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<RouteComponentProps> = () => {
  return (
    <div className="main__container">
      <Switch>
        <Route exact path="/admin" component={Index} />
        <Route exact path="/admin/content/exercise-list" component={ExerciseList} />
        <Route exact path="/admin/content/exercise-publish" component={ExercisePublish} />
        <Route exact path="/admin/content/exercise-modify/:id" component={ExerciseModify} />

        <Redirect exact from="/" to="/admin" />
      </Switch>
    </div>
  )
}

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])
复制代码

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

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

<Menu
        mode="inline"
        defaultSelectedKeys={['/admin']}
        selectedKeys={selectedKeys}
        openKeys={openedKeys}
        onOpenChange={handleMenuChange}
      >
        <Menu.Item key="/admin">
          <Link to="/admin">
            <Icon type="home" />
            首页
        </Link>
        </Menu.Item>
        <SubMenu
          key="/admin/content"
          title={
            <span>
              <Icon type="profile" />
              内容管理
            </span>
          }
        >
          <Menu.Item key="/admin/content/exercise-list">
            <Link to="/admin/content/exercise-list">题库管理</Link>
          </Menu.Item>
        </SubMenu>
    </Menu>
复制代码

大功告成!!!

5. 接口获取信息后填充Ant表单

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

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

// 定义选项列表来存储题库的题目列表信息
  const [topicList, setTopicList] = useState<TopicList[]>([{
    topicType: 1,
    topicAnswer: [],
    topicContent: '',
    topicOptions: []
  }])
  // 定义题库基本信息对象
  const [exerciseInfo, setExerciseInfo] = useState<ExerciseInfo>({
    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,
  })

复制代码

这样咱们就获得了题库信息的对象啦,待会咱们就能够用来传默认值给表单啦!

// 这里就经过题库名称来作例子,就从刚才设置的信息对象中取值而后设置默认值就能够啦
<Form.Item label="题库名称">
  {getFieldDecorator('exerciseName', {
    rules: ExerciseNameRules,
    initialValue: exerciseInfo.exerciseName
  })(<Input />)}
</Form.Item>
复制代码

由于题库的题目是有挺多,因此是一个列表,相似下图

因此咱们实现设置好 topicList这个数组来存储题目的信息,而后咱们经过遍历这个列表来实现多题目编辑

<Form.Item label="新增题目">
    {topicList && topicList.map((_: any, index: number) => {
      return (
        <Fragment key={index}>
          <div className="form__subtitle">
            第{index + 1}题
            <Tooltip title="删除该题目">
              <Icon
                type="delete"
                theme="twoTone"
                twoToneColor="#fa4b2a"
                style={{ marginLeft: 16, display: topicList.length > 1 ? 'inline' : 'none' }}
                onClick={() => handleTopicDeleteClick(index)} />
            </Tooltip>
          </div>
          <Form.Item label="题目内容" >
            {getFieldDecorator(`topicList[${index}].topicContent`, {
              rules: TopicContentRules,
              initialValue: topicList[index].topicContent
            })(<Input.TextArea />)}
          </Form.Item>
          
          ...... 省略一堆~
          
        </Fragment>
      )
    })}
    <Form.Item>
      <Button onClick={handleTopicAddClick}>新增题目</Button>
    </Form.Item>
  </Form.Item>
复制代码

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

大功告成!!!

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

要想使用登陆注册功能,还有用户权限的问题,咱们就须要使用到这个token啦!为何咱们要使用token呢?而不是用传统的cookies呢,由于使用token能够避免跨域啊还有更多的复杂问题,大大简化咱们的开发效率

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

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

// token的秘钥,能够存在数据库中,我偷懒就卸载这里面啦hhh
const 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必定要在路由信息以前,不然是拦截不到的哟(若是在后面,路由都先执行了,还拦截啥嘛!)

大功告成!!!

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

咱们在处理用户的信息的时候,须要存储密码,可是直接存储确定不安全啦!因此咱们须要加密以及加盐的处理,在这里我用到的是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还有点赞!!!

github项目猛戳进来star一下嘿嘿
小程序介绍文章,使劲戳!

相关文章
相关标签/搜索