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一下嘿嘿
小程序介紹文章,使勁戳!

相關文章
相關標籤/搜索