React--》从零开始搭建一个文章后台管理系统

目录

项目准备

项目搭建

scss预处理器的使用

配置基础路由

组件库antd的使用

开发者工具的安装

登录模块

基本样式搭建

创建表单结构

获取表单数据并进行相关登录操作

对登录模块的token进行相关处理

路由鉴权实现

后台页面模块

基本页面结构搭建

菜单高亮显示

展示个人信息

退出登录实现

Token失效处理

首页Home页面展示

内容管理Article页面展示

发布文章Publish页面展示

项目的相关优化

项目git上线

项目打包


项目准备

本篇文章讲解的是一个简单的文章后台管理系统,系统的功能很简单,如下:

登录、退出;首页;内容(文章)管理:文章列表、发布文章、修改文章。

看完本篇文章你将了解学习到的知识如下:

React官方脚手架:create-react-app

react-hooks

状态管理:mobx

UI组件库:antd v5

ajax请求库:axios

路由:react-router-dom 以及 history

富文本编辑器:react-quill

CSS预编译器:sass

项目搭建

本系统是基于react官方脚手架搭建,具体的详细搭建,参考文章:React脚手架的搭建与使用

使用如下命令生成项目:

npx create-react-app article-pc

将生成的文件拖到vscode编辑器,删除一些不必要的文件,然后终端执行 npm start 即可,如下:

scss预处理器的使用

SASS是一种预编译的CSS,作用类似于Less。由于React中内置了处理SASS的配置,所有在CRA创建的项目中可以直接使用SASS来写样式,实现如下:

安装解析sass的第三方包:

npm install sass

创建全局样式文件并引入:

配置基础路由

前端路由是能够实现页面跳转的导航按钮,在前端领域中,路由是必不可少要掌握的技能之一,详情了解的话可以参加我之前的文章:ReactRouter5讲解ReactRouter6讲解 。其实现步骤如下:

安装路由:

npm install react-router-dom

实现过程如下:

在src目录下创建router文件夹并创建index.jsx文件,里面存放着配置路由的路由表,如下:

打开入口文件进行如下操作:

打开App根组件,将路由内容进行呈现,如下:

组件库antd的使用

antd是一个经常被react使用的一个组件库,大大提高了前端程序员的编码效率,详细了解的可以参考一下我之前的文章:UI组件库ant-design的介绍与使用 ,使用步骤如下:

安装 antd 组件库:

npm install antd

安装完成之后,访问 antd官网 ,随机点击一个功能,例如一个按钮的功能,如下:

可见如下的效果,引入的没毛病

开发者工具的安装

如果你是第一次接触React开发或者说还没有安装react的开发者工具建议还是安装一下,如果不能打开谷歌网上商店的uu,推荐国内安装插件的一个网站:极简插件 。如下:

下载解压,将文件拖到你的浏览器安装插件的界面即可,如下以谷歌浏览器为例:

登录模块

接下来开始实现登录页功能的实现,具体步骤分为以下几个方面:

基本样式搭建

登录页面需要一个简单的背景图片,有需要的还可以自行添加一个logo图片,如下:

import React from 'react'
import { Card } from 'antd'
import logo from '../../assets/logo.jpg'
import './index.scss'

const Login = () => {
  return (
    <div className='login'>
      <Card className='login-container'>
        <img src={logo} alt="图片" className='login-logo' />
        {/* 登录表单 */}
      </Card>
    </div>
  )
}

export default Login
.login{
  width: 100%;
  height: 100%;
  position: absolute;
  left: 0;
  top: 0;
  background: center/cover url('../../assets/login.jpg');
  .login-container{
    width: 600px;
    height: 400px;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%,-50%);
    box-shadow: 0 0 50px rgb(0 0 0 /10%);
  }
  .login-logo{
    width: 200px;
    height: 100px;
    display: flex;
    margin: 0 auto 20px;
  }
  .login-checkbox-label{
    color: #1890ff;
  }
}

图片可以自行百度寻找满意的图片,如下是个人简单的实现效果:

创建表单结构

表单的登录解构可以参考antd的Form表单对登录框的书写方式,如下:

根据antd给出的登录框的书写样式,结合自身需求,给出如下代码:

import React from 'react'
import { Card,Form,Input,Checkbox,Button } from 'antd'
import logo from '../../assets/logo.jpg'
import './index.scss'
// Form.Item的简写形式
const Item = Form.Item

const Login = () => {
  return (
    <div className='login'>
      <Card className='login-container'>
        <img src={logo} alt="图片" className='login-logo' />
        {/* 登录表单 */}
        <Form validateTrigger={['onBlur','onChange']} initialValues={
   
   {remember: true}}>
          <Item name='phone' rules={[
            { required:true,message:'请输入手机号' },
            { pattern:/^1[3-9]\d{9}$/, // 设置正则匹配规则
              validateTrigger:'onBlur', // 设置触发时机失去焦点时触发
              message:'请输入正确的手机号格式' }
          ]}>
            <Input size='large' placeholder='请输入手机号' />
          </Item>
          <Item name='password' rules={[
            { required:true,message:'请输入密码' },
            { len:6, // 设置密码长度为6位数
              validateTrigger:'onBlur',
              message:'请输入6位密码' }
          ]}>
            <Input size='large' placeholder='请输入密码' />
          </Item>
          <Item 
            name="remember"
            valuePropName='checked'
          >
            <Checkbox className='login-checkbox-label'>
              我已经阅读并同意 [用户协议] 和 [隐私条款]
            </Checkbox>
          </Item>
          <Item>
            <Button type='primary' htmlType='submit' size='large' block>登录</Button>
          </Item>
        </Form>
      </Card>
    </div>
  )
}

export default Login

具体的校验规则可在文章末尾给出的源码地址,自行下载探索:

获取表单数据并进行相关登录操作

在开始之前先安装好项目要准备的第三方库,如下:

安装发送ajax请求的第三方库

npm install axios

安装好发送ajax请求的第三方库之后,在src目录下新建utils文件夹,里面存放着项目的所有工具函数,当然发送ajax的工具函数也会放置在里面,命名为 http.jsx ,如下:

// 封装axios
import axios from "axios";

const http = axios.create({
  baseURL:"http://geek.itheima.net/v1_0",
  timeout:5000 // 超时时间定下5秒钟
})
// 添加请求拦截器
http.interceptors.request.use((config)=>{
  return config
},(error)=>{
  return Promise.reject(error)
})
// 添加响应拦截器
http.interceptors.response.use((response)=>{
  // 2xx 范围内的状态码都会触发该函数
  return response.data
},(error)=>{
  // 超出 2xx 范围内的状态码都会触发该函数
  return Promise.reject(error)
})
export default http

定义好发送ajax请求的工具函数后,在当前utils文件夹下新建一个index.jsx文件,用来封装整合所有的工具函数,以后所有书写的工具函数都会存放到这,便于调用:

// 先把所有的工具函数导出的模块在这里导入,整合在一起再统一导出
import http from './http.jsx'

export {
  http
}

安装集中式状态管理工具Mobx

npm install mobx mobx-react-lite

安装好mobx状态管理工具之后,在src目录下新建一个store文件夹用来处理所有要使用的状态,如下将要使用的登录的token存放到状态中,并命名为 login.jsx文件,如下:

// login module
import { makeAutoObservable } from 'mobx'
import { http } from '../utils'

class LoginStore {
  token = ''
  constructor(){
    // 响应式
    makeAutoObservable(this)
  }
  getToken = async({mobile,code}) => {
    // 调用登录接口
    const res = await http.post('http://geek.itheima.net/v1_0/authorizations',{mobile,code})
    // 存入token
    this.token = res.data.token
  }
}

export default LoginStore

定义好状态文件后,还需要在store文件夹下新建一个index.jsx文件,用来管理所有的要操作状态的函数和方法,如下:

// 把所有模块进行一个统一的处理,导出一个统一的方法 useStore
import React from "react";
import LoginStore from "./login";

class RootStore {
  constructor(){
    this.LoginStore = new LoginStore()
  }
}

// 实例化根 导出useStore context
const rootStore = new RootStore()
const context = React.createContext(rootStore)

const useStore = () => React.useContext(context)

export default useStore

登录的接口和状态都配置完成之后,便开始需要在登录页面调用要获取接口的getToken函数,得到自己的token之后,便进行编程式路由导航,进行页面的跳转,如下:

因为调用的接口是固定死的,密码必须是这个,用户名可以随便输入:

对登录模块的token进行相关处理

token对于登录模块而言至关重要,它保证着你登录后能够坚持登录后数据的时间,以及不同的token登录会获取不同的数据的一个身份凭证,所有在设计登录模块的时候,通常会进行如下操作:

token持久化:因为设置token持久化的也是一个工具函数,所以还是需要封装在utils文件夹下面并设置相关操作token的函数,如下:

// 定义操作token的函数
const key = 'pc-key'
// 存token
const setToken = (token) =>{
  return window.localStorage.setItem(key,token)
}
// 取token
const getToken = ()=>{
  return window.localStorage.getItem(key)
}
// 删token
const removeToken = ()=>{
  return window.localStorage.removeItem(key)
}

export {
  setToken,
  getToken,
  removeToken
}

 将封装好的token工具函数也存放到当前文件夹下的index.jsx文件夹下:

// 先把所有的工具函数导出的模块在这里导入,整合在一起再统一导出
import http from './http.jsx'
import{ setToken,getToken,removeToken} from './token.jsx'

export {
  http,
  setToken,
  getToken,
  removeToken
}

接下来需要将设置好的操作token的函数导入到store状态管理工具里面即可,如下:

请求拦截器注入token: 在每次接口正式发送之前进行拦截,将获取到的token进行装入,凡是调用了自己设计的接口请求,就会自动拥有token,不需要每次发送请求时都去请求一遍token接口函数,起到了一处配置多处生效的效果。如下:

路由鉴权实现

先解释一下什么是路由鉴权,假设你知道登录后台主页的访问路径,在没有登录的情况下,你能直接访问后台主页的路径吗?答案是肯定的 (在没有设置路由鉴权的情况下) ,所以后台设置路由鉴权极为重要。具体过程如下:

实现思路:自己封装一个路由鉴权的高阶组件,实现未登录拦截,并跳转到登录页面。判断本地是否有token,如果有就返回登录之后的子组件,否则就重定向到登录的Login组件。

在component文件夹下新建authComponent文件,用来对登录页面进行鉴权,如果本地没有token值就强制跳转到登录页面,如下:

// 判断token是否存在,如果存在正常渲染,如果不存在重定向到登录路由
import { getToken } from "../utils";
import { Navigate } from "react-router-dom";

const AuthComponent = ({children}) =>{
  const isToken = getToken()
  if(isToken){
    return <>{children}</>
  }else{
    return <Navigate to='/login' replace />
  }
}
export default AuthComponent

设置好函数之后路由表对其进行判断,如下:

后台页面模块

接下来进行后台页面的搭建,这里也可以借助antd的Layout布局和Menu导航菜单。

基本页面结构搭建

整出代码如下:

import React, { useState } from 'react';
import { Outlet } from 'react-router-dom'
import { Layout, Menu, theme,Popconfirm } from 'antd';
import {
  MenuFoldOutlined,
  MenuUnfoldOutlined,
  LogoutOutlined
} from '@ant-design/icons';
import items from '../../config/index.jsx'
import './index.scss'
const { Header, Sider, Content,Footer } = Layout;
const App = () => {
  const [collapsed, setCollapsed] = useState(false);
  const {
    token: { colorBgContainer },
  } = theme.useToken();
  return (
    <Layout>
      <Sider trigger={null} collapsible collapsed={collapsed} className='sider'>
        <div className="logo" />
        <Menu
          theme="dark"
          mode="inline"
          defaultSelectedKeys={['1']}
          items={items}
        />
      </Sider>
      <Layout className="site-layout">
        <Header style={
   
   { padding: 0,background: colorBgContainer }}>
          {React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
            className: 'trigger',
            onClick: () => setCollapsed(!collapsed),
          })}
          {/* <div className="user-info"> */}
            <span className="user-name">username</span>
            <span className="user-logout">
              <Popconfirm
                // onConfirm={onConfirm}
                title="是否确认退出?" okText="退出" cancelText="取消">
                <LogoutOutlined /> 退出
              </Popconfirm>
            </span>
          {/* </div> */}
        </Header>
        <Content
          style={
   
   {
            margin: '24px 16px 0px',
            padding: 24,
            minHeight: 280,
            background: colorBgContainer,
            overflowY: 'auto'
          }}
        >
          <Outlet />
        </Content>
        <Footer
            style={
   
   {
              textAlign: 'center',
            }}
          >
            Ant Design ©2023 Created by Ant UED
          </Footer>
      </Layout>
    </Layout>
  );
};
export default App;

将items文件单独抽离出来如下代码:

import { Link } from 'react-router-dom'
import { HomeOutlined,DiffOutlined,EditOutlined } from '@ant-design/icons';
function getItem(label, key, icon, children, type) {
  return {
    key,
    icon,
    children,
    label,
    type,
  };
}

const items = [
  getItem(<Link to='/layout/home'>数据概览</Link>, '1', <HomeOutlined />),
  getItem(<Link to='/layout/article'>内容管理</Link>, '2', <DiffOutlined />),
  getItem(<Link to='/layout/publish'>发布文章</Link>, '3', <EditOutlined />),

];

export default items

给出的代码样式为:

.ant-layout {
  height: 100%;
}
.ant-layout-sider{
  flex: 0 0 235px !important; 
  max-width: 300px !important;
}
.sider {
  padding: 0;
}
.logo {
  width: 225px;
  height: 60px;
  background: url('../../assets/logo.jpg') no-repeat center / 160px auto;
  margin: 10px auto 10px;
}

.ant-layout-header svg{
  font-size: 15px;
  margin-left: 15px;
}

.user-name {
  position: absolute;
  right: 5%;
  margin-right: 10px;
  margin-left: 22px;
}
.user-logout {
  position: absolute;
  right: 2%;
  display: inline-block;
  cursor: pointer;
}

#components-layout-demo-custom-trigger .trigger {
  padding: 0 24px;
  font-size: 18px;
  line-height: 64px;
  cursor: pointer;
  transition: color 0.3s;
}

#components-layout-demo-custom-trigger .trigger:hover {
  color: #1890ff;
}

#components-layout-demo-custom-trigger .logo {
  height: 32px;
  margin: 16px;
  background: rgba(255, 255, 255, 0.3);
}

在单独为每个导航菜单创建一个单独的组件进行页面的切换显示,如下:

呈现的结果如下:

菜单高亮显示

配置完路由后,需要对菜单进行相应的高亮显示,在其刷新之后还是处于我们选中的菜单路由,点击浏览器的回退按钮会回退到上一个点击的菜单路由,如下:

具体思路:使用 useLocation 拿到当前的访问路径,根据路径修改key值,来选中当前的key来实现菜单路由的高亮,如下:

展示个人信息

接下来实现后台页面右上角的用户名信息的展示,这里需要借助状态管理工具mobx,如下:

// 获取当前的用户名称即手机号
import { makeAutoObservable } from "mobx";
import { http } from "../utils";

class UserStore {
  userInfo = {}
  constructor(){
    makeAutoObservable(this)
  } 
  getUserInfo = async() => {
    // 调用接口数据
    const res = await http.get('/user/profile')
    this.userInfo = res.data
  }
}
export default UserStore

将定义好的管理用户名的状态存放到根store里面,如下:

// 把所有模块进行一个统一的处理,导出一个统一的方法 useStore
import React from "react";
import LoginStore from "./login";
import UserStore from './username'

class RootStore {
  constructor(){
    this.LoginStore = new LoginStore()
    this.UserStore = new UserStore()
  }
}

// 实例化根 导出useStore context
const rootStore = new RootStore()
const context = React.createContext(rootStore)

const useStore = () => React.useContext(context)

export default useStore

退出登录实现

退出登录需要对token进行删除,具体操作如下:

在处理登录的mobx状态文件中,新增一个退出的功能:

Token失效处理

在日常开发中我们不能让token一直保持活性,需要给其一定的寿命,超过时间token就失活,需要重新登录,这样会保证用户一定的安全性,而当token发送错误时应该如何操作,具体操作如下:

当token发生错误时,调用 window 去跳转到登录页面即可。

首页Home页面展示

首页Home页面采用 echarts 图表封装进行数据显示,这里需要借助 exharts官网 ,进行相关操作如下,需要先安装 echarts 第三方插件包,并使用其第一个案例:

引入之后,在components组件中单独创建应该设置图表的组件,如下:

// 封装图表bar组件
import * as echarts from 'echarts'
import { useEffect, useRef } from 'react'

const Bar = ({ title, xData, yData, style }) =>{
  const domRef = useRef()
  const chartInit = () => {
    // 基于准备好的dom,初始化echarts实例
    const myChart = echarts.init(domRef.current)
    // 绘制图表
    myChart.setOption({
      title: {
        text: title
      },
      tooltip: {},
      xAxis: {
        data: xData
      },
      yAxis: {},
      series: [
        {
          name: '销量',
          type: 'bar',
          data: yData
        }
      ]
    })
  }
  useEffect(()=>{
    chartInit()
  })
  return (
    <div>
      {/* 准备一个挂载节点 */}
      <div ref={domRef} style={style}></div>
    </div>
  )
}
export default Bar

在Home组件中导入设置图表的组件Bar,如下:

import React from 'react'
import Bar from '../../components/Bar'
import './index.scss'

const Home = () => {
  return (
    <div className='home'>
      {/* 渲染Bar组件 */}
      <Bar
        title='主流框架满意度'
        xData={['react', 'vue', 'angular']}
        yData={[30, 40, 50]}
        style={
   
   { width: '500px', height: '400px' }} />
      <Bar
        title='主流框架使用度'
        xData={['react', 'vue', 'angular']}
        yData={[60, 70, 80]}
        style={
   
   { width: '500px', height: '400px' }} />
    </div>
  )
}

export default Home

设置样式如下:

.home {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

最后的界面如下:

内容管理Article页面展示

内容的article组件需要借助 antd中 Card、Form、DatePicker等功能块,给出代码如下:

import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { observer } from 'mobx-react-lite'
import { Table, Space, Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import locale from 'antd/es/date-picker/locale/zh_CN'
import { http } from '../../utils'
import img404 from '../../assets/error.jpg'

const { Option } = Select
const { RangePicker } = DatePicker

const Article = () => {
  // 路由导航
  const navigate = useNavigate()
  // 频道列表管理
  const [channelList,setChannelList] = useState([])  
  // 文章列表管理
  const [articleData,setArticleData] = useState({
    list:[], // 文章列表
    count:0 // 文章数量
  })
  // 文章参数管理
  const [params,setParams] = useState({
    page:1,
    per_page:10
  })

  // 获取频道管理的数据
  const loadChannelList = async () =>{ 
    const res = await http.get('/channels')
    setChannelList(res.data.channels)
  }

  useEffect(()=>{
    loadChannelList()
  },[])
  useEffect(()=>{
    // 获取文章列表数据
    const loadList = async () =>{ 
      const res = await http.get('/mp/articles',{ params })
      const { results,total_count } = res.data
      setArticleData({
        list:results,
        count:total_count
      })
    }
    loadList()
  },[params])
  
  const onFinish = (values) =>{ 
    console.log(values);
    // 获取表单数据
    const { channel_id,date,status } = values
    // 数据处理
    const _params = {}
    if(status !== -1){
      _params.status = status
    }
    if(channel_id){
      _params.channel_id = channel_id
    }
    if(date){
      _params.begin_pubdate = date[0].format('YYYY-MM-DD')
      _params.end_pubdate = date[1].format('YYYY-MM-DD')
    }
    // 修改params数据 引起接口的重新发送 对象的合并是一个整体覆盖 改了对象的整体引用
    setParams({
      ...params,
      ..._params
    })
  }
  // 翻页实现
  const pageChange = (page) => {
    setParams({
      ...params,
      page
    })
  }

  // 删除文章
  const delArticle = async (data) => {
    await http.delete(`/mp/articles/${data.id}`)
    // 刷新一下列表
    setParams({
      ...params,
      page: 1
    })
  }

  // 编辑文章
  const goPublish = (data) => {
    navigate(`/layout/publish?id=${data.id}`)
  }

  const columns = [
    {
      title: '封面',
      dataIndex: 'cover',
      width: 120,
      render: cover => {
        return <img src={cover.images[0] || img404} width={80} height={60} alt="" />
      }
    },
    {
      title: '标题',
      dataIndex: 'title',
      width: 220
    },
    {
      title: '状态',
      dataIndex: 'status',
    },
    {
      title: '发布时间',
      dataIndex: 'pubdate'
    },
    {
      title: '阅读数',
      dataIndex: 'read_count'
    },
    {
      title: '评论数',
      dataIndex: 'comment_count'
    },
    {
      title: '点赞数',
      dataIndex: 'like_count'
    },
    {
      title: '操作',
      render: data => {
        return (
          <Space size="middle">
            <Button
              type="primary"
              shape="circle"
              icon={<EditOutlined />}
              onClick={() => goPublish(data)} 
            />
            <Button
              type="primary"
              danger
              shape="circle"
              icon={<DeleteOutlined />}
              onClick={() => delArticle(data)}
            />
          </Space>
        )
      },
      fixed: 'right'
    }
  ]

  return (
    <div>
      {/* 筛选区域 */}
      <Card
        title={
          <Breadcrumb separator=">" items={[
            {title:<Link to="/layout/home">首页</Link>},
            {title:'内容管理'}
          ]} />
        }
        style={
   
   { marginBottom: 20 }}
      >
        <Form
          onFinish={onFinish}
          initialValues={
   
   { status: '' }}>
          <Form.Item label="状态" name="status">
            <Radio.Group>
              <Radio value=''>全部</Radio>
              <Radio value={0}>草稿</Radio>
              <Radio value={1}>待审核</Radio>
              <Radio value={2}>审核通过</Radio>
              <Radio value={3}>审核失败</Radio>
            </Radio.Group>
          </Form.Item>

          <Form.Item label="频道" name="channel_id">
            <Select
              placeholder="请选择文章频道"
              style={
   
   { width: 120 }}
            >
              {channelList.map(channel => <Option key={channel.id} value={channel.id}>{channel.name}</Option>)}
            </Select>
          </Form.Item>

          <Form.Item label="日期" name="date">
            {/* 传入locale属性 控制中文显示*/}
            <RangePicker locale={locale}></RangePicker>
          </Form.Item>

          <Form.Item>
            <Button type="primary" htmlType="submit" style={
   
   { marginLeft: 80 }}>
              筛选
            </Button>
          </Form.Item>
        </Form>
      </Card>
      {/* 文章列表区域 */}
      <Card title={`根据筛选条件共查询到 ${articleData.count} 条结果:`}>
        <Table
          rowKey="id"
          columns={columns}
          dataSource={articleData.list}
          pagination={
            {
              pageSize: params.per_page,
              total: articleData.count,
              onChange: pageChange,
              current: params.page
            }
          }
        />
      </Card>
    </div>
  )
}

export default observer(Article)

实现的界面如下:

发布文章Publish页面展示

发布文章这个界面需要使用富文本编辑器,这里需要借助第三方插件库,详细的使用教程可以参考一下github上的介绍,网址为:react-quill ,其安装命令如下:

npm install react-quill

给出如下代码:

import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { observer } from 'mobx-react-lite'
import { Card,Breadcrumb,Form,Button,Radio,Input,Upload,Space,Select,message} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import './index.scss'
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'
import useStore from '../../store'
import http from '../../utils/http'

const { Option } = Select
const Item = Form.Item

const Publish = () => {
  const navigate = useNavigate()
  // 获取频道数据
  const { ChannelStore } = useStore()
  // 存放上传图片的列表
  const [fileList,setFileList] = useState([])
  // 保存的图片数量
  const [imgCount,setImgCount] = useState(1)
  // 声明图片的暂存仓库
  const cacheImgList = useRef()

  // 获取表单数据
  const onFinish = async(values) =>{ 
    // 数据的二次处理 重点是处理cover字段
    const { channel_id, content, title, type } = values
    // 判断type fileList 是匹配的才能正常提交
    const params = {
      channel_id,
      content,
      title,
      type,
      cover: {
        type: type,
        images: fileList.map(item => item.url)
      }
    }
    if (id) {
      await http.put(`/mp/articles/${id}?draft=false`, params)
    } else {
      await http.post('/mp/articles?draft=false', params)
    }

    // 跳转列表 提示用户
    navigate('/layout/article')
    message.success(`${id ? '更新成功' : '发布成功'}`)
  }
  const onUploadChange = ({fileList}) =>{ 
    // 这里关键位置:需要做数据格式化
    const formatList = fileList.map(file => {
      // 上传完毕 做数据处理
      if (file.response) {
        return {
          url: file.response.data.url
        }
      }
      // 否则在上传中时,不做处理
      return file
    })
    // 存放data数据
    setFileList(formatList)
    // 同时把图片列表存入仓库一份
    cacheImgList.current = formatList
  }
  // 切换图片
  const radioChange = (e) =>{ 
    const rawValue = e.target.value
    setImgCount(rawValue)
    console.log(cacheImgList.current);
    // 从仓库里面获取对应的图片数量 交给用来渲染图片的fileList
    if(cacheImgList.current === undefined || 0){
      return false
    }
    if( rawValue === 1 ){
      const img = cacheImgList.current ? cacheImgList.current[0] : []
      setFileList([img])
    }else if ( rawValue === 3 ){
      setFileList(cacheImgList.current)
    }
  }
  // 编辑功能 文案适配 路由参数id 判断条件
  const [params] = useSearchParams()
  const id = params.get('id')
  // 数据回填  id调用接口  1.表单回填 2.暂存列表 3.Upload组件fileList
  const [form] = Form.useForm()
  useEffect(() => {
    const loadDetail = async () => {
      const res = await http.get(`/mp/articles/${id}`)
      const data = res.data
      // 表单数据回填
      form.current.setFieldsValue({ ...data, type: data.cover.type })
      // 回填upload
      const formatImgList = data.cover.images.map(url => ({ url }))
      setFileList(formatImgList)
      // 暂存列表里也存一份
      cacheImgList.current = formatImgList
      // 图片type
      setImgCount(data.cover.type)
    }
    // 必须是编辑状态 才可以发送请求
    if (id) {
      loadDetail()
    }
  }, [id, form])

  return (
    <div className="publish">
      <Card
        title={
          <Breadcrumb separator=">" items={[
            {title:<Link to="/layout/home">首页</Link>},
            {title:`${id ? '编辑' : '发布'}文章`}
          ]} />
        }
      >
        <Form ref={form} labelCol={
   
   { span: 4 }} wrapperCol={
   
   { span: 16 }} initialValues={
   
   { type: 1, content: ''}} onFinish={onFinish}>
          <Item label="标题" name="title" rules={[{ required: true, message: '请输入文章标题' }]}>
            <Input placeholder="请输入文章标题" style={
   
   { width: 400 }} />
          </Item>
          <Item label="频道" name="channel_id" rules={[{ required: true, message: '请选择文章频道' }]}>
            <Select placeholder="请选择文章频道" style={
   
   { width: 400 }}>
              {ChannelStore.channelList.map(channel => <Option key={channel.id} value={channel.id}>{channel.name}</Option>)}
            </Select>
          </Item>
          <Item label="封面">
            <Item name="type">
              <Radio.Group onChange={radioChange}>
                <Radio value={1}>单图</Radio>
                <Radio value={3}>三图</Radio>
                <Radio value={0}>无图</Radio>
              </Radio.Group>
            </Item>
            { imgCount > 0 && (
              <Upload name='image' listType='picture-card' className='avatar-uploader' showUploadList
                action="http://geek.itheima.net/v1_0/upload" fileList={fileList} onChange={onUploadChange}
                multiple={ imgCount > 1 } maxCount={ imgCount } >
                <div style={
   
   {marginTop:8}}>
                  <PlusOutlined />
                </div>
              </Upload>)
            }
  
          </Item>
          {/* 这里的富文本组件 已经被Item控制 */}
          {/* 它的输入内容 会在onFinished回调中收集起来 */}
          <Item label="内容" name="content" rules={[{ required: true, message: '请输入文章内容' }]}>
            <ReactQuill theme="snow" />
          </Item>
          <Item wrapperCol={
   
   { offset: 4 }}>
            <Space>
              <Button size="large" type="primary" htmlType="submit">
                { id ? '更新' : '发布' }文章
              </Button>
            </Space>
          </Item>
        </Form>
      </Card>
    </div>
  )
}

export default observer(Publish)

给出样式如下:

.publish {
  position: relative;
  .ql-container {
    height: 400px !important;
  }
}

.ant-upload-list {
  .ant-upload-list-picture-card-container,
  .ant-upload-select {
    width: 146px;
    height: 146px;
  }
}

文章管理与发布文章的联动这里就不在赘述,可以看一下上面的组件代码,画面如下:

项目的相关优化

路由懒加载:能够对路由进行懒加载实现代码分隔,因为React是单页面应用,webpake打包后文件会很大所以启动的时候打开首页资源过多可能会出现路由懒加载,接下来讲解路由懒加载的使用。具体过程如下:

对router进行路由懒加载,如下:

import { lazy } from "react";
import { Navigate } from "react-router-dom";
import Layout from "../pages/Layout";
import Login from "../pages/Login";
import AuthComponent from "../components/authComponent";
const Home = lazy(()=>import('../pages/Home'))
const Article = lazy(()=>import('../pages/Article'))
const Publish = lazy(()=>import('../pages/Publish'))

const routes = [
  { path:'/login',element:<Login/> },
  { path:'/layout',element:
    <AuthComponent>
      <Layout/> 
    </AuthComponent>,
    children:[
      { path:'/layout/home',element:<Home/> },
      { path:'/layout/article',element:<Article/> },
      { path:'/layout/publish',element:<Publish/> }
    ]
  },
  { path:'/',element:<Navigate to='layout/home'/> },
]
 
export default routes

配置完路由表之后,将要进行路由懒加载的展示区进行组件包裹,并以组件作为回调:

给Loading组件添加相关的样式,给页面添加好看的loading组件

配置全局样式:在 5.0 版本的 Ant Design 中,提供了一套全新的定制主题方案。不同于 4.x 版本的 less 和 CSS 变量,有了 CSS-in-JS 的加持后,动态主题的能力也得到了加强。具体操作如下:

项目git上线

每次书写项目时,完成一个功能点为了避免后期编写代码产生错误,需要将代码上传到远程仓库上进行一个版本的保存,这样便避免了代码遗失或写错而产生的错误,以 Github 为例,具体操作步骤如下:

创建远程仓库:进入github网址,登录自己的账号后点击右上角头像新建仓库,如下

创建完成后,会跳转到如下界面,上面代码详细的告诉你如何将代码提交到远程库:

提交代码需要借用一个 git 工具,如何下载这里不再赘述,自行百度。如果想详细的了解git的使用,可以参考一下我之前的专栏:git专栏 ,详细操作如下:

生成本地仓库:具体步骤如下:

git init 生成工作区

git add . 提交到暂存区

git commit -m "article" 提交到版本区

推送到远程仓库:具体步骤如下:

git remote add origin [email protected]:ztK63LrD/article-pc.git  关联别名

git push -u origin "master" 推送分支

推送完成之后,刷新github页面就会将我们推送的代码呈现在页面上,如下:

创建新的分支:通常我们在创建完master分支后,并不会直接将所有的代码都整合到该分支上,而是再建立起别的分支,在别的分支上推送代码,当确保代码完整性后再与主分支master进行合并,这样会大大提高代码的正确性和规范性。其具体步骤如下:

git checkout -b dev 创建并切换到该分支

git push origin -u "dev" 将该分支推送到远程仓库上

将dev分支的代码推送到远程仓库: 因为你使用的不是主分支master,当前日常开发中,解决了一些问题之后,想将其推送到远程仓库做一个保存,需要进行如下操作(和之前操作一致),这里可以直接在编辑器的终端进行操作:

推送完成之后,远程仓库会有提示,提示你当前分支出现了新的推送:

可以点击当前推送的dev分支,点击如下选项可以查看推送后的代码与之前的代码有何区别:

dev分支与主分支master的合并:当确定dev分支的代码没毛病后,就可以推送到主分支master上面,具体操作如下:

进入当前页面后,可以写一些你为啥要修改的描述,也可以不写,直接点击创建即可:

点击如下按钮进行接收合并的请求:

合并之后,两个master就具有了dev分支的内容,当前页面也没有任何的提示消息了:

修改本地的master分支:修改了远程库的master分支后,本地的master并没有改变,还是之前的版本,所以我们还需要将远程更新过后的master分支进行一个本地的拉取,如下:

 git checkout master 切换到master分支

git pull origin master 将远程的master分支进行一个拉取

项目打包

项目完成之后,对项目进行打包然后交给后端服务器进行项目上线即可,下面举个例子:

终端运行如下命令进行项目打包:

npm run build

打包完成之后如果想在本地进行预览的话,可以通过全局安如下方式进行操作:

全局安装本地服务包:npm install -g serve 该包提供了serve命令,用来启动本地服务

在项目根目录中执行命令:serve -s ./build 在build目录中开启服务器

在浏览器中访问:http://localhost:3000/ 预览项目

打包完成后,也可以进行项目打包体积的分析,其使用步骤如下:

安装分析打包体积的包:npm install source-map-explorer

在package.json中的scripts标签中,添加分析打包体积的命令,核心代码如下:

  "scripts": {
    "analyze": "source-map-explorer 'build/static/js/*.js'"
  },

对项目打包:npm build(如果已经打包可以省略这一步)

运行分析命令:npm analyze

通过浏览器打开的界面,分析图表中的包体积

源码地址项目地址 。如果觉得对自己有帮助的话,希望可以给这个项目点点小星星,您的支持就是对博主最大的鼓励。

猜你喜欢

转载自blog.csdn.net/qq_53123067/article/details/129757298