一. 项目入门
1. 项目介绍
该项目是一个全球新闻发布管理系统,可供普通游客,超级管理员,区域管理员,和区域编辑四种角色访问,针对不同的角色所展示的页面也不相同,对于游客而言可以访问到新闻展示页面和新闻详情页面;对于超级管理员而言,可以对用户列表,角色列表,不同角色对应的权限进行管理,并且可以撰写新闻和审核新闻;对于区域管理员而言,它可以管理相应的区域编辑,发布新闻,并且对该区域的新闻进行审核和发布;而对于区域编辑而言,仅可以撰写新闻,审核自己的新闻以及发布该新闻。
项目中的数据是通过json-server模拟得到的,前端开发时不依赖后端数据,而是在本地搭建一个JSON服务,自己产生测试数据,关于json-server后面会有详细介绍。
2. 项目中用到的知识点
react, react hooks, react router5, react redux, antd组件库
3. 项目启动
- npm create-react-app newssystem ------------创建项目脚手架
- npm i sass --save --------------安装sass模块,使用嵌套语法
- npm i http-proxy-middleware --save,解决跨域问题,在src文件夹下添加一个setupProxy.js的文件,在里面进行如下配置:
const { createProxyMiddleware } = require('http-proxy-middleware'); module.exports = function (app) { app.use( '/api', createProxyMiddleware({ //表示存储当前数据的目标路径 target: 'http://localhost:5000', changeOrigin: true, }) ); }
但其实这里用的是json-server本地服务器,故不会存在跨域的问题。
二. 路由架构+搭建路由
1. 路由架构
- 若是用户访问时未登录,则重定向时/login路由界面
<Route path="/" render={() => localStorage.getItem("token") ? <NewsSandBox ></NewsSandBox> : <Redirect to="/login" />
2. 搭建路由
- 在src文件夹下建立component文件夹,写入页面中共享的SideMune侧边栏组件和TopHeader头部组件
- 对于页面中的路由展示区,使用Switch组件来匹配路径以及对应路由
三. antd组件库的引入
1.安装antd组件库 (官方网址:组件总览 - Ant Design)
-
npm i antd --save -------引入antd样式库,
-
在app.css文件引入antd的样式@import '~antd/dist/antd.css';
2. layout布局
- 在NewsSandBox列表里 import { Layout } from 'antd',并:
<Layout>
<SideMenu></SideMenu>
<Layout className="site-layout">
<TopHeader></TopHeader>
<Content
className="site-layout-background"
style={{
margin: '24px 16px',
padding: 24,
minHeight: 280,
overflow:"auto"
}}
>
<NewsRouter></NewsRouter>
</Content>
</Layout>
</Layout>
三. TopHeader组件
- 在渲染TopHeader组件时,首先判断本地存储中是否有用户的token,如果有则直接展示用户名,没有的话则重定向至登录界面。
const { role: { roleName }, username } = JSON.parse(localStorage.getItem("token"))
<div style={{ float: "right" }}>
<span>欢迎<span style={{ color: "#1890ff" }}>{username}</span>回来</span>
<Dropdown overlay={menu}>
{/* 头像图标 */}
<Avatar size="large" icon={<UserOutlined />} />
</Dropdown>
</div>
四. SideMenu组件
1. 动态SideMenu
- 动态数据获取,_embed=children表示获取当前数据的内联数据
useEffect(() => {
axios.get("/rights?_embed=children").then(res => {
// console.log(res.data)
setMeun(res.data)
})
}, [])
- 根据数据渲染界面,需要用到Menu和SubMenu两个antd组件库
将渲染过程先定义成一个函数:
const renderMenu = (menuList) => {
return menuList.map(item => {
// 如果item.children为false,则不会再取.length
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={() => {
// console.log(props)
props.history.push(item.key)
}}>{item.title}</Menu.Item>
})
}
这里在props.history.push(item.key)时会报错:无法在空对象中拿到.history属性,这是因为在注册路由组件中,只有login,news,detail,/这四个可以拿到路由组件提供的props,但是NewsSandBox作为后代组件是拿不到父组件的props属性的。解决方法:import { withRouter } from 'react-router-dom',引入高阶组件withRouter,在暴露子函数组件时:export default withRouter(SideMenu),这样SideMenu可以拿到所有的props。
再在函数式组件的return中:
<div style={{ flex: 1, "overflow": "auto" }}>
{/* 这里若是写defaultSelectedKeys,则会成为非受控组件,当重定向时,导航栏不会亮 */}
<Menu theme="dark" mode="inline" selectedKeys={selectKeys} className="aaaaaaa" defaultOpenKeys={openKeys}>
{/* 封装函数遍历生成菜单项 */}
{renderMenu(meun)}
</Menu>
</div>
五. JsonServer
1. 全局安装npm i -g json-server
- 测试json-server:在任务管理器中执行:json-server --watch .\test.json --port 8000,这个表示给当前目录下的test.json文件开启8000端口号,在json文件中,一级的key会自动当成接口来使用。
- json-server为了方便大家取数据,在启动创建server时已经解决了跨域的问题
2. json-server的增、删、改、查,联合取
-
查 :get
axios.get('http://localhost:8000/posts/2').then(res=>{
console.log(res)
})
- 增 :post
axios.post('http://localhost:8000/posts').then(res=>{
//id不用写,会自增长
title:'2222'
author:'zj'
})
- 删:delete,但是这里有个问题,如果新闻被删除了,那么新闻所关联的一些评论也会被删除。
axios.delete('http://localhost:8000/posts/1')
- 改:有put和patch两个字段,put的意思是全部替换,而patch则是只修改你提交的那部分
axios.patch('http://localhost:8000/posts/1').then(res=>{
//只会修改id为1的title,其他的信息不变
title:'2222-22222'
})
- _embed表连接,将所有的数据以及它们的关联数据都取出来
axios.get('http://localhost:8000/posts?_embed=comments').then(res=>{
console.log(res)
})
- _expand向上查找,注意这里的comments,post和接口posts的写法
axios.get('http://localhost:8000/comments?_expand=post).then(res=>{
console.log(res)
})
六. 后端SideMenu
1. 获取后端权限列表数据并存储,将menu数据传入到<Menu>组件中,
const [meun, setMeun] = useState([])
useEffect(() => {
//内嵌它的子数据
axios.get("/rights?_embed=children").then(res => {
setMeun(res.data)
})
}, [])
{/* 封装函数遍历生成菜单项 */}
{renderMenu(meun)}
2. 用pagepermisson来控制SubMenu中的字段的显示与隐藏,事先在代码中设置好路径和图标的对应的键值对
if (item.children?.length > 0 && checkPagePermission(item)) {
return <SubMenu key={item.key} icon={iconList[item.key]} title={item.title}>
{/* 如果是二级列表,调用递归 */}
{renderMenu(item.children)}
</SubMenu>
}
其中:checkPagePermission这个函数用来判断数据中是否有pagepermisson属性
3. SideMenu的默认选中
- 用<withrouter>高阶组件来获取祖辈传过来的props,其中props.location中有pathname这个路径值, const selectKeys = [props.location.pathname]:
- 默认展开是用到defaultOpenKeys这个属性,但是只对一级Menu生效,故需要对pathname进行截取。
- 受控和非受控的概念:受控组件:外部状态改变了,内部组件也会受到影响;非受控组件:外部状态改变了,内部组件只在第一次有影响就是非受控组件。
{/* 这里若是写defaultSelectedKeys,则会成为非受控组件,当重定向时,导航栏不会亮 */}
<Menu theme="dark" mode="inline" selectedKeys={selectKeys} className="aaaaaaa" defaultOpenKeys={openKeys}>
{/* 封装函数遍历生成菜单项 */}
{renderMenu(meun)}
</Menu>
七. 权限列表
给整体权限进行配置,侧边栏通过权限列表进行动态配置
1. 权限列表的数据获取
useEffect(() => {
axios.get("/rights?_embed=children").then(res => {
const list = res.data
list.forEach(item => {
if (item.children.length === 0) {
// 树形数据展示:当数据中有children字段时会自动展示为为树形表格,如果不需要或配置为其它字段,可以用childrenColumnName进行配置。
item.children = ""
}
})
setdataSource(list)
})
}, [])
获取权限数据以及它的关联子数据存入dataSource中。
2. 页面表格布局
- 定义表格布局:
const columns = [
{
title: 'ID',
dataIndex: 'id',
render: (id) => {
return <b>{id}</b>
}
},
{
// 将dataSource中的key为title的值放在这一列
title: '权限名称',
dataIndex: 'title'
},
{
title: "权限路径",
dataIndex: 'key',
render: (key) => {
return <Tag color="orange">{key}</Tag>
}
},
{
title: "操作",
// item为datasourse中当前点击的这一项
render: (item) => {
return <div>
<Button danger shape="circle" icon={<DeleteOutlined />} onClick={() => confirmMethod(item)} />
{/* 卡片弹框 */}
<Popover content={<div style={{ textAlign: "center" }}>
<Switch checked={item.pagepermisson} onChange={() => switchMethod(item)}></Switch>
{/* 如果没有配置pagepermisson这一项时,则会禁用按钮 */}
</div>} title="页面配置项" trigger={item.pagepermisson === undefined ? '' : 'click'}>
<Button type="primary" shape="circle" icon={<EditOutlined />} disabled={item.pagepermisson === undefined} />
</Popover>
</div>
}
}
];
- 定义页面中操作数据的方法:
confirmMethod: 卡片弹框
const confirmMethod = (item) => {
confirm({
title: '你确定要删除?',
icon: <ExclamationCircleOutlined />,
onOk() {
deleteMethod(item)
},
onCancel() {
},
});
}
deleteMethod:删除方法
//删除,这里的item是当前点击的这一项的全部数据
const deleteMethod = (item) => {
// 当前页面同步状态 + 后端同步
if (item.grade === 1) {
// filter方法,过滤出能使表达式成立的项
setdataSource(dataSource.filter(data => data.id !== item.id))
axios.delete(`/rights/${item.id}`)
} else {
// 找到children的上一级数据(子找父)
let 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}`)
}
}
switchMethod:控制权限是否打开的开关
const switchMethod = (item) => {
// 改变dataSource中的pagepermisson值
item.pagepermisson = item.pagepermisson === 1 ? 0 : 1
setdataSource([...dataSource])
// 如果是父,直接携带item.id至rights这个接口
if (item.grade === 1) {
axios.patch(`/rights/${item.id}`, {
pagepermisson: item.pagepermisson
})
// 如果是子,携带item.id至children这个接口
} else {
axios.patch(`/children/${item.id}`, {
pagepermisson: item.pagepermisson
})
}
}
- 渲染Table布局
<Table dataSource={dataSource} columns={columns}
// 分页器
pagination={{
pageSize: 5
}} />
页面中用到的Button, Table, Tag, Modal, Popover, Switch组件都是从antd中拿到的,DeleteOutlined, EditOutlined, ExclamationCircleOutlined是从@ant-design/icons拿到的, confirm是从Modal中解构出来的。
八. 角色列表
为某个用户分配到当前角色,就能拥有当前用户所需要的权限,该项目的角色分为: 超级管理员:可以写新闻,审批所有人的新闻,创建账号,修改角色的权限分配
区域管理员:可以写新闻,审核自己所在区域的新闻,发布新闻
区域编辑:负责写新闻,将新闻存储到草稿箱中
1. 数据获取
useEffect(() => {
//获取角色信息
axios.get("/roles").then(res => {
setdataSource(res.data)
})
}, [])
useEffect(() => {
// 将权限赋值给弹出框中的树形结构
axios.get("/rights?_embed=children").then(res => {
setRightList(res.data)
})
}, [])
2. 页面表格布局
- 定义表格布局,同权限列表类似,table中需要一个唯一key值,权限列表中由于后端返回的数据中有key这个选项,Table自动识别。
<Table dataSource={dataSource} columns={columns}
// table中需要一个唯一key值
rowKey={(item) => item.id}></Table>
- 定义弹出框
<Modal title="权限分配" visible={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
<Tree
checkable
// 当前角色的权限,checkedKeys受控属性
checkedKeys={currentRights}
onCheck={onCheck}
checkStrictly={true}
// 将权限给treeData
treeData={rightList}
/>
</Modal>
- 处理数据的方法
通过勾选弹出框中的用户权限选项编辑用户权限,再将更新后的权限同步datasource和后端
const handleOk = () => {
setisModalVisible(false)
//同步datasource
setdataSource(dataSource.map(item => {
if (item.id === currentId) {
return {
...item,
// 当前勾选框中的权限赋值给dataSource中的rights
rights: currentRights
}
}
return item
}))
//patch,将数据同步至后端
axios.patch(`/roles/${currentId}`, {
rights: currentRights
})
}
九. 用户列表
该部分是用来管理系统中各登录用户,包括开启/关闭用户登录状态,更新用户数据,删除用户以及添加用户等功能。
1. 数据获取
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)
})
}, [])
2. 生成Table结构
这部分同权限列表和用户列表,但是此处的区域的表头部分增加了一个筛选功能,用户可以自行选择查看某个区域的用户:
filters: [
...regionList.map(item => ({
text: item.title,
value: item.value
})),
{
text: "全球",
value: "全球"
}
],
// onFilter为过滤后满足条件的值
onFilter: (value, item) => {
if (value === "全球") {
// 全球字段在后端的数据中显示为''
return item.region === ""
}
return item.region === value
},
3. 添加用户
模态框如下:
<Modal
visible={isAddVisible} title="添加用户" okText="确定" cancelText="取消"
onCancel={() => {
setisAddVisible(false)
}}
onOk={() => addFormOK()}>
<UserForm regionList={regionList} roleList={roleList} ref={addForm}></UserForm>
</Modal>
此处将模态框的表单组件封装成一个共享组件:UserForm,供添加用户的模态框和更新用户的模态框复用在UserForm组件中引入表单antd组件库中的Form组件,为了使父组件能够拿到子组件中的表单值,这里使用了forwardRef,用于将ref转发给父组件。收集子组件中表单列表的值,然后更新datasource和后台数据:
const addFormOK = () => {
// 收集表单的信息
addForm.current.validateFields().then(value => {
setisAddVisible(false)
addForm.current.resetFields()
//post到后端,生成id,再设置 datasource, 方便后面的删除和更新
axios.post(`/users`, {
...value,
"roleState": true,
"default": false,
}).then(res => {
setdataSource([...dataSource, {
...res.data,
// role这个字段是在联表中,故第一次刷新的过程中会拿不到,
role: roleList.filter(item => item.id === value.roleId)[0]
}])
})
}).catch(err => {
console.log(err)
})
}
4. 更新用户
复用上一小节的UserForm组件,但是因为是更新列表,因此在弹出框中应该将原始数据展示出来,代码如下:
const handleUpdate = (item) => {
setisUpdateVisible(true)
setTimeout(() => {
if (item.roleId === 1) {
//禁用
setisUpdateDisabled(true)
} else {
//取消禁用
setisUpdateDisabled(false)
}
// 动态设置表单的初始值
updateForm.current.setFieldsValue(item)
}, 0)
setcurrent(item)
}
将更新后的数据重新提交至datasource和后端:
const updateFormOK = () => {
updateForm.current.validateFields().then(value => {
// console.log(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)
})
}
删除用户部分的逻辑和权限列表和角色列表相似,这里不过多赘述。
十. 登录界面
1. topHeader组件
登录界面的跳转有两种方式:1. 页面初始渲染时,路由的路径是’ ‘,将其重定向至登录路由组件;2. 点击退出按钮,自动跳转至/login界面,这里用到了高阶组件withrouter和路由的redirect属性,这里介绍后面一种:
<Menu.Item danger onClick={() => {
localStorage.removeItem("token")
// console.log(props.history)
props.history.replace("/login")
}}>退出</Menu.Item>
2. login页面(粒子效果)
- 引入Form表单,点击提交按钮,立即触发表单校验功能,然后将数据提交给onFinish,在onFinish中,进行数据提交
// json serve不能用用post请求,故用get请求代替post,如果get请求有返回,说明账号和密码正确
const onFinish = (values) => {
// 向user接口发数据,查询username、password、roleState,以及用户列表的关联角色列表的数据,四项数据均正确才能返回正确的数据
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]))
console.log(JSON.stringify(res.data[0]));
props.history.push("/")
}
})
}
- 粒子效果
引入react-tsparticles粒子库,在页面中使用该组件,
<Particles
height={document.documentElement.clientHeight} params={...}
但是这样设置的话会使粒子界面的高度高过视图高度出现滚动栏,因此要给父元素设置 overflow: 'hidden'
若一开始页面加载时重定向至登录页面,但访问者没有账号或不想登录时,我们给访问者提供一个游客访问的接口:
<a style={{ color: "white" }} href={`#/news`}>游客模式</a>
3. sideMenu组件完善
根据登录的不同的用户展示不同的sideMenu,因此要在checkPagePermission的代码中判断当前登录的用户的权限列表包括item.key才有可能继续展示侧边栏。
const { role: { rights } } = JSON.parse(localStorage.getItem("token"))
// item.key表示侧边栏的数据
const checkPagePermission = (item) => {
// 当前登录的用户的权限列表包括item.key才有可能继续修改侧边栏,如果没有则应该被隐藏
return item.pagepermisson && rights.includes(item.key)
}
4. userList:不同的用户身份拥有不同的权限
在用户更新权限中,全球管理员拥有任何的权限,区域管理员的修改区域以及角色身份的选项被禁用,而在添加用户时,将区域编辑的修改角色的的选项给禁用掉,将区域管理员的修改区域的选项给禁用。
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"
}
}
}
十一. 路由权限
1. 配置路由
将rights接口和children接口中的数据取出来并拼接存入BackRouteList,Switch组件遍历BackRouteList:
<Switch>
{
BackRouteList.map(item => {
// 组件被渲染的两个条件
if (checkRoute(item) && checkUserPermission(item)) {
// 二级匹配,避免出现二级路由被重复匹配的情况
return <Route path={item.key} key={item.key} component={LocalRouterMap[item.key]} exact />
}
return null
}
)
}
<Redirect from="/" to="/home" exact />
{
// 若数据还没回来,则不会重定向
BackRouteList.length > 0 && <Route path="*" component={Nopermission} />
}
</Switch>
判断当前用户登录状态下是否可以显示该路径的两个条件:
// 当前角色下的权限
const { role: { rights } } = JSON.parse(localStorage.getItem("token"))
const checkRoute = (item) => {
// 判断后端返回的路径中有没有当前路径,当前路径是item.key,并且该项权限是开着的且有routepermisson这个选项
return LocalRouterMap[item.key] && (item.pagepermisson || item.routepermisson)
}
// 登录的用户有该权限
const checkUserPermission = (item) => {
return rights.includes(item.key)
}
2. NProgress进度条
npm安装后在NewsSandBox.js引入使用:
// 渲染开始
NProgress.start()
// 渲染结束开始发送请求时
useEffect(() => {
NProgress.done()
})
3. 配置请求路径
新建util文件夹,在http.js文件中进行配置:
axios.defaults.baseURL = "http://localhost:5000"
十二. 新闻相关
新闻数据相关介绍:
"title": "Introducing JSX", //标题名
"categoryId": 2, //分类
"content": //内容
"region": "全球", //区域分类
"author": "admin", //作者
"roleId": 1, //作者所属角色
"auditState": 2, //审核状态:0-未审核(放进草稿箱);1-正在审核(放进审核列表);2-已通过;3-未通过
"publishState": 2, //发布状态:0-未发布;1-待发布;2-已发布;3-已下线
"createTime": 1615778715619, //创建时间
"star": 600, //点赞人数
"view": 983, //浏览次数
"id": 3, //id
"publishTime": 1615778911762 //发布时间
1. 添加新闻
- 步骤条
引入Steps组件,通过操作current的值来控制步骤进行到哪一步
<Steps current={current}>
<Step title="基本信息" description="新闻标题,新闻分类" />
<Step title="新闻内容" description="新闻主体内容" />
<Step title="新闻提交" description="保存草稿或者提交审核" />
</Steps>
之后判断current的值来渲染步骤条下方的按钮:
<div style={{ marginTop: "50px" }}>
{
current === 2 && <span>
{/* 保存至草稿箱, 并将现在的审核状态0(表示未审核)传给handleSave函数*/}
<Button type="primary" onClick={() => handleSave(0)}>保存草稿箱</Button>
{/* 提交审核, 并将现在的审核状态1(表示未审核)传给handleSave函数 */}
<Button danger onClick={() => handleSave(1)}>提交审核</Button>
</span>
}
{
// 表单校验不成功不会跳转,handleNext:点击下一步的回调,将current的值加一
current < 2 && <Button type="primary" onClick={handleNext}>下一步</Button>
}
{
// handlePrevious:点击上一步的回调,将current的值减一
current > 0 && <Button onClick={handlePrevious}>上一步</Button>
}
</div>
- 添加新闻标题以及分类(step1)
引入Form组件,Form.Item的第一项为新闻标题,Form.Item的第二项为新闻分类,数据是从后台/categories的接口拿到的。
- 编辑新闻正文
引入NewsEditor:
<div>
<Editor
// 编辑的内容
editorState={editorState}
// 自定义编辑器的样式
toolbarClassName="aaaaa"
wrapperClassName="bbbbb"
editorClassName="ccccc"
// editorState和onEditorStateChange让Editor成为受控组件
onEditorStateChange={(editorState) => setEditorState(editorState)}
// 失去焦点时,拿到状态值
onBlur={() => {
// 这个状态值只有editor才认识,所以要经过变换,需要引入convertToRaw和draftToHtml这两个组件
props.getContent(draftToHtml(convertToRaw(editorState.getCurrentContent())))
}}
/>
</div>
上面这三个页面是不能同时出现的,并且上一个页面的数据是需要携带至下一个页面,不能用三元表达式来切换页面,页面会销毁,数据会丢失,因此需要判断current这个值来确定显示那个页面。
- 提交新闻
保存至草稿箱/提交审核:调用handleSave,若是保存至草稿箱则将auditState=0传入函数,若是提交审核则将auditState=1传入函数
const handleSave = (auditState) => {
axios.post('/news', {
// title和分类
...formInfo,
"content": content,
// 本地中已经存好
"region": User.region ? User.region : "全球",
"author": User.username,
"roleId": User.roleId,
"auditState": auditState,
"publishState": 0,
"createTime": Date.now(),
"star": 0,
"view": 0,
// "publishTime": 0
}).then(res => {
// 传0表示跳转至草稿箱列表,传1表示跳转到审核列表
props.history.push(auditState === 0 ? '/news-manage/draft' : '/audit-manage/list')
notification.info({
message: `通知`,
description:
`您可以到${auditState === 0 ? '草稿箱' : '审核列表'}中查看您的新闻`,
// 位置
placement: "bottomRight"
});
})
}
2. 草稿箱列表
- 从/news接口中拿到新闻的相关数据,并过滤出auditState=0以及属于当前登录用户撰写的新闻
const { username } = JSON.parse(localStorage.getItem("token"))
useEffect(() => {
// news接口中的数据,只有auditState=0才会被放入草稿箱
axios.get(`/news?author=${username}&auditState=0&_expand=category`).then(res => {
const list = res.data
setdataSource(list)
})
// 依赖的是username,若username发生变化,则调用该函数
}, [username])
草稿箱中点击新闻标题可以携带当前新闻的id进入新闻预览界面:
{
title: '新闻标题',
dataIndex: 'title',
render: (title, item) => {
// 点击标题可以进入预览界面
return <a href={`#/news-manage/preview/${item.id}`}>{title}</a>
}
},
- 新闻草稿箱列表中,可以将删除新闻,编辑新闻,将新闻提交至审核列表
删除新闻,逻辑同之前的删除操作
编辑新闻:点击编辑按钮,跳转至更新页面
<Button shape="circle" icon={<EditOutlined />} onClick={() => {
props.history.push(`/news-manage/update/${item.id}`)
}} />
更新新闻页面的结构同编辑新闻的页面相似,但是跳转至更新新闻的界面时,拿到要更新的新闻的id,并且将原来的新闻信息展现在表单中供作者修改:
useEffect(() => {
// console.log()
// 拿到要更新的新闻的id,以及他们的分类和创建该新闻的角色
axios.get(`/news/${props.match.params.id}?_expand=category&_expand=role`).then(res => {
let { title, categoryId, content } = res.data
// 当前的NewsForm设置初始值
NewsForm.current.setFieldsValue({
title,
categoryId
})
setContent(content)
})
}, [props.match.params.id])
title和categoryId是在编辑新闻的第一步展示:而content是在第二步的富文本框展示,
useEffect(() => {
// 给的html片段
// console.log(props.content)
// html-===> draft,
const html = props.content
// 如果html本文内容为空,则不渲染
if (html === undefined) return
const contentBlock = htmlToDraft(html);
if (contentBlock) {
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
const editorState = EditorState.createWithContent(contentState);
setEditorState(editorState)
}
// 每次props.content改变的时候就会重新执行一遍
}, [props.content])
提交审核:先将auditState的状态改为1,然后路由跳转至/audit-manage/list这个接口。
const handleCheck = (id) => {
axios.patch(`/news/${id}`, {
// 0表示草稿箱,1表示待审核,2正在审核,3未通过,4已通过
auditState: 1
}).then(res => {
props.history.push('/audit-manage/list')
notification.info({
message: `通知`,
description:
`您可以到${'审核列表'}中查看您的新闻`,
placement: "bottomRight"
});
})
}
注意:draftjs-to-html和html-to-draftjs这两个组件在react18下安装时需要在后面加入后缀--legacy-peer-deps。
十三. 审核管理
1. 自己撰写的新闻当前的审核状态AuditList
- 数据来源
const { username } = JSON.parse(localStorage.getItem("token")) useEffect(() => { // auditState_ne表示auditState不等于0(等于0表示在草稿箱中),publishState_lte=1表示小于等于1(表示未发布) axios(`/news?author=${username}&auditState_ne=0&publishState_lte=1&_expand=category`).then(res => { // console.log(res.data) setdataSource(res.data) }) }, [username])
拿到属于当前用户撰写的新闻,且该新闻时不在草稿箱中并且没有发布,故这些新闻有四个状态。将这四个状态的新闻渲染出来,每个状态对应的可操作的按钮也不一样,
return <div>
{
// 正在审核的话可以将其撤销至草稿箱
item.auditState === 1 && <Button onClick={() => handleRervert(item)} >撤销</Button>
}
{ //已通过的话可以将其发布
item.auditState === 2 && <Button danger onClick={() => handlePublish(item)}>发布</Button>
}
{ //未通过的话,可以重新更新新闻
item.auditState === 3 && <Button type="primary" onClick={() => handleUpdate(item)}>更新</Button>
}
</div>
撤销的函数,发布的函数,更新的函数相似,以撤销函数为例:
const handleRervert = (item) => {
// 先将当前数据从本地列表中移出
setdataSource(dataSource.filter(data => data.id !== item.id))
// 并将news接口中的数据的auditState的接口改为0
axios.patch(`/news/${item.id}`, {
auditState: 0
}).then(res => {
notification.info({
message: `通知`,
description:
`您可以到草稿箱中查看您的新闻`,
placement: "bottomRight"
});
})
}
更新函数和发布函数只是在接口处和auditState/publishState有所不同。
2. 需要当前用户审核的新闻列表
数据来源:
useEffect(() => {
const roleObj = {
"1": "superadmin",
"2": "admin",
"3": "editor"
}
axios.get(`/news?auditState=1&_expand=category`).then(res => {
const list = res.data
// 如果判断登录角色是superadmin,则将整个响应的列表返回,否则返回自己或者同一个区域下的编辑的数据
setdataSource(roleObj[roleId] === "superadmin" ? list : [
...list.filter(item => item.author === username),
...list.filter(item => item.region === region && roleObj[item.roleId] === "editor")
])
})
}, [roleId, region, username])
点击新闻标题可以携带新闻的id跳转至新闻详情,故要调用#/news-manage/preview/${item.id}这个接口。对于列表中的新闻,可进行通过和驳回两种操作,对应两种不同的button,回调函数如下:
const handleAudit = (item, auditState, publishState) => {
setdataSource(dataSource.filter(data => data.id !== item.id))
axios.patch(`/news/${item.id}`, {
auditState,
publishState
}).then(res => {
notification.info({
message: `通知`,
description:
`您可以到[审核管理/审核列表]中查看您的新闻的审核状态`,
placement: "bottomRight"
});
})
}
十四. 新闻分类
数据来源:/categories这个接口
1. 可编辑的文本框
- 定制好可编辑的行
<div>
<Table dataSource={dataSource} columns={columns}
pagination={{
pageSize: 5
}}
rowKey={item => item.id}
// 定制好可编辑的行
components={{
body: {
row: EditableRow,
cell: EditableCell,
}
}}
/>
</div>
- 定义好可编辑的row和可编辑的cell
// 可编辑的row
const EditableRow = ({ index, ...props }) => {
const [form] = Form.useForm();
return (
// 将form传给EditableContext
<Form form={form} component={false}>
<EditableContext.Provider value={form}>
<tr {...props} />
</EditableContext.Provider>
</Form>
);
};
// 可编辑的cell
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.`,
},
]}
>
{/* 失去焦点,调用save函数 */}
<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>;
};
删除按钮的逻辑同权限列表类似
十五. 发布管理
项目中的待发布,已发布,已下线三个界面的布局几乎相同,故这里采用自定义hooks的当时来封装组件。
1. 自定义hooks:自定义函数部分
- 获取数据
// react中的自定义hooks
function usePublish(type) {
// 获取数据
const { username } = JSON.parse(localStorage.getItem("token"))
const [dataSource, setdataSource] = useState([])
useEffect(() => {
//根据路由组件传入的type不同,拿到不同的数据
axios(`/news?author=${username}&publishState=${type}&_expand=category`).then(res => {
// console.log(res.data)
setdataSource(res.data)
})
}, [username, type])
- 不同的button对应的回调函数
未发布的列表中的发布按钮的回调:
const handlePublish = (id) => {
setdataSource(dataSource.filter(item => item.id !== id))
axios.patch(`/news/${id}`, {
"publishState": 2,
"publishTime": Date.now()
}).then(res => {
notification.info({
message: `通知`,
description:
`您可以到【发布管理/已经发布】中查看您的新闻`,
placement: "bottomRight"
});
})
}
发布列表中的下线按钮的回调:
const handleSunset = (id) => {
setdataSource(dataSource.filter(item => item.id !== id))
axios.patch(`/news/${id}`, {
"publishState": 3,
}).then(res => {
notification.info({
message: `通知`,
description:
`您可以到【发布管理/已下线】中查看您的新闻`,
placement: "bottomRight"
});
})
}
下线列表中的删除按钮的回调:
const handleDelete = (id) => {
setdataSource(dataSource.filter(item => item.id !== id))
axios.delete(`/news/${id}`).then(res => {
notification.info({
message: `通知`,
description:
`您已经删除了已下线的新闻`,
placement: "bottomRight"
});
})
}
将自定义的函数返回出去:
return {
dataSource,
handlePublish,
handleSunset,
handleDelete
}
2. 自定义hooks:页面布局部分
- 自定义布局:
return (
<div>
<Table dataSource={props.dataSource} columns={columns}
pagination={{
pageSize: 5
}}
rowKey={item => item.id}
/>
</div>
)
3. 在待发布,已发布,已下线三个路由组件中调用上述的自定义hooks
以待发布路由组件为例为例:
import NewsPublish from '../../../components/publish-manage/NewsPublish'
import usePublish from '../../../components/publish-manage/usePublish'
import { Button } from 'antd'
// dataSource和按钮的回调都是从usePublish这个hooks中取到的
export default function Unpublished() {
// 1=== 待发布的
const { dataSource, handlePublish } = usePublish(1)
return (
<div>
<NewsPublish dataSource={dataSource} button={(id) => <Button type="primary" onClick={() => handlePublish(id)}>
发布
</Button>} ></NewsPublish>
</div>
)
}
十六. Redux
左:同步redux过程, 右: 异步redux过程
1. 折叠sideMenu
创建reducer,接受数据之前的状态,返回加工后的状态。
// 本质是一个函数,接受数据之前的状态,action,返回加工后的状态,reducer这个函数是纯函数
// reducer被第一次调用时,是store自动触发的
export const CollApsedReducer = (prevState = {
isCollapsed: false
}, action) => {
// console.log(action)
let { type } = action
switch (type) {
case "change_collapsed":
let newstate = { ...prevState }
newstate.isCollapsed = !newstate.isCollapsed
return newstate
default:
return prevState
}
}
在topHeader组件中调用connect,返回高阶组件,将组件和redux连接起来
// connect用法,connect()执行一次,返回一个高阶函数,再执行这个高阶函数,connect可以拿到store中的state和方法
// 将数据和处理数据的方法提交至高阶组件connect中
export default connect(mapStateToProps, mapDispatchToProps)(withRouter(TopHeader))
// 将state中的状态映射成一个属性,传给组件
const mapStateToProps = ({ CollApsedReducer: { isCollapsed } }) => {
return {
isCollapsed
}
}
// 将state中的方法映射成一个属性,传给组件
const mapDispatchToProps = {
changeCollapsed() {
return {
type: "change_collapsed"
// payload:
}//action
}
}
调用changeCollapsed这个函数,派发action
sideMenu这个组件中,将state中的状态映射成一个属性,传给组件,然后通过collapsed={props.isCollapsed}拿到这个状态。
const mapStateToProps = ({ CollApsedReducer: { isCollapsed } }) => ({
isCollapsed
})
// 将state中的状态映射成一个属性,传给组件
export default connect(mapStateToProps)(withRouter(SideMenu))
2. 异步加载数据时的loading框
在需要异步获取数据的路由组件外,包裹一个Spin组件,在数据未加载完成时显示loading框,数据加载完毕时,loading框消失。Spin组件的状态受<Spin size="large" spinning={props.isLoading}>控制
const mapStateToProps = ({ LoadingReducer: { isLoading } }) => ({
isLoading
})
// 用connect进行包装,使之可以拿到store中的方法和数据
export default connect(mapStateToProps)(NewsRouter)
在http请求中添加请求拦截器和响应拦截器:在请求发送前派发action修改isLoading的状态:
axios.interceptors.request.use(function (config) {
// 显示loading
store.dispatch({
type: "change_loading",
payload: true
})
return config;
}, function (error) {
return Promise.reject(error);
});
在数据加载完毕后派发action修改isLoading的状态:
// 响应拦截器,隐藏loading
axios.interceptors.response.use(function (response) {
store.dispatch({
type: "change_loading",
payload: false
})
//隐藏loading
return response;
}, function (error) {
store.dispatch({
type: "change_loading",
payload: false
})
//隐藏loading
return Promise.reject(error);
});
LoadingReducer中存储修改isLoading状态的方法:
export const LoadingReducer = (prevState={
isLoading:false
},action)=>{
// console.log(action)
let {type,payload} =action
switch(type){
case "change_loading":
let newstate = {...prevState}
newstate.isLoading = payload
return newstate
default:
return prevState
}
}
3. store.js合并reducer并且持久化存储数据
合并reducer:redux的原则是单一设计原则,故引入combineReducers,
// 合并Reducers
const reducer = combineReducers({
CollApsedReducer,
LoadingReducer
})
持久化数据:redux的状态是存储在内存中,页面刷新的时候,数据会消失,需要持久化存储,引入persistStore,persistReducer和storage,持久化数据代码如下:
const persistConfig = {
// 存到本地中key: 'kerwin'的值里面
key: 'kerwin',
storage,
// 这里是黑名单,表示不会被持久化的
blacklist: ['LoadingReducer']
}
// 将合并后的reducer作持久化,经过persistedReducer生成store
const persistedReducer = persistReducer(persistConfig, reducer)
// persistedReducer是为store服务的reducer,再将persistedReducer生成store
const store = createStore(persistedReducer);
const persistor = persistStore(store)
export {
store,
persistor
}
十七. 首页数据展示
1. 表格布局
从antd中引入Row布局,将页面平均分成三等份,每个等分使用Table布局,分别用来显示用户最常浏览,用户点赞最多以及用户资料卡。
- 用户最常浏览:
数据获取:
useEffect(() => {
// _sort表示对数据进行排序,_order=desc表示降序,limit限制返回的数据
axios.get("/news?publishState=2&_expand=category&_sort=view&_order=desc&_limit=6").then(res => {
setviewList(res.data)
})
}, [])
页面布局:
<Col span={8}>
{/* 给card加一个边框 */}
<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>
- 用户点赞最多:
数据获取:
useEffect(() => {
axios.get("/news?publishState=2&_expand=category&_sort=star&_order=desc&_limit=6").then(res => {
// console.log(res.data)
setstarList(res.data)
})
}, [])
页面布局:
<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>
- 用户资料卡:用户资料卡中点击setting按钮,调用Drawer组件弹出一个饼状图,该饼状图表示该登录用户发布的新闻分类统计:
<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>
饼状图的数据获取:
useEffect(() => {
axios.get("/news?publishState=2&_expand=category").then(res => {
// lodash.groupBy以category.title进行分组
renderBarView(_.groupBy(res.data, item => item.category.title))
setallList(res.data)
})
}, [])
饼状图定义:
const renderPieView = (obj) => {
//数据处理工作
// 筛选出该作者发布的新闻
var currentList = allList.filter(item => item.author === username)
var groupObj = _.groupBy(currentList, item => item.category.title)
var list = []
for (var i in groupObj) {
list.push({
name: i,
value: groupObj[i].length
})
}
var myChart;
if (!pieChart) {
// 只做一次初始化
myChart = Echarts.init(pieRef.current);
setpieChart(myChart)
} else {
myChart = pieChart
}
var option;
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 renderBarView = (obj) => {
// 放到barRef这个容器中
var myChart = Echarts.init(barRef.current);
// 指定图表的配置项和数据
var option = {
title: {
text: '新闻分类图示'
},
tooltip: {},
legend: {
data: ['数量']
},
xAxis: {
data: Object.keys(obj),
axisLabel: {
rotate: "45",
// 设置为0表示强制显示所有标签
interval: 0
}
},
yAxis: {
minInterval: 1
},
series: [{
name: '数量',
type: 'bar',
data: Object.values(obj).map(item => item.length)
}]
};
// 使用刚指定的配置项和数据显示图表。
myChart.setOption(option);
// 给window绑定resize
window.onresize = () => {
myChart.resize()
}
}
柱状图优化:在页面缩放时,柱状图需要随着页面的宽度调整宽度,因此在配置柱状图组件时,添加onresize配置:
// 使用刚指定的配置项和数据显示图表。
myChart.setOption(option);
// 给window绑定resize
window.onresize = () => {
myChart.resize()
}
在组件销毁时,在useEffect中添加如下配置:
return () => {
window.onresize = null
}
十八. 游客访问
在登陆页面中,当访问者不想登录或者没有账号时,可以选择以临时游客身份浏览新闻界面和新闻详情界面。
1. 新闻界面
数据获取:拿到所有的新闻数据,并且使用lodash组件将数据分类,并转换成二维数组
useEffect(() => {
axios.get("/news?publishState=2&_expand=category").then(res => {
// console.log()
// 拿到分好类的数据,Object.entries转换成二维数组
setlist(Object.entries(_.groupBy(res.data, item => item.category.title)))
})
}, [])
使用Row布局:
{/* 控制上下左右间距的 */}
<Row gutter={[16, 16]}>
{
list.map(item =>
<Col span={8} key={item[0]}>
{/* hoverable鼠标刚放上去会有这个属性 */}
<Card title={item[0]} bordered={true} hoverable={true}>
<List
size="small"
dataSource={item[1]}
pagination={{
pageSize: 3
}}
//携带当前新闻的id跳转至新闻详情界面
renderItem={data => <List.Item><a href={`#/detail/${data.id}`}>{data.title}</a></List.Item>}
/>
</Card>
</Col>
)
}
</Row>
2. 新闻详情界面
数据获取:由于用户每访问一次该界面,该条新闻的浏览量都应该加一,因此在发送请求获取数据的同时,也要修改当前的数据:
useEffect(() => {
axios.get(`/news/${props.match.params.id}?_expand=category&_expand=role`).then(res => {
setnewsInfo({
...res.data,
// 每刷新一次就把view的值加一
view: res.data.view + 1
})
//同步后端
return res.data
}).then(res => {
axios.patch(`/news/${props.match.params.id}`, {
view: res.view + 1
})
})
}, [props.match.params.id])
新闻详情页面的布局可以直接复用NewsPreview这个组件的页面布局,在这个页面,游客可以给此条新闻点赞,代码逻辑:
const handleStar = () => {
// 本地和数据库都要更新数据
setnewsInfo({
...newsInfo,
star: newsInfo.star + 1
})
axios.patch(`/news/${props.match.params.id}`, {
star: newsInfo.star + 1
})
}