前端react项目---全球新闻发布管理系统

一. 项目入门

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
        })
    }

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值