本項目1.0完成於2022年3月8日,請注意時效~
// 暫不可用!項目部署預覽地址:點擊查看
// 暫不可用!項目Github地址(包含所有源碼、數據):點擊查看如有疑問請私信博客,期待你的star!
項目Gitee地址(包含所有源碼、數據):點擊查看如有疑問請私信博客,期待你的star!
React教程參考:千鋒2022版React全家桶教程
一、項目目標
1.1 個人期望
經過了下列文檔&刷題,期望實戰中提高。
- MDN:web/html/css/js文檔
- React官方文檔
- FCC:響應式網頁設計(html5等)/js算法與數據結構(ES6、面向對象編程、函數式編程、算法等)/前端開發庫(react/redux/sass等)刷題
技術目標:使用React全家桶
- React組件開發
- React Hooks
- React Router
- Recat Redux
- Antd組件庫
1.2 產品選擇
可借鑒的有:網易雲音樂PC項目、后台管理系統項目。結合使用帆軟數據可視化產品的經驗,選擇做后台管理項目。業務交互選擇新聞后台管理。
1.3 項目描述
實現一個新聞發布管理平台,業務目標:
- 用戶登錄
- 游客訪問:瀏覽新聞
- 用戶管理:新增用戶、修改用戶、刪除用戶、禁用用戶
- 權限管理:角色管理、頁面訪問權限控制、側邊欄權限控制
- 新聞業務:撰寫新聞、草稿箱、新聞審核、新聞發布及下線等
1.4 適合人群
- 對前端有興趣
- 對HTML/CSS/JS/REACT有一定了解
- 希望有一定的的那個項目經驗
1.5 推薦用時
60h~100h
二、技術選型
- create-react-app:腳手架
- React Hooks:函數式編程,用過的都說真的爽
- React Router V6:路由控制訪問,V6升級了許多東西
- Recat Redux:狀態管理,組件通信
- Antd組件庫:你為什么要使用react?
- axios:實現網絡請求
- JSON Server:生成數據接口
- react-tsparticles:登陸頁面粒子美化
- draft-js:富文本編輯
- draftjs-to-html:富文本轉換html
- html-to-draftjs:html轉換富文本
- Echarts:數據可視化(柱狀圖、餅圖)
- Sass: CSS輔助工具,實現變量、嵌套、導入
- http-proxy-middleware:開發環境反向代理跨域(前期使用練手,JSON Server不需要~);引入后需要重啟服務器
- CSS Modules: CSS模塊化,選取class
.moduleTest
或id選擇器,將CSS module文件引入style變量,設置className={style.moduleTest}
三、項目模塊文檔
3.1 登錄
實現用戶登錄功能:用戶進入登陸頁面,輸入必填項賬號及密碼,點擊登錄校驗賬號密碼,登錄成功后保存狀態,跳轉至home頁面;若登陸失敗彈出“用戶名或密碼不匹配”。
實現效果:
(首頁使用了粒子效果太大了,展示不出來)
3.2 首頁
首頁展示四個模塊:用戶最常瀏覽、用戶點贊最多、用戶信息、新聞分類。用戶最常瀏覽模塊展示瀏覽量最多的6個新聞標題;用戶點贊最多模塊展示點贊量最多的6個新聞標題;用戶信息展示用戶頭像&名稱&角色&地區,並設有按鈕彈出展示該用戶已發布新聞分類的餅圖;新聞分類使用柱狀圖展示所有用戶的新聞分類數量。其中,新聞標題可點擊預覽新聞內容。
實現效果:
3.3 用戶管理
用戶管理頁面展示用戶信息列表及用戶操作:包括新增用戶、區域篩選、用戶狀態開關、刪除用戶、編輯用戶等。用戶信息列表展示區域、角色、名稱、狀態、操作(刪除、編輯)。超級管理員可以添加、刪除、編輯所有用戶;區域管理員盡可以新增、刪除、編輯本用戶及本區域下的區域編輯用戶;區域編輯沒有本頁面權限。
3.4 權限管理
權限管理包含兩個頁面:角色列表;權限列表。
角色列表展示角色ID、角色名稱、角色操作(刪除角色、編輯角色權限)。
權限列表展示頁面ID、權限名稱、權限路徑、操作(刪除路徑、路徑配置狀態)。
實現效果:
3.5 新聞管理
新聞管理包含三個頁面:撰寫新聞、草稿箱、新聞分類。
撰寫新聞包括:新聞標題、新聞分類(下拉選擇)、新聞內容、新聞提交。其中新聞提交要包括保存草稿箱及提交審核兩個操作。
點擊保存草稿箱,跳轉至草稿箱頁面,並在右下側通知用戶相關消息,草稿箱頁面顯示新聞ID、新聞標題(新聞標題可點擊預覽新聞內容)、作者、分類、操作(刪除、修改、提交審核)。
點擊提交審核,將跳轉至審核管理-審核列表,並在右下側通知用戶相關消息。
新聞分類頁面展示分類ID、分類名稱(可修改)、操作(刪除)。
實現效果:
3.6 審核管理
審核管理包括兩個頁面:審核新聞、審核列表。
審核新聞頁面展示待審核的新聞項,內容有:新聞標題、作者、分類、操作(通過、駁回)。點擊通過或駁回在右下側通知用戶相關消息。
審核列表展示本用戶在審核階段的新聞,內容有:新聞標題、作者、分類、審核狀態、操作。若審核狀態為未通過、操作為更新;若審核狀態為已通過、操作為發布。點擊更新可編輯新聞內容(類似撰寫新聞的頁面);點擊發布則跳轉至已發布頁面,並在右下側通知用戶相關消息。
實現效果:
3.7 發布管理
發布管理包括三個頁面:待發布、已發布、已下線。
待發布頁面展示本用戶審核通過仍未發布的新聞,內容有新聞標題、作者、分類、操作(發布)。
已發布頁面展示本用戶已發布的新聞,內容有新聞標題、作者、分類、操作(下線)。
已下線頁面展示本用戶已下線的新聞,內容有新聞標題、作者、分類、操作(刪除)。
實現效果:
四、項目規范
- 文件夾、文件名稱統一小寫
- JS組件采用大駝峰命名,比那輛采用小駝峰命名
- 使用hooks編寫
- 豐富注釋
- rudux:每個模塊有自己獨立的reducer,通過combineReducer進行合並
五、技術文檔
- 接口:使用JSON Server部署本地數據接口(http://localhost:5000)
功能 | 接口地址 | 調用案例 |
---|---|---|
用戶 | /users | 獲取用戶及其角色權限/users?_expand=role |
角色權限 | /roles | |
子菜單 | /children | |
父菜單 | /rights | 取父子菜單/rights?_embed=children |
新聞分類 | /categories | |
區域 | /regions | |
新聞 | /news | 獲取對應新聞內容、分類及作者權限/news/${id}?_expand=category&_expand=role// 審核狀態、發布狀態映射(數組id即為狀態碼) const auditList = ["未審核", '審核中', '已通過', '未通過'] const publishList = ["未發布", '待發布', '已上線', '已下線'] |
- 路由架構
// V6實例
import React from 'react'
import {
HashRouter as Router,
Routes,
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" />} />
{/*
{localStorage.getItem("token")?<Route path="/" element={<NewSandBox />} />:<Route path="*" element={<Navigate to="/login" />} />}
*/}
</Routes>
</Router>
)
}
- 簡單數據處理:使用lodash進行簡單數據處理
renderBarView(_.groupBy(res.data, item => item.category.title))
- 頂欄控制側邊欄伸縮
// 頂欄組件
const mapStateToProps = ({CollapsedReducer: {isCollapsed}})=>{
return {
isCollapsed
}
}
const mapDispatchToProps = {
changeCollapsed(){
return {
type:"change_collapsed"
}
}
}
export default connect(mapStateToProps,mapDispatchToProps)(TopHeader)
// 側邊欄組件
// 側邊欄伸縮,使用connect
const mapStateToProps = ({CollapsedReducer: {isCollapsed}})=>({isCollapsed})
export default connect(mapStateToProps)(SideMenu)
實現效果:
- 數據加載Loading,狀態持久化
// Redux store設置,使用黑名單避免&實現持久化
import {createStore,combineReducers} from 'redux'
import {CollapsedReducer} from './reducers/CollapsedReducer'
import {LoadingReducer} from './reducers/LoadingReducer'
import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
const persistConfig = {
key: 'hangyi',
storage,
blacklist: ['LoadingReducer']
}
const reducer = combineReducers({
CollapsedReducer,
LoadingReducer
})
const persistedReducer = persistReducer(persistConfig, reducer)
const store = createStore(persistedReducer);
const persistor = persistStore(store)
export {
store,
persistor
}
// Reducer設置
export const CollapsedReducer = (prevState={
isCollapsed:false
},action)=>{
let {type} =action
switch(type){
case "change_collapsed":
let newstate = {...prevState}
newstate.isCollapsed = !newstate.isCollapsed
return newstate
default:
return prevState
}
}
- JSON Server方法
//取數據 get
// axios.get("http://localhost:8000/posts/2").then(res=>{
// console.log(res.data)
// })
// 增 post
// axios.post("http://localhost:8000/posts",{
// title:"33333",
// author:"xiaoming"
// })
// 更新 put
// axios.put("http://localhost:8000/posts/1",{
// title:"1111-修改"
// })
// 更新 patch
// axios.patch("http://localhost:8000/posts/1",{
// title:"1111-修改-11111"
// })
// 刪除 delete
// axios.delete("http://localhost:8000/posts/1")
// _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 {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]];
// 側邊欄內容列表
const renderMenu = (menuList) => {
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>
})
}
- 多級用戶管理:在添加用戶、編輯用戶時,超級管理員可以隨意添加、區域管理員可以添加編輯本人及本區域下的區域編輯。
// 代碼節選
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]);
// 控制區域、角色的新增&編輯權限
// 父組件傳遞 regionList={regionList} roleList={roleList}等參數
<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>
// 子組件props.regionList
<Select disabled={isDisabled} >
{props.regionList.map(item => {
return <Option value={item.value} key={item.id} disabled={checkRegionDisabled(item)}>{item.title}</Option>
})}
</Select>
- 可編輯單元格
// 參考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>;
};
···
<Table dataSource={dataSource} columns={columns}
pagination={{
pageSize: 5
}}
rowKey={item => item.id}
components={{
body: {
row: EditableRow,
cell: EditableCell,
}
}}
/>
六、疑難巧點
6.1 react routerV6新版本
- Routes代替Switch
- element代替component:
// V6
element={<Login />}
// history
component={Login}
- Navigate干掉了Redirect:
// V6
<Route path="/*" element={localStorage.getItem("token")?<NewsSandBox />:<Navigate to="/login" />} />
// history
<Route path="/" render={()=>localStorage.getItem("token")?<NewsSandBox ></NewsSandBox>:<Redirect to="/login"/>}/>
- useNavigate, useLocation等代替withRouter,props.history.push等的方法
// V6
const location = useLocation();
const selectedkeys = location.pathname;
const openkeys = ["/" + location.pathname.split("/")[1]];
// history
const selectKeys = [props.location.pathname]
const openKeys = ["/"+props.location.pathname.split("/")[1]]
// 導航方法
// V6
const navigate = useNavigate();
navigate(item.key)
// history
props.history.push(item.key)
6.2 axios攔截
可以實現:
- 簡化每次axios請求的代碼量
- 實現加載數據時有提示
import axios from 'axios'
import {store} from '../redux/store'
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
store.dispatch({
type:"change_loading",
payload:true
})
return config;
}, function (error) {
// Do something with request error
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);
});
七、待補充
- 性能優化:useMemo、useCallback和memo等
- redux hooks
八、附錄
8.1 命令表
// 創建react-app
npx create-react-app my-app
// 進入該目錄
cd my-app
// 啟動工程
npm start
// npm安裝相關依賴(例如antd)
npm i --save antd
// JSON Server啟動(使用db.json文件,本地5000端口)
json-server --watch db.json --port 5000