如何读懂一个react后台项目


之前做了一个,因此有一定的基础。
QUESTION:在阅读antd组件源码时,发现还有很多看不懂,非常吃力。一方面是因为自己没有tsx组件设计的经验(其实没有ts的使用经验,也只是能看懂);也有一部分是不会快速上手一个项目

1. 概览

1.1 node_modules

该项目存在nodejs环境,这是npm包管理文件夹

1.2 public

静态资源文件夹,在里面看到了index.html



  
  
    
	
    
	
    
	
    
	
    
	
    
    
	
    
    
	
    React App
  
  
    
    

1.3 src

里面有index.js,看来是页面的主要内容文件了

1.4 package.json

package.json 文件是项目的清单。 它可以做很多完全互不相关的事情。 例如,它是用于工具的配置中心。 它也是 npm 和 yarn 存储所有已安装软件包的名称和版本的地方。

{ // https://www.jianshu.com/p/b525e009cc4e
  "name": "newssystem", //项目名
  "version": "0.1.0",//版本号
  "private": true,//私有项目,禁止意外发布私有存储库的方法
  "dependencies": {//依赖包,在开发和线上环境均需要使用
    "@testing-library/jest-dom": "^5.16.2",
    "@testing-library/react": "^12.1.3",
    "@testing-library/user-event": "^13.5.0",
    "antd": "^4.18.9",//antd组件库
    "axios": "^0.26.0",//网络请求
    "dayjs": "^1.10.8",// 日期事件处理
    "draft-js": "^0.11.7",//富文本编辑
    "draftjs-to-html": "^0.9.1",// 富文本转html
    "echarts": "^5.3.1",//图表
    "html-to-draftjs": "^1.5.0",// HTML转富文本
    "http-proxy-middleware": "^2.0.3",// 反向代理中间件
    "lodash-es": "^4.17.21",//js方法库
    "moment": "^2.29.1",// 日期事件处理,已用dayjs替换掉了(性能优化https://www.cnblogs.com/shixiu/p/16002113.html)
    "nprogress": "^0.2.0",//进度条
    "react": "^17.0.2",//react核心库
    "react-dom": "^17.0.2",//react核心库,处理虚拟DOM渲染等功能
    "react-draft-wysiwyg": "^1.14.7",// react富文本编辑器,基于 ReactJS 和 DraftJS
    "react-redux": "^7.2.6",//状态管理
    "react-router-dom": "^6.2.2",//路由
    "react-scripts": "5.0.0",//react项目配置https://newsn.net/say/react-scripts-action.html
    "react-tsparticles": "^1.41.6",// 粒子效果
    "redux": "^4.1.2",//状态管理
    "redux-persist": "^6.0.0",// 状态持久化
    "sass": "^1.49.9",// css拓展
    "web-vitals": "^2.1.4"// 性能检测工具https://juejin.cn/post/6930903996127248392
  },
  "scripts": {//配置命令,执行npm run xxx即可运行scripts文件下对应的js文件
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {//eslint规则
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {//浏览器兼容范围,也可以配置在.browserslistrc文件,会被Autoprefixer Babel postcss-preset-env等使用
    "production": [
      ">0.2%",//兼容市场份额在0.2%以上的浏览器
      "not dead", //在维护中
      "not op_mini all"//忽略OperaMini浏览器
    ],
    "development": [//开发环境只需兼容以下三种浏览器的最新版本
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {//只在开发环境存在的依赖
    "terser-brunch": "^4.1.0"// Brunch 生产构建
  }
}

1.5 package-lock.json

  • 该文件旨在跟踪被安装的每个软件包的确切版本,以便产品可以以相同的方式被 100% 复制(即使软件包的维护者更新了软件包)。
  • package-lock.json 会固化当前安装的每个软件包的版本,当运行 npm install时,npm 会使用这些确切的版本。
  • 当运行 npm update 时,package-lock.json 文件中的依赖的版本会被更新。

2. JS

2.1 index.js

index.js是项目的入口文件

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
// 导入APP组件
import App from './App';
// 导入工具包
import './util/http'
// react拓展禁用
if (window.location.port && typeof window.__REACT_DEVTOOLS_GLOBAL_HOOK__ === 'object') {
  window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {}
}
// ReactDOM.render(template,targetDOM)将app组件渲染到root根节点中
ReactDOM.render(
 ,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals

2.2 util/http.js

index.js引入了我们就先看下。在这里配置了axios网络请求的内容;使用redux完成当进行网络请求时显示加载的圈。

// Axios 是一个基于 promise 的网络请求库,可以用于浏览器和 node.js
import axios from 'axios'
// 导入redux-store
import {store} from '../redux/store'
// 全局axios默认值
axios.defaults.baseURL="http://localhost:5000"

// axios.defaults.headers
// 请求拦截器、响应拦截器
// axios.interceptors.request.use
// axios.interceptors.response.use
axios.interceptors.request.use(function (config) {
    // Do something before request is sent
    // 显示loading。更新 state 的唯一方法是调用 store.dispatch() 并传入一个 action 对象。
    store.dispatch({
        type:"change_loading",
        payload:true
    })
    return config;
  }, function (error) {
    // Do something with request error
    // Promise.reject()方法返回一个带有拒绝原因(error)的Promise对象。
    return Promise.reject(error);
  });

// Add a response interceptor
axios.interceptors.response.use(function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    store.dispatch({
        type:"change_loading",
        payload:false
    })
    //隐藏loading
    return response;
  }, function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    store.dispatch({
        type:"change_loading",
        payload:false
    })
     //隐藏loading
    return Promise.reject(error);
  });

2.3 redux

对redux不熟悉的可以参考

2.3.1 store.js

状态容器。

// 创建Store,combineReducers:合并Reducer
import {createStore,combineReducers} from 'redux'
// 导入两个Reducer
import {CollapsedReducer} from './reducers/CollapsedReducer'
import {LoadingReducer} from './reducers/LoadingReducer'
// 状态持久化:https://github.com/rt2zz/redux-persist
import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
// 持久化配置,LoadingReducer列入黑名单(不需要持久化)
const persistConfig = {
    key: 'hangyi',
    storage,
    blacklist: ['LoadingReducer']
}
// 合并
const reducer = combineReducers({
    CollapsedReducer,
    LoadingReducer
})
// 持久化Reducer
const persistedReducer = persistReducer(persistConfig, reducer)
// 创建store
const store = createStore(persistedReducer);
// 持久化store
const persistor = persistStore(store)
export {
    store, // 正常store
    persistor // 持久化后的store
}

/*
 store.dispatch()

 store.subsribe()

*/

2.3.2 reducers/CollapsedReducer.js

侧边折叠状态控制。

// 当前state为 isCollapsed:false
export const CollapsedReducer = (prevState={
    isCollapsed:false
},action)=>{
// 解构出type
    let {type} =action
// 检查reducer是否关心传入的action
    switch(type){
        case "change_collapsed": // 如果关心
            let newstate = {...prevState} // 复制state
            newstate.isCollapsed = !newstate.isCollapsed // 改变state:取反
            return newstate
        default: // 不关心则不变
            return prevState
    }
}

2.3.3 reduces/LoadingReducer.js

网络请求状态控制。

export const LoadingReducer = (prevState={
    isLoading:false
},action)=>{
        // 解构出type,以及附加信息payload
    let {type,payload} =action

    switch(type){
        case "change_loading":
            let newstate = {...prevState}
            newstate.isLoading = payload
            return newstate
        default:
            return prevState
    }
}

结合http.js和LoadingReducer.js,我们知道它实现了一个功能:每次发送axios请求前,将Loading状态改为true;拿到响应后结束Loading。这个状态可以用来设置数据加载时的一些用户体验,我们往后看。

2.4 App.js

这里应该就是App的核心内容架构了

import './App.css'
// 引入路由配置
import IndexRouter from "./router/IndexRouter";
// 引入store配置
import { Provider } from "react-redux";
import {store} from "./redux/store";

function App(){
// Provider包裹
  return 
  
  
}
export default App

2.5 router

我们来看下路由配置

import React from 'react'
// 路由相关
import {
    HashRouter as Router, // HashRouter只是一个容器,并没有DOM结构,它渲染的就是它的子组件,并向下层传递locationhttps://www.cnblogs.com/lyt0207/p/12734944.html
    Routes, // 路由容器:只显示匹配到的第一个路由(以前版本的switch)
    Route, // 路由规则
    Navigate // 导航&重定向
} from "react-router-dom"
// 四个组件
import Login from '../views/login/Login'
import NewsSandBox from '../views/sandbox/NewsSandBox'
import News from '../views/news/News'
import Detail from '../views/news/Detail'

export default function IndexRouter() {

    return (
        
            
                } /> 
                }/>  
                }/>  
                :} /> 
             {/*
                {localStorage.getItem("token")?} />:} />}
            */}
            
        
    )
}

到这里我们可以结合axios网络请求的配置看下页面了
登陆页面 http://localhost:3000/#/login
游客新闻页面 http://localhost:3000/#/news
游客新闻详情 http://localhost:3000/#/detail/3
对其他任意页面,如果localStorage.getItem("token")有值会导航到组件,如果没有则会导航到login组件
去看看

2.6 views/sandbox/NewsSandBox.js

主页面框架。

import React, { useEffect } from 'react' // useEffect副作用函数,一般用来设置请求数据、事件处理、订阅等
// 导入了侧边栏和顶栏的组件
import SideMenu from '../../components/sandbox/SideMenu'
import TopHeader from '../../components/sandbox/TopHeader'
import NProgress from 'nprogress' // nprogress是个进度条插件
import 'nprogress/nprogress.css'
//css
import './NewsSandBox.css'
//antd
import { Layout } from 'antd'
// 导入新闻路由组件
import NewsRouter from '../../components/sandbox/NewsRouter'
// 解构出Content组件
const { Content } = Layout;

export default function NewsSandBox() {
// 进度条加载,'组件挂载完成之后 或 组件数据更新完成之后 执行'进度条取消:https://developer.aliyun.com/article/792403
    NProgress.start()
    useEffect(()=>{
        NProgress.done()
    })
    return (
         
            

            
                
                
                    className="site-layout-background"
                    style={{
                        margin: '24px 16px',
                        padding: 24,
                        minHeight: 280,
                    }}
                >

                    
                
            
        
    )
}

2.7 components/sandbox/SideMenu

先看下侧边栏吧

import React, { useState, useEffect } from 'react';
import '../../index.css'
import { Layout, Menu } from 'antd';
import {
  UserOutlined
} from '@ant-design/icons';
// useNavigate导航方法。useLocation获取路径
import { useNavigate, useLocation } from 'react-router-dom'
import axios from 'axios';
import {connect} from 'react-redux'

const { Sider } = Layout;
const { SubMenu } = Menu;


// // 模拟数组结构
// const menuList = [
//   {
//     key: "/home",
//     title: "首页",
//     icon: 
//   },
//   {
//     key: "/user-manage",
//     title: "用户管理",
//     icon: ,
//     children: [
//       {
//         key: "/user-manage/list",
//         title: "用户列表",
//         icon: 
//       }
//     ]
//   },
//   {
//     key: "/right-manage",
//     title: "权限管理",
//     icon: ,
//     children: [
//       {
//         key: "/right-manage/role/list",
//         title: "角色列表",
//         icon: 
//       },
//       {
//         key: "/right-manage/right/list",
//         title: "角色列表",
//         icon: 
//       }
//     ]
//   }
// ]
// 定义了一个对象,映射侧边栏路径与图标
const iconList = {
  "/home": ,
  "/user-manage": ,
  "/user-manage/list": ,
  "/right-manage": ,
  "/right-manage/role/list": ,
  "/right-manage/right/list": 
  //.......
}

// 既然用到了connect,就要传props了,不知道connect的看下[Redux入门](https://www.cnblogs.com/shixiu/p/16011266.html)
function SideMenu(props) {
// 要动态渲染侧边栏,就会有状态
  const [menu, setMenu] = useState([])
  // 初始化侧边栏内容列表,包含了父子关系(树形)
  useEffect(() => {
    axios.get("/rights?_embed=children").then(res => {
      // console.log(res.data)
      setMenu(res.data)
    })
  }, []);
  // 解构当前用户的页面权限,JSON.parse解析JSON格式返回对象
  const {role: {rights}} = JSON.parse(localStorage.getItem("token"));
  // 检查登录用户页面权限方法:
  const checkPagePermission = (item) => {
    return item.pagepermisson && rights.includes(item.key)
  };
  // 导航方法
  const navigate = useNavigate();
  // 截取当前URL路径
  const location = useLocation();
  const selectedkeys = location.pathname; // 截取出整个路径
  const openkeys = ["/" + location.pathname.split("/")[1]]; // 截取出一级路径
  // 侧边栏内容列表渲染方法:传入形参menuList
  const renderMenu = (menuList) => {
  // map遍历
    return menuList.map(item => {
      // 检查每一项是否有下级列表(使用可选链语法)&& 页面权限
      if (item.children?.length > 0 && checkPagePermission(item)) { // 如果有子菜单
        return  // 渲染父菜单
          {renderMenu(item.children)} // 递归调用渲染子菜单
        
      }
      return checkPagePermission(item) && 
        navigate(item.key)
      }>{item.title} // 没有子菜单的直接检查权限并渲染
    })
  }

  return (
     
      
新闻发布后台管理系统
{renderMenu(menu)}
) } // 利用connect连接store,获取props.isCollapsed const mapStateToProps = ({CollapsedReducer: {isCollapsed}})=>({isCollapsed}) export default connect(mapStateToProps)(SideMenu)

2.8 components/sandbox/TopHeader.js

顶栏。

import React from 'react'
import { Layout,Menu, Dropdown,Avatar } from 'antd';
import {
  MenuUnfoldOutlined,
  MenuFoldOutlined,
  UserOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'
import {connect} from 'react-redux'

const { Header } = Layout;


function TopHeader(props) {
  // console.log(props)
  const navigate = useNavigate();
  // 因为使用了store,所以不需要在组件内定义这个状态了
  // const [collapsed, setCollapsed] = useState(false)
   const changeCollapsed = () => {
       // 使用props.changeCollapsed()触发action
     // setCollapsed(!collapsed)
     props.changeCollapsed()
   }
   // 解构出用户名和权限
  const {username,role: {roleName}} = JSON.parse(localStorage.getItem("token"));
    // 退出登录的方法
  const leaveMethod = () => {
    localStorage.removeItem("token");
    navigate("/login")
  }
  // 头像菜单栏
  const menu = (
    
      {roleName}
      退出
    
  );
  return (
    
{/* {React.createElement(this.state.collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, { className: 'trigger', onClick: this.toggle, })} */} {props.isCollapsed ? : }
欢迎{username}回来 } />
) } /* connect( // mapStateToProps // mapDispatchToProps )(被包装的组件) */ // 从store获取isCollapsed状态 const mapStateToProps = ({CollapsedReducer: {isCollapsed}})=>{ return { isCollapsed } } // dispatch 更新store的状态 const mapDispatchToProps = { changeCollapsed(){ return { type:"change_collapsed" } } } export default connect(mapStateToProps,mapDispatchToProps)(TopHeader)

至此我们可以看出项目中redux的一个使用了。利用CollapsedReducer完成了兄弟组件之间的通信:顶部栏图标影响侧边栏的折叠状态;并通过黑名单持久化保证了刷新页面也能维持折叠状态。

2.9 components/sandbox/NewsRouter.js

侧边栏看完,看看页面主体吧。看起来又封装了一个路由。

import React, { useEffect, useState,Suspense,lazy } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import axios from 'axios'
import {connect} from 'react-redux'
import { Spin } from 'antd'
// 路由懒加载lazy()能实现切换到该页面时才加载相关js,这样会进行代码分割,webpack打包时会分开打
import Home from '../../views/sandbox/home/Home'
const NoPermission = lazy(()=> import('../../views/sandbox/nopermission/NoPermission'))
const RightList = lazy(()=> import('../../views/sandbox/right-manage/RightList'))
const RoleList = lazy(()=> import('../../views/sandbox/right-manage/RoleList'))
const UserList = lazy(()=> import('../../views/sandbox/user-manage/UserList'))
const NewsAdd = lazy(()=> import('../../views/sandbox/news-manage/NewsAdd'))
const NewsDraft = lazy(()=> import('../../views/sandbox/news-manage/NewsDraft'))
const NewsCategory = lazy(()=> import('../../views/sandbox/news-manage/NewsCategory'))
const Audit = lazy(()=> import('../../views/sandbox/audit-manage/Audit'))
const AuditList = lazy(()=> import('../../views/sandbox/audit-manage/AuditList'))
const Unpublished = lazy(()=> import('../../views/sandbox/publish-manage/Unpublished'))
const Published = lazy(()=> import('../../views/sandbox/publish-manage/Published'))
const Sunset = lazy(()=> import('../../views/sandbox/publish-manage/Sunset'))
const NewsPreview = lazy(()=> import('../../views/sandbox/news-manage/NewsPreview'))
const NewsUpdate = lazy(()=> import('../../views/sandbox/news-manage/NewsUpdate'))
// 路由与组件映射
const LocalRouterMap = {
    "/home": ,
    "/user-manage/list": ,
    "/right-manage/role/list": ,
    "/right-manage/right/list": ,
    "/news-manage/add": ,
    "/news-manage/draft": ,
    "/news-manage/preview/:id":,
    "/news-manage/update/:id":,
    "/news-manage/category": ,
    "/audit-manage/audit": ,
    "/audit-manage/list": ,
    "/publish-manage/unpublished": ,
    "/publish-manage/published": ,
    "/publish-manage/sunset":
}

function NewsRouter(props) {
    const [BackRouteList, setBackRouteList] = useState([])
    // 初始化所有路由列表
    useEffect(() => {
        Promise.all([ // 使用Promise等待一级菜单,二级菜单数据返回
            axios.get("/rights"),
            axios.get("/children"),
        ]).then(res => {
            // console.log(res)
            setBackRouteList([...res[0].data, ...res[1].data]) // 设置返回路由清单为所有的页面(一级+二级)
            // console.log([...res[0].data,...res[1].data])
        })
    }, [])
    const {role:{rights}} = JSON.parse(localStorage.getItem("token"))
    // 路由自身页面权限:映射列表存在+ (页面配置权限(判断该一级页面是否允许配置)||路由配置权限(判断该二级页面是否是侧边路由))
    const checkRoute = (item)=>{
        return LocalRouterMap[item.key] && (item.pagepermisson || item.routepermisson)
    }
    // 用户权限检查:解构出来的页面列表
    const checkUserPermission = (item)=>{
        return rights.includes(item.key)
    }
    return (
         
         
            {BackRouteList.map(item => {
                if(checkRoute(item) && checkUserPermission(item)){
                   return Loading...
}>{LocalRouterMap[item.key]}} /> } return } /> } )} } /> ) } // 从store获取isLoading状态 const mapStateToProps = ({LoadingReducer:{isLoading}})=>({isLoading}) export default connect(mapStateToProps)(NewsRouter)

至此总体的路由、状态管理等就已经看完了。接下来就到具体的页面和组件了。这部分就基本上只看业务逻辑就行了。

3. views

3.1 login/Login.js

登录模块

  • 使用Particles实现登陆页面粒子效果
  • Form表单实现用户数据收集
  • 表单上定义onFinish方法,收集数据后像后端请求验证,如果返回的数据长度为0,说明用户名&密码不正确,使用antd message完成消息弹出;否则登陆成功,localStorage的token存储数据,跳转至首页。
import React from 'react'
import './Login.css'
import { Form, Button, Input ,message } from 'antd'
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import Particles from "react-tsparticles";
import axios from 'axios'
import { useNavigate } from 'react-router-dom'

export default function Login(props) {
  const navigate = useNavigate();
  const onFinish = (values) => {
    axios.get(`/users?username=${values.username}&password=${values.password}&roleState=true&_expand=role`).then(res=>{
      if(res.data.length===0){
        message.error("用户名或密码不匹配")
    }else{
      localStorage.setItem("token",JSON.stringify(res.data[0]))
      navigate("/")
    }
  })
  }
  return (
    
全球新闻发布管理系统
} placeholder="Username" /> } type="password" placeholder="Password" autoComplete="on" />
) }

3.2 sandbox

3.2.1 home/Home.js

首页
首页画了三个卡片和一个柱形图。

  • 使用ref.current获取dom节点,使echarts图表正确内置
  • 使用lodash的groupBy方法处理数据
  • 使用JSON-SERVER内置方法获取前六的数据
  • 使用Echarts.resize()来时图表响应window.onresize的变化
  • 新闻标题点击进入预览页面,这与新闻管理时的新闻预览需求合并,提取出了NewsPreview组件
  • 图片使用了webp格式优化性能
  • 在card组件使用action属性,在里面写了一个设置图标触发饼图的Drawer容器渲染,并在容器内部渲染饼图。在这里使用settimeout函数封装了先显示Drawer容器,再渲染饼图。原理是settimeout作为异步函数,内部的任务会进入任务队列执行,而任务队列先进先出,所以能先渲染容器。react的状态更新是异步的,因此不能保证先后。
import React, { useEffect, useState, useRef } from 'react'
import { Card, Col, Row, List, Avatar, Drawer } from 'antd';
import { EditOutlined, EllipsisOutlined, SettingOutlined } from '@ant-design/icons';
import axios from 'axios'
// 按需引入lodash
import {groupBy} from 'lodash-es'
// 按需引入echarts
// import * as Echarts from 'echarts'
import * as Echarts from 'echarts/core';
import {
  BarChart,
  PieChart
} from 'echarts/charts';
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  LegendComponent,
  DatasetComponent
} from 'echarts/components';
import {
  CanvasRenderer
} from 'echarts/renderers';
Echarts.use(
  [
    TitleComponent,
    TooltipComponent,
    GridComponent,
    BarChart,
    PieChart,
    LegendComponent,
    DatasetComponent,
    CanvasRenderer
  ]
);

const { Meta } = Card;

export default function Home() {
  // const ajax = () => {
  //   // 取数
  //   // axios.get("http://localhost:8000/posts/1").then(res => console.log(res.data))

  //   // 增数
  //   // axios.post("http://localhost:8000/posts",{
  //   //   title:"title3",
  //   //   author:"threeMan"
  //   // })

  //   // 修改
  //   // axios.put("http://localhost:8000/posts/1",{
  //   //   title:"title1.1"
  //   // })

  //   // 更新
  //   // axios.patch("http://localhost:8000/posts/1",{
  //   //    title:"title1.2"
  //   //  })

  //    // 删除
  //   //  axios.delete("http://localhost:8000/posts/2")

  //   // _embed 级联关系
  //   // axios.get("http://localhost:8000/posts?_embed=comments").then(res => console.log(res.data))

  //   // _expand 父级关系
  //   axios.get("http://localhost:8000/comments?_expand=post").then(res => console.log(res.data))
  // }
  const [viewList, setviewList] = useState([])
  const [starList, setstarList] = useState([])
  const [allList, setallList] = useState([])
  const [visible, setvisible] = useState(false)
  const [pieChart, setpieChart] = useState(null)
  const barRef = useRef()
  const pieRef = useRef()
  useEffect(() => {
    axios.get("/news?publishState=2&_expand=category&_sort=view&_order=desc&_limit=6").then(res => {
      // console.log(res.data)
      setviewList(res.data)
    })
  }, [])

  useEffect(() => {
    axios.get("/news?publishState=2&_expand=category&_sort=star&_order=desc&_limit=6").then(res => {
      // console.log(res.data)
      setstarList(res.data)
    })
  }, [])

  useEffect(() => {
    axios.get("/news?publishState=2&_expand=category").then(res => {
      // console.log(res.data)
      // console.log()
      // 柱形图数据
      renderBarView(groupBy(res.data, item => item.category.title))
      // 饼图数据(需要更多处理
      setallList(res.data)
    })
    // 组件销毁时清除图标响应
    return ()=>{
      window.onresize = null
  }
  }, [])

  const renderBarView = (obj) => {
    // console.log(obj)
    var myChart = Echarts.init(barRef.current);
    // 指定图表的配置项和数据
    var option = {
      title: {
        text: '新闻分类图示'
      },
      tooltip: {},
      legend: {
        data: ['数量']
      },
      xAxis: {
        data: Object.keys(obj),
        axisLabel:{
          rotate:"45",
          interval:0
      }
      },
      yAxis: {
        minInterval: 1
      },
      series: [{
        name: '数量',
        type: 'bar',
        data: Object.values(obj).map(item => item.length)
      }]
    };
    // 使用刚指定的配置项和数据显示图表。
    myChart.setOption(option);
    // 图表响应
    window.onresize= ()=>{
      // console.log("resize")
      myChart.resize()
  }
  }
  // 饼图渲染
  const renderPieView = (obj) => {
    //数据处理工作
    let currentList =allList.filter(item=>item.author===username)
    let groupObj = groupBy(currentList,item=>item.category.title)
    let list = []
    for(let i in groupObj){
        list.push({
            name:i,
            value:groupObj[i].length
        })
    }
    let myChart;
    if(!pieChart){
        myChart = Echarts.init(pieRef.current);
        setpieChart(myChart)
    }else{
        myChart = pieChart
    }
    let option = {
        title: {
            text: '当前用户新闻分类图示',
            // subtext: '纯属虚构',
            left: 'center'
        },
        tooltip: {
            trigger: 'item'
        },
        legend: {
            orient: 'vertical',
            left: 'left',
        },
        series: [
            {
                name: '发布数量',
                type: 'pie',
                radius: '50%',
                data: list,
                emphasis: {
                    itemStyle: {
                        shadowBlur: 10,
                        shadowOffsetX: 0,
                        shadowColor: 'rgba(0, 0, 0, 0.5)'
                    }
                }
            }
        ]
    };
    option && myChart.setOption(option);
}


  const { username, region, role: { roleName } } = JSON.parse(localStorage.getItem("token"))

  return (
    
{item.title} } /> {item.title} } /> } actions={[ { setTimeout(() => { setvisible(true) // init初始化 renderPieView() }, 0) }} />, , , ]} > } title={username} description={
{region ? region : "全球"} {roleName}
} />
{ setvisible(false) }} visible={visible} >
) }

3.2.2 right-manage

权限管理

3.2.2.1 RoleList.js

角色管理:渲染了一个表格

  • antd column中,如果设置了dataIndex,则render(x,y)中形参x为dataindex\y为行数据(y可以省略);如果没有dataindex,则render(y)
  • 删除方法中,我们使用dataSource.filter返回id不等于删除项id的数据并用来更新状态值,随后推送后端删除
  • 使用antd组件modal弹出对话框,在其中包裹树形组件Tree
  • 点击操作按钮时,触发三个操作:显示对话框;初始化该角色权限数据;初始化该角色ID
  • 点击权限树中的某个选项时,根据checkedKeys.checked属性值更新该角色权限数据
  • 点击OK时,触发:隐藏对话框;使用{...item, rights:currentRights}写法更新该角色数据;再使用patch修补后端数据
import { Button, Table, Modal, Tree} from 'antd';
import axios from 'axios';
import React, { useEffect, useState } from 'react'
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons'

const { confirm } = Modal;

export default function RoleList() {

  const [dataSource,setdataSource] = useState([]);
  const [isModalVisible,setisModalVisible] = useState(false);
  const [rightList,setRightList] = useState([]);
  const [currentRights,setcurrentRights] = useState([]);
  const [currentId,setcurrentId] = useState(0);

  useEffect(() => {
    axios.get("/roles").then(res => {
      setdataSource(res.data)
    })
  },[]);
  useEffect(() => {
    axios.get("/rights?_embed=children").then(res => {
      setRightList(res.data)
    })
  },[])

  const columns = [
    {
      title: 'ID',
      dataIndex: 'id',
      render: (id) => {
          return {id}
      }
  },
  {
      title: '角色名称',
      dataIndex: 'roleName'
  },
  {
      title: "操作",
      render: (item) => {
        return 
} } ]; const confirmDelete = (item) => { confirm({ title: '你确定要删除?', icon: , // content: 'Some descriptions', onOk() { deleteMethod(item); }, onCancel() { // console.log('Cancel'); }, }); }; const deleteMethod = (item) => { setdataSource(dataSource.filter(data => data.id !== item.id)); axios.delete(`/roles/${item.id}`) }; const handleOk = () => { setisModalVisible(false); setdataSource(dataSource.map(item => { if (item.id===currentId) { return { ...item, rights:currentRights } } return item })); axios.patch(`/roles/${currentId}`,{ rights:currentRights }); }; const handleCancel = () => { setisModalVisible(false); }; const onCheck = (checkedKeys) => { setcurrentRights(checkedKeys.checked); } return (
item.id} /> ) }

3.2.2.2 RightList.js

权限列表

  • antd Pagination 分页
  • 删除页面方法:先判断是否为1级页面;否则根据rightId字段找到children进行删除
  • 使用了antd tag标签
  • 使用了antd Popover气泡标签
import { Button, Popover, Table, Tag, Modal, Switch } from 'antd'
import axios from 'axios';
import React, { useEffect, useState } from 'react'
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons'

const { confirm } = Modal;

export default function RightList() {
  const [dataSource, setdataSource] = useState([]);
  useEffect(() => {
    axios.get("/rights?_embed=children").then(res => {
      const list = res.data;
      list.forEach(element => {
        if (element.children.length === 0) {
          element.children = ""
        }
      });
      setdataSource(list)
    })
  }, []);

  const confirmDelete = (item) => {
    confirm({
      title: '你确定要删除?',
      icon: ,
      // content: 'Some descriptions',
      onOk() {
        deleteMethod(item);
      },
      onCancel() {
        // console.log('Cancel');
      },
    });
  };
  const deleteMethod = (item) => {
    if (item.grade === 1) {
      setdataSource(dataSource.filter(data => data.id !== item.id));
      axios.delete(`/rights/${item.id}`)
    } else {
      const list = dataSource.filter(data => data.id === item.rightId);
      list[0].children = list[0].children.filter(data => data.id !== item.id);
      setdataSource([...dataSource]);
      axios.delete(`/children/${item.id}`)
    }
  };
  const switchMethod = (item) => {
    item.pagepermisson = item.pagepermisson===1?0:1;
    setdataSource([...dataSource]);
    if (item.grade === 1) {
      axios.patch(`/rights/${item.id}`,{pagepermisson:item.pagepermisson})
    } else {
      axios.patch(`/children/${item.id}`,{pagepermisson:item.pagepermisson})
    }
  };

  const columns = [
    {
      title: "ID",
      dataIndex: "id",
      render: (id) => {
        return {id}
      }
    },
    {
      title: "权限名称",
      dataIndex: "title"
    },
    {
      title: "权限路径",
      dataIndex: "key",
      render: (key) => {
        return {key}
      }
    },
    {
      title: "操作",
      render: (item) => {
        return 
} trigger={item.pagepermisson===undefined?'':"click"}>
) }

3.2.3 user-manage

用户管理

3.2.3.1 user-manage/UserList.js

用户列表。用户列表展示一个增加用户按钮,一个用户表格。

  • 使用Modal对话框弹出增加用户、编辑用户操作。
  • 增加用户、编辑用户共用UserForm组件,使用父组件定义ref加子组件forwardref获取子组件DOM节点和值
  • 再columns使用filters、onFilter完成列数据筛选
  • 增加用户:addForm.current.validateFields().then(value => {}).catch(err =>{})进行表单校验,有数据进行后端提交等操作, addForm.current.resetFields()进行清空表单
import { Button, Table, Modal, Switch } from 'antd'
import axios from 'axios';
import React, { useEffect, useState, useRef } from 'react'
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import UserForm from '../../../components/user-manage/UserForm';

const { confirm } = Modal;

export default function UserList() {
  const [dataSource, setdataSource] = useState([]);
  const [isModalVisible, setIsModalVisible] = useState(false);
  const [roleList, setroleList] = useState([]);
  const [regionList, setregionList] = useState([]);
  const addForm = useRef("");
  const [isUpdateVisible,setisUpdateVisible] = useState(false);
  const updateForm = useRef("");
  const [isUpdateDisabled,setisUpdateDisabled] = useState(false);
  const [current,setcurrent] =useState(null);
  const {roleId,region,username} = JSON.parse(localStorage.getItem("token"));
  // 初始化用户权限列表
  useEffect(() => {
    const roleObj = {
      "1":"superadmin",
      "2":"admin",
      "3":"editor"
  }
    axios.get("/users?_expand=role").then(res => {
      const list = res.data;
      setdataSource(roleObj[roleId]==="superadmin"?list:[
        // 超级管理员不限制,区域管理员:自己+自己区域编辑,区域编辑:看不到用户列表
        ...list.filter(item=>item.username===username),
        ...list.filter(item=>item.region===region&& roleObj[item.roleId]==="editor")
    ])
    })
  }, [roleId,region,username]);
  // 初始化区域列表
  useEffect(() => {
    axios.get("/regions").then(res => {
      const list = res.data;
      setregionList(list)
    })
  }, []);
  // 初始化角色列表
  useEffect(() => {
    axios.get("/roles").then(res => {
      const list = res.data;
      setroleList(list)
    })
  }, []);

  // 删除确认,删除方法
  const confirmDelete = (item) => {
    confirm({
      title: '你确定要删除?',
      icon: ,
      // content: 'Some descriptions',
      onOk() {
        deleteMethod(item);
      },
      onCancel() {
        // console.log('Cancel');
      },
    });
  };
  const deleteMethod = (item) => {
    setdataSource(dataSource.filter(data => data.id !== item.id))
    axios.delete(`/users/${item.id}`)
  };
  // 开关方法
  const switchMethod = (item) => {
    item.roleState = !item.roleState;
    setdataSource([...dataSource]);
    axios.patch(`/users/${item.id}`, {
      roleState: item.roleState
    })
  };
  // 增加用户对话框
  const showModal = () => {
    setIsModalVisible(true);
  };
  const handleOk = () => {
    addForm.current.validateFields().then(value => {
      setIsModalVisible(false);
      // 重置下增加表单
      addForm.current.resetFields();
      // 先post生成id
      axios.post("/users", {
        ...value,
        roleState: true,
        default: false
      }).then(res => {
        setdataSource([...dataSource, {
          ...res.data,
          // 提交数据中没有角色名称,是关联得来的
          role: roleList.filter(item => item.id === value.roleId)[0]
        }])
      })
      // 
    }).catch(err => {
      console.log(err)
    })
  };
  const handleCancel = () => {
    setIsModalVisible(false);
  };
  // 更新用户对话框
  const showUpdate = (item) => {
    setTimeout(()=> {setisUpdateVisible(true);
      if(item.roleId===1) {
        setisUpdateDisabled(true)
      } else {
        setisUpdateDisabled(false)
      };
    updateForm.current.setFieldsValue(item)},0)
    setcurrent(item);
  };
  const updateOk = () => {
    updateForm.current.validateFields().then(value => {
      setisUpdateVisible(false);
      setdataSource(dataSource.map(item => {
        if(item.id===current.id) {
          return {
            ...item,
            ...value,
            role:roleList.filter(data => data.id === value.roleId)[0]
          }
        }
        return item
      }))
      setisUpdateDisabled(!isUpdateDisabled);
      axios.patch(`/users/${current.id}`,value)
    })
  };
  const updateCancel = () => {
    setisUpdateVisible(false);
    setisUpdateDisabled(!isUpdateDisabled);
  };

  const columns = [
    {
      title: "区域",
      dataIndex: "region",
      filters:[
        ...regionList.map(item => ({
          text:item.title,
          value:item.value
        })),
        {
          text:"全球",
          value:"全球"
        }
      ],
      onFilter: (value, item) => {
        if(value==="全球") {
          return item.region === ""
        } 
      return item.region.includes(value)
      },
      render: (region) => {
        return {region === "" ? "全球" : region}
      }
    },
    {
      title: "角色名称",
      dataIndex: "role",
      render: (role) => {
        return role?.roleName
      }
    },
    {
      title: "用户名称",
      dataIndex: "username"
    },
    {
      title: "用户状态",
      dataIndex: "roleState",
      render: (roleState, item) => {
        return  switchMethod(item)} />
      }
    },
    {
      title: "操作",
      render: (item) => {
        return 
} } ] return (
item.id} /> ) }

3.2.3.2 components/user-manage/UserForm.js

用户表单

  • 使用forwardRef保证父组件能拿到和控制子组件(本组件)的节点和值
  • 有props,父组件需要向该组件传递isUpdateDisabled、isUpdate、regionList、roleList四个属性
  • 该组件返回一个表单:用户名、密码、区域、角色
  • 区域的验证规则:
    1. 用isDisabled状态接受props.isUpdateDisabled;
    2. isDisabled为真时,没有规则校验;为假时触发校验;
    3. isDisabled为真时,禁用select选择。
      结合父组件看:
    4. isUpdateDisabled默认值为false;
    5. 弹出更新框时,校验当前修改的用户角色,如果是超级管理员,则设为true;否则设为假。
    6. 当关闭更新框时,对该值进行取反。

从用户逻辑的角度,当修改某个用户时:
【父组件点击弹出更新框时】如果修改用户初始为超级管理员,设置isUpdateDisabled为真,避免违反子组件校验规则;否则设置isUpdateDisabled为假,子组件正常校验。设置完之后使用updateForm.current.setFieldsValue(item)向子组件填写数据。
【进入到子组件表单】当修改某用户为超级管理员时,设置isDisabled为真,避免违反区域校验规则;并使用ref.current.setFieldsValue({ region: ""})设置区域为空。当修改某用户为其他时,设置isDisabled为假。
【当点击提交或者取消】对isUpdateDisabled进行取反,让子组件useEffect()函数检测到状态变化,清除掉上一次的内容。否则会出现BUG:当点击修改某个非超级,传入isUpdateDisabled false;修改其为超级,此时isDisabled为true;点击取消,再次点击修一个非超级,传入isUpdateDisabled false,useEffect()没有检测到变化,不更改isDisabled,这样出现了非超级的区域被禁用的情况。

  • 因为使用了Modal,所以提交数据的时候不知道更新的哪条数据,因此需要一个current状态来比对推送后端。
const [isDisabled, setisDisabled] = useState(false)
    useEffect(()=> {
        setisDisabled(props.isUpdateDisabled)
    },[props.isUpdateDisabled])
...
rules={isDisabled ? [] : [{ required: true, message: 'Please input your username!' }]}
...

完整代码

import React, { forwardRef, useEffect, useState } from 'react'
import { Form, Input, Select } from 'antd'

const { Option } = Select;

const UserForm = forwardRef((props, ref) => {
    const [isDisabled, setisDisabled] = useState(false)
    useEffect(()=> {
        setisDisabled(props.isUpdateDisabled)
    },[props.isUpdateDisabled])
    const {roleId,region}  = JSON.parse(localStorage.getItem("token"))
    const roleObj = {
        "1":"superadmin",
        "2":"admin",
        "3":"editor"
    }
    const checkRegionDisabled = (item)=>{
        if(props.isUpdate){
            if(roleObj[roleId]==="superadmin"){
                return false
            }else{
                return true
            }
        }else{
            if(roleObj[roleId]==="superadmin"){
                return false
            }else{
                return item.value!==region
            }
        }
    }

    const checkRoleDisabled = (item)=>{
        if(props.isUpdate){
            if(roleObj[roleId]==="superadmin"){
                return false
            }else{
                return true
            }
        }else{
            if(roleObj[roleId]==="superadmin"){
                return false
            }else{
                return roleObj[item.id]!=="editor"
            }
        }
    }
    return (
        
) }) export default UserForm

3.2.4 news-manage

新闻管理,主要页面:新闻分类、新增新闻、新闻草稿箱,以及新闻预览、新闻更新

3.2.4.1 NewsCategory.js

新闻分类
新闻分类主要难点在可编辑单元格,参考https://ant.design/components/table-cn/#components-table-demo-edit-cell 的实现

import React, { useState, useEffect,useRef,useContext } from 'react'
import { Button, Table, Modal,Form,Input } from 'antd'
import axios from 'axios'
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
const { confirm } = Modal

export default function NewsCategory() {
    const [dataSource, setdataSource] = useState([])
    // 初始化新闻分类数据
    useEffect(() => {
        axios.get("/categories").then(res => {
            setdataSource(res.data)
        })
    }, [])
    // 修改数据保存,推送后端
    const handleSave = (record)=>{
        // console.log(record)
        setdataSource(dataSource.map(item=>{
            if(item.id===record.id){
                return {
                    id:item.id,
                    title:record.title,
                    value:record.title
                }
            }
            return item
        }))
        axios.patch(`/categories/${record.id}`,{
            title:record.title,
            value:record.title
        })
    }
    // 表单列
    const columns = [
        {
            title: 'ID',
            dataIndex: 'id',
            render: (id) => {
                return {id}
            }
        },
        // 栏目列需可修改
        {
            title: '栏目名称',
            dataIndex: 'title',
            onCell: (record) => ({
                record,
                editable: true,
                dataIndex: 'title',
                title: '栏目名称',
                handleSave: handleSave,
              }),
      
        },
        {
            title: "操作",
            render: (item) => {
                return 
} } ]; // 确认删除 const confirmMethod = (item) => { confirm({ title: '你确定要删除?', icon: , // content: 'Some descriptions', onOk() { // console.log('OK'); deleteMethod(item) }, onCancel() { // console.log('Cancel'); }, }); } // 数据删除 const deleteMethod = (item) => { // 当前页面同步状态 + 后端同步 setdataSource(dataSource.filter(data => data.id !== item.id)) axios.delete(`/categories/${item.id}`) } // 参考https://ant.design/components/table-cn/#components-table-demo-edit-cell // 使用Context来实现跨层级的组件数据传递 const EditableContext = React.createContext(null); const EditableRow = ({ index, ...props }) => { const [form] = Form.useForm(); return (
); }; const EditableCell = ({ title, editable, children, dataIndex, record, handleSave, ...restProps }) => { const [editing, setEditing] = useState(false); const inputRef = useRef(null); const form = useContext(EditableContext); useEffect(() => { if (editing) { inputRef.current.focus(); } }, [editing]); const toggleEdit = () => { setEditing(!editing); form.setFieldsValue({ [dataIndex]: record[dataIndex], }); }; const save = async () => { try { const values = await form.validateFields(); toggleEdit(); handleSave({ ...record, ...values }); } catch (errInfo) { console.log('Save failed:', errInfo); } }; let childNode = children; if (editable) { childNode = editing ? ( ) : (
{children}
); } return
; }; return (
{childNode}
item.id} components={{ body: { row: EditableRow, cell: EditableCell, } }} /> ) }

3.2.4.2 NewsAdd.js

3.2.4.3 NewsUpdate.js

3.2.4.4 NewsDraft,js

3.2.4.5 NewsPreview.js

3.2.5 audit-manage

3.2.5.1 Audit.js

3.2.5.2 AuditList.js

3.2.6 publish-manage

3.2.6.1 Published.js

3.2.6.2 Unpublished.js

3.2.6.3 Sunset.js

4. 总结

看组件的js时,先看他返回了什么(就是实际渲染了什么);再去看状态

参考:

theme-color
meta
overflow
Public
package.json
package.json逐行解释

react-scripts
web-vitals性能检测工具
package-lock.json

NProgress
antd layout

antd 可编辑单元格