如何读懂一个react后台项目

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

1. 概览

image

1.1 node_modules

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

1.2 public

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

<!DOCTYPE html>
<html lang="en">
  <head>
  <!--  <meta> 元素可用于提供 名称 - 值 对形式的文档元数据,name 属性为元数据条目提供名称,而 content 属性提供值。 -->
    <meta charset="utf-8" />
	<!-- 页面图标 -->
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
	<!-- 移动设备:宽度为设备宽度,初始缩放值为1.参考https://www.cnblogs.com/yelongsan/p/7975580.html -->
    <meta name="viewport" content="width=device-width, initial-scale=1" />
	<!-- 用户界面颜色。参考https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/meta/name/theme-color -->
    <meta name="theme-color" content="#000000" />
	<!-- 页面描述:使用了create react app开发 -->
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
	<!--IOS设备的私有标签.参考https://www.cnblogs.com/blosaa/p/3977975.html -->
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
	<!--manifest.json 是每个 WebExtension 唯一必须包含的元数据文件。-->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
	<!--标题-->
    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

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(
 <App />,
  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不熟悉的可以参考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 <Provider store={store}>
  <IndexRouter></IndexRouter>
  </Provider>
}
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 (
        <Router>
            <Routes>
                <Route path="/login" element={<Login />} /> <!--登录-->
                <Route path="/news" element={<News />}/>  <!--游客访问新闻-->
                <Route path="/detail/:id" element={<Detail />}/>  <!--游客访问新闻详情-->
                <Route path="/*" element={localStorage.getItem("token")?<NewsSandBox />:<Navigate to="/login" />} /> <!--判断登录并导航到NewsSandBox组件-->
             {/*
                {localStorage.getItem("token")?<Route path="/" element={<NewSandBox />} />:<Route path="*" element={<Navigate to="/login" />} />}
            */}
            </Routes>
        </Router>
    )
}

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

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 (
        <Layout> <!--采用了antd Layout布局https://ant.design/components/layout-cn/#components-layout-demo-custom-trigger-->
            <SideMenu></SideMenu>

            <Layout className="site-layout">
                <TopHeader></TopHeader>
                <Content <!-- 页面主体内容 -->
                    className="site-layout-background"
                    style={{
                        margin: '24px 16px',
                        padding: 24,
                        minHeight: 280,
                    }}
                >
<!-- 内容路由 -->
                    <NewsRouter></NewsRouter>
                </Content>
            </Layout>
        </Layout>
    )
}

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: <UserOutlined />
//   },
//   {
//     key: "/user-manage",
//     title: "用户管理",
//     icon: <UserOutlined />,
//     children: [
//       {
//         key: "/user-manage/list",
//         title: "用户列表",
//         icon: <UserOutlined />
//       }
//     ]
//   },
//   {
//     key: "/right-manage",
//     title: "权限管理",
//     icon: <UserOutlined />,
//     children: [
//       {
//         key: "/right-manage/role/list",
//         title: "角色列表",
//         icon: <UserOutlined />
//       },
//       {
//         key: "/right-manage/right/list",
//         title: "角色列表",
//         icon: <UserOutlined />
//       }
//     ]
//   }
// ]
// 定义了一个对象,映射侧边栏路径与图标
const iconList = {
  "/home": <UserOutlined />,
  "/user-manage": <UserOutlined />,
  "/user-manage/list": <UserOutlined />,
  "/right-manage": <UserOutlined />,
  "/right-manage/role/list": <UserOutlined />,
  "/right-manage/right/list": <UserOutlined />
  //.......
}

// 既然用到了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 <SubMenu key={item.key} icon={iconList[item.key]} title={item.title}> // 渲染父菜单
          {renderMenu(item.children)} // 递归调用渲染子菜单
        </SubMenu>
      }
      return checkPagePermission(item) && <Menu.Item key={item.key} icon={iconList[item.key]} onClick={() =>
        navigate(item.key)
      }>{item.title}</Menu.Item> // 没有子菜单的直接检查权限并渲染
    })
  }

  return (
    <Sider trigger={null} collapsible collapsed={props.isCollapsed}> <!-- 隐藏默认trigger;collapsible表示可收起;collapsed表示当前收起状态,使用从store传来的props的值 -->
      <div style={{ display: "flex", height: "100%", flexDirection: 'column' }}> <!--流式布局-->
        <div className="logo" >新闻发布后台管理系统</div>
        <div style={{ flex: 1, overflow: 'auto' }}> <!-- auto 元素内容太大,在块级内部产生滚动条-->
          <Menu theme="dark" mode="inline" selectedKeys={selectedkeys} className="aaaaaaa" openKeys={openkeys}><!-- mode菜单类型内嵌 -->
            {renderMenu(menu)}
          </Menu>
        </div>
      </div>
    </Sider>
  )
}
// 利用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 = (
    <Menu>
      <Menu.Item key="roleName">{roleName}</Menu.Item>
      <Menu.Item danger onClick={leaveMethod} key="leave">退出</Menu.Item>
    </Menu>
  );
  return (
    <Header className="site-layout-background" style={{ padding: '0 16px' }}>
      {/* {React.createElement(this.state.collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
              className: 'trigger',
              onClick: this.toggle,
            })} */}
<!-- 三目运算符判断折叠图标,点击时触发changeCollapsed方法,传递出action -->
      {props.isCollapsed ? <MenuUnfoldOutlined onClick={changeCollapsed} /> : <MenuFoldOutlined onClick={changeCollapsed} />}

      <div style={{ float: "right" }}>
        <span>欢迎<span style={{color:"#1890ff"}}>{username}</span>回来</span>
        <Dropdown overlay={menu}> <!-- 点击获得下拉抽屉 -->
        <span>
        <Avatar size="large" icon={<UserOutlined />} />
        </span>
        </Dropdown>
      </div>
    </Header>
  )
}

/*
 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": <Home />,
    "/user-manage/list": <UserList />,
    "/right-manage/role/list": <RoleList />,
    "/right-manage/right/list": <RightList />,
    "/news-manage/add": <NewsAdd />,
    "/news-manage/draft": <NewsDraft />,
    "/news-manage/preview/:id":<NewsPreview />,
    "/news-manage/update/:id":<NewsUpdate />,
    "/news-manage/category": <NewsCategory />,
    "/audit-manage/audit": <Audit />,
    "/audit-manage/list": <AuditList />,
    "/publish-manage/unpublished": <Unpublished />,
    "/publish-manage/published": <Published />,
    "/publish-manage/sunset":<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 (
        <Spin size="large" spinning={props.isLoading}> <!--根据store的isLoading状态来展示加载圈-->
        <Routes> <!-- 对初始路由列表遍历,如果路由自身页面权限以及用户权限检查都通过后,渲染对应的组件;如果没有对应权限则展示无权限页面 -->
            {BackRouteList.map(item => {
                if(checkRoute(item) && checkUserPermission(item)){<!-- Suspense组件用于在组件加载时展示一个页面,实际上这与最外层的Spin重复了 -->
                   return <Route path={item.key} key={item.key} element={<Suspense fallback={<div>Loading...</div>}>{LocalRouterMap[item.key]}</Suspense>} />
                   }
                return <Route path="*" key="NoPermission" element={<NoPermission />} />
            }

            )}
            <Route path='/' element={<Navigate to="/home" />} />
        </Routes>
        </Spin>
    )
}
// 从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 (
    <div style={{background:'rgb(35, 39, 65)', height: "100%", overflow:"hidden"}}
    >
    <Particles height={document.documentElement.clientHeight}params={
                {
                    "background": {
                      "color": {
                        "value": "rgb(35, 39, 65)"
                      },
                      "position": "50% 50%",
                      "repeat": "no-repeat",
                      "size": "cover"
                    },
                    "fullScreen": {
                      "enable": true,
                      "zIndex": 1
                    },
                    "interactivity": {
                      "events": {
                        "onClick": {
                          "enable": true,
                          "mode": "push"
                        },
                        "onHover": {
                          "enable": true,
                          "mode": "bubble",
                          "parallax": {
                            "force": 60
                          }
                        }
                      },
                      "modes": {
                        "bubble": {
                          "distance": 400,
                          "duration": 2,
                          "opacity": 1,
                          "size": 40
                        },
                        "grab": {
                          "distance": 400
                        }
                      }
                    },
                    "particles": {
                      "color": {
                        "value": "#ffffff"
                      },
                      "links": {
                        "color": {
                          "value": "#fff"
                        },
                        "distance": 150,
                        "opacity": 0.4
                      },
                      "move": {
                        "attract": {
                          "rotate": {
                            "x": 600,
                            "y": 1200
                          }
                        },
                        "enable": true,
                        "outModes": {
                          "default": "bounce",
                          "bottom": "bounce",
                          "left": "bounce",
                          "right": "bounce",
                          "top": "bounce"
                        },
                        "speed": 6
                      },
                      "number": {
                        "density": {
                          "enable": true
                        },
                        "value": 170
                      },
                      "opacity": {
                        "animation": {
                          "speed": 1,
                          "minimumValue": 0.1
                        }
                      },
                      "shape": {
                        "options": {
                          "character": {
                            "fill": false,
                            "font": "Verdana",
                            "style": "",
                            "value": "*",
                            "weight": "400"
                          },
                          "char": {
                            "fill": false,
                            "font": "Verdana",
                            "style": "",
                            "value": "*",
                            "weight": "400"
                          },
                          "polygon": {
                            "nb_sides": 5
                          },
                          "star": {
                            "nb_sides": 5
                          },
                          "image": {
                            "height": 32,
                            "replace_color": true,
                            "src": "/logo192.png",
                            "width": 32
                          },
                          "images": {
                            "height": 32,
                            "replace_color": true,
                            "src": "/logo192.png",
                            "width": 32
                          }
                        },
                        "type": "image"
                      },
                      "size": {
                        "value": 16,
                        "animation": {
                          "speed": 40,
                          "minimumValue": 0.1
                        }
                      },
                      "stroke": {
                        "color": {
                          "value": "#000000",
                          "animation": {
                            "h": {
                              "count": 0,
                              "enable": false,
                              "offset": 0,
                              "speed": 1,
                              "sync": true
                            },
                            "s": {
                              "count": 0,
                              "enable": false,
                              "offset": 0,
                              "speed": 1,
                              "sync": true
                            },
                            "l": {
                              "count": 0,
                              "enable": false,
                              "offset": 0,
                              "speed": 1,
                              "sync": true
                            }
                          }
                        }
                      }
                    }
                  }
            }/>
      <div className="formContainer">
                <div className="logintitle">全球新闻发布管理系统</div>
                <Form
                    name="normal_login"
                    className="login-form"
                    onFinish={onFinish}
                >
                    <Form.Item
                        name="username"
                        rules={[{ required: true, message: 'Please input your Username!' }]}
                    >
                        <Input prefix={<UserOutlined className="site-form-item-icon" />} placeholder="Username" />
                    </Form.Item>
                    <Form.Item
                        name="password"
                        rules={[{ required: true, message: 'Please input your Password!' }]}
                    >
                        <Input
                            prefix={<LockOutlined className="site-form-item-icon" />}
                            type="password"
                            placeholder="Password"
                            autoComplete="on"
                        />
                    </Form.Item>
                    <Form.Item>
                        <Button type="primary" htmlType="submit" className="login-form-button">
                            登录
                     </Button>
                    </Form.Item>
                </Form>
            </div>
    </div>
  )
}

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 (
    <div>
      <Row gutter={16}>
        <Col span={8}>
          <Card title="用户最常浏览" bordered={true}>
            <List
              size="small"
              // bordered
              dataSource={viewList}
              renderItem={item => <List.Item>
                <a href={`#/news-manage/preview/${item.id}`}>{item.title}</a>
              </List.Item>}
            />
          </Card>
        </Col>
        <Col span={8}>
          <Card title="用户点赞最多" bordered={true}>
            <List
              size="small"
              // bordered
              dataSource={starList}
              renderItem={item => <List.Item>
                <a href={`#/news-manage/preview/${item.id}`}>{item.title}</a>
              </List.Item>}
            />
          </Card>
        </Col>
        <Col span={8}>
          <Card
            cover={
              <img
                alt="example"
                src="/moji.webp"
                height="159px"
                width="262px"
              />
            }
            actions={[
              <SettingOutlined key="setting" onClick={() => {
                                setTimeout(() => {
                                    setvisible(true)
                                    // init初始化
                                    renderPieView()
                                }, 0)
                            }} />,
              <EditOutlined key="edit" />,
              <EllipsisOutlined key="ellipsis" />,
            ]}
          >
            <Meta
              avatar={<Avatar src="/personMin.webp" alt="person"/>}
              title={username}
              description={
                <div>
                  <b>{region ? region : "全球"}</b>
                  <span style={{
                    paddingLeft: "30px"
                  }}>{roleName}</span>
                </div>
              }
            />
          </Card>
        </Col>
      </Row>

      <Drawer
                width="500px"
                title="个人新闻分类"
                placement="right"
                closable={true}
                onClose={() => {
                    setvisible(false)
                }}
                visible={visible}
            >
                <div ref={pieRef} style={{
                    width: '100%',
                    height: "400px",
                    marginTop: "30px"
                }}></div>
            </Drawer>

      <div ref={barRef} style={{
        width: '100%',
        height: "300px",
        marginTop: "30px"
      }}></div>
    </div>
  )
}

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 <b>{id}</b>
      }
  },
  {
      title: '角色名称',
      dataIndex: 'roleName'
  },
  {
      title: "操作",
      render: (item) => {
        return <div>
        <Button danger shape="circle" icon={<DeleteOutlined />} onClick={() => confirmDelete(item)} />
        <Button type="primary" shape="circle" icon={<EditOutlined />} onClick={() => {
          setisModalVisible(true);
          setcurrentRights(item.rights);
          setcurrentId(item.id);
        }} />
      </div>
      }
  }
  ];

  const confirmDelete = (item) => {
    confirm({
      title: '你确定要删除?',
      icon: <ExclamationCircleOutlined />,
      // 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 (
    <div>
      <Table dataSource={dataSource} columns={columns} rowKey={(item) => item.id} />

      <Modal title="权限分配" visible={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
      <Tree
      checkable
      checkedKeys={currentRights}
      onCheck={onCheck}
      checkStrictly
      treeData={rightList}
    />
      </Modal>
    </div>
  )
}

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: <ExclamationCircleOutlined />,
      // 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 <b>{id}</b>
      }
    },
    {
      title: "权限名称",
      dataIndex: "title"
    },
    {
      title: "权限路径",
      dataIndex: "key",
      render: (key) => {
        return <Tag color="orange">{key}</Tag>
      }
    },
    {
      title: "操作",
      render: (item) => {
        return <div>
          <Button danger shape="circle" icon={<DeleteOutlined />} onClick={() => confirmDelete(item)} />
          <Popover title="页面配置项" content={
            <div style={{ textAlign: "center" }}>
            <Switch checked={item.pagepermisson} onChange={() => switchMethod(item)} />
            </div>
          } trigger={item.pagepermisson===undefined?'':"click"}>
            <Button type="primary" shape="circle" icon={<EditOutlined />} disabled={item.pagepermisson===undefined} />
          </Popover>
        </div>
      }
    }
  ]
  return (
    <div>
      <Table dataSource={dataSource} columns={columns} pagination={{
        pageSize: 5,
      }} />
    </div>
  )
}

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: <ExclamationCircleOutlined />,
      // 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 <b>{region === "" ? "全球" : region}</b>
      }
    },
    {
      title: "角色名称",
      dataIndex: "role",
      render: (role) => {
        return role?.roleName
      }
    },
    {
      title: "用户名称",
      dataIndex: "username"
    },
    {
      title: "用户状态",
      dataIndex: "roleState",
      render: (roleState, item) => {
        return <Switch checked={roleState} disabled={item.default} onChange={() => switchMethod(item)} />
      }
    },
    {
      title: "操作",
      render: (item) => {
        return <div>
          <Button danger shape="circle" icon={<DeleteOutlined />} onClick={() => confirmDelete(item)} disabled={item.default} />
          <Button type="primary" shape="circle" icon={<EditOutlined />} disabled={item.default} onClick={() => showUpdate(item)} />
        </div>
      }
    }
  ]
  return (
    <div>
      <Button type='primary' onClick={showModal}>增加用户</Button>
      <Modal title="添加用户" okText="确定" cancelText="取消" visible={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
        <UserForm ref={addForm} regionList={regionList} roleList={roleList} />
      </Modal>

      <Modal title="更新用户" okText="更新" cancelText="取消" visible={isUpdateVisible} onOk={updateOk} onCancel={updateCancel}>
      <UserForm ref={updateForm} regionList={regionList} roleList={roleList} isUpdateDisabled={isUpdateDisabled} />
    </Modal>

      <Table dataSource={dataSource} columns={columns} pagination={{
        pageSize: 5,
      }}
        rowKey={item => item.id}
      />
    </div>
  )
}

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!' }]}
...
<Select disabled={isDisabled} >
                    {props.regionList.map(item => {
                        return <Option value={item.value} key={item.id} disabled={checkRegionDisabled(item)}>{item.title}</Option>
                    })}
                </Select>

完整代码

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 (
        <Form
            ref={ref} // ref传递
            layout='vertical' // 垂直布局
        >
            <Form.Item
                label="用户名"
                name="username"
                rules={[{ required: true, message: 'Please input your username!' }]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="密码"
                name="password"
                rules={[{ required: true, message: 'Please input your username!' }]}
            >
                <Input />
            </Form.Item>
            <Form.Item
                label="区域"
                name="region"
                rules={isDisabled ? [] : [{ required: true, message: 'Please input your username!' }]}
            >
                <Select disabled={isDisabled} >
                    {props.regionList.map(item => {
                        return <Option value={item.value} key={item.id} disabled={checkRegionDisabled(item)}>{item.title}</Option>
                    })}
                </Select>
            </Form.Item>
            <Form.Item
                label="角色"
                name="roleId"
                rules={[{ required: true, message: 'Please input your username!' }]}
            >
                <Select onChange={(value) => {
                    if (value === 1) {
                        setisDisabled(true);
                        ref.current.setFieldsValue({ // ref.current.setFieldsValue改变要目前的表单数据
                            region: ""
                        })
                    } else {
                        setisDisabled(false)
                    }
                }}>
                    {props.roleList.map(item => {
                        return <Option value={item.id} key={item.id} disabled={checkRoleDisabled(item)}>{item.roleName}</Option>
                    })}
                </Select>
            </Form.Item>
        </Form>
    )
})

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 <b>{id}</b>
            }
        },
        // 栏目列需可修改
        {
            title: '栏目名称',
            dataIndex: 'title',
            onCell: (record) => ({
                record,
                editable: true,
                dataIndex: 'title',
                title: '栏目名称',
                handleSave: handleSave,
              }),
      
        },
        {
            title: "操作",
            render: (item) => {
                return <div>
                    <Button danger shape="circle" icon={<DeleteOutlined />} onClick={() => confirmMethod(item)} />
                </div>
            }
        }
    ];
    // 确认删除
    const confirmMethod = (item) => {
        confirm({
            title: '你确定要删除?',
            icon: <ExclamationCircleOutlined />,
            // 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 (
            <Form form={form} component={false}>
                <EditableContext.Provider value={form}>
                    <tr {...props} />
                </EditableContext.Provider>
            </Form>
        );
    };
    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 ? (
                <Form.Item
                    style={{
                        margin: 0,
                    }}
                    name={dataIndex}
                    rules={[
                        {
                            required: true,
                            message: `${title} is required.`,
                        },
                    ]}
                >
                    <Input ref={inputRef} onPressEnter={save} onBlur={save} />
                </Form.Item>
            ) : (
                    <div
                        className="editable-cell-value-wrap"
                        style={{
                            paddingRight: 24,
                        }}
                        onClick={toggleEdit}
                    >
                        {children}
                    </div>
                );
        }

        return <td {...restProps}>{childNode}</td>;
    };



    return (
        <div>
             <Table dataSource={dataSource} columns={columns}
                pagination={{
                    pageSize: 5
                }}
                rowKey={item => item.id}

                 components={{
                     body: {
                         row: EditableRow,
                         cell: EditableCell,
                     }
                 }}

            />
        </div>
    )
}

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时,先看他返回了什么(就是实际渲染了什么);再去看状态

参考:
移动前端开发之viewport的深入理解
theme-color
meta
overflow
Public
package.json
package.json逐行解释
性能优化
react-scripts
web-vitals性能检测工具
package-lock.json
hashrouter
NProgress
antd layout
Redux入门
antd 可编辑单元格

posted @ 2022-03-15 23:27  沧浪浊兮  阅读(1247)  评论(0编辑  收藏  举报