之前做了一個react后台管理系統,因此有一定的基礎。
QUESTION:在閱讀antd組件源碼時,發現還有很多看不懂,非常吃力。一方面是因為自己沒有tsx組件設計的經驗(其實沒有ts的使用經驗,也只是能看懂);也有一部分是不會快速上手一個項目
1. 概覽
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四個屬性
- 該組件返回一個表單:用戶名、密碼、區域、角色
- 區域的驗證規則:
- 用isDisabled狀態接受props.isUpdateDisabled;
- isDisabled為真時,沒有規則校驗;為假時觸發校驗;
- isDisabled為真時,禁用select選擇。
結合父組件看: - isUpdateDisabled默認值為false;
- 彈出更新框時,校驗當前修改的用戶角色,如果是超級管理員,則設為true;否則設為假。
- 當關閉更新框時,對該值進行取反。
從用戶邏輯的角度,當修改某個用戶時:
【父組件點擊彈出更新框時】如果修改用戶初始為超級管理員,設置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 可編輯單元格