一、项目搭建

基于CRA创建项目:CRA是一个底层基于webpack快速创建React项目的脚手架工具

# 使用npx创建项目
npx create-react-app react-jike

# 进入到项
cd react-jike

# 启动项目
npm start
按照业务规范整理项目目录(重点SRC目录)

image

image

调整项目目录结构

-src
  -apis           项目接口函数
  -assets         项目资源文件,比如,图片等
  -components     通用组件
  -pages          页面组件
  -store          集中状态管理
  -utils          工具,比如,token、axios 的封装等
  -App.js         根组件
  -index.css      全局样式
  -index.js       项目入口

下面来清理文件:

src/index.js

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.scss'
import './App.js'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App/>
  </React.StrictMode>
)

src/App.js

const App = () => {
  return <div>this is app</div>
}

export default App

删除App.css、App.test.js、logo.svg、reportWebVitals.js、setupTests.js文件

二、使用scss预处理器

SASS 是一种预编译的 CSS,支持一些比较高级的语法,可以提高编写样式的效率,CRA接入scss非常简单只需要我们装一个sass工具

SCSS是什么
SCSS是一种预编译CSS语言,支持一些原生CSS不支持的高级用法,比如变量使用,嵌套语法等,使用SCSS可以让样式代码更加高效灵活

image

实现步骤

  1. 安装解析 sass 的包:npm i sass -D

  2. 创建全局样式文件:index.scss(将index.css改名为index.scss,并修改index.js中引入为import './index.scss';)

下面的嵌套语法在原生css中是不支持的,只在scss文件里面才支持,

body {
  margin: 0;
  div {
    color: blue;
  }
}

效果如下:

image

三、组件库antd使用

Ant Design 是什么

Ant Design 是由蚂蚁金服出品的社区使用最广的React PC端组件库,内置了常用的现成组件,可以帮助我们快速开发PC管理后台项目

我们的项目是一个传统的PC管理后台,有现成的组件库可以使用,帮助我们提升开发效率,其中使用最广的就是antD

官网:https://ant.design/index-cn

 访问 Ant Design 官网卡顿,通常是因为官方站点 ant.design 的服务器在海外,国内网络直连时容易受网络波动影响

最直接的解决方法是改用国内镜像站。这些镜像由国内服务托管,访问速度会快很多。

image

实现步骤

  1. 安装 antd 组件库:npm i antd

  2. 导入 Button 组件

  3. 在 App.js 页面渲染 Button 组件进行测试

测试Button

import { Button } from 'antd'
function App() {
  return (
    <div>
      this is app
      <Button type='primary'>test</Button>
    </div>
  );
}

export default App;

效果如下:

image

四、配置基础路由

 单页应用需要对应的路由支持,我们使用 react-router-dom 最新版本

 

实现步骤

  1. 安装路由包 npm i react-router-dom

  2. 准备 Layout和 Login俩个基础组件

  3. 配置路由(在router/index.js文件中引入组件进行路由配置,导出router实例)

  4. 在入口文件中渲染 <RouterProvider/>,传入router实例

代码实现

pages/Layout/index.js

const Layout = () => {
  return <div>this is layout</div>
}
export default Layout

pages/Login/index.js

const Login = () => {
  return <div>this is login</div>
}
export default Login

router/index.js

import { createBrowserRouter } from 'react-router-dom'

import Login from '../pages/Login'
import Layout from '../pages/Layout'

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
  },
  {
    path: '/login',
    element: <Login />,
  },
])

export default router

index.js

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.scss'
import router from './router'
import { RouterProvider } from 'react-router-dom'

ReactDOM.createRoot(document.getElementById('root')).render(
    <RouterProvider router={router} />
)

效果如下:

image

五、配置别名路径

项目背景:在业务开发过程中文件夹的嵌套层级可能会比较深,通过传统的路径选择会比较麻烦也容易出错,设置路径别名可以简化这个过程

什么是@别名路径?

通过@替代src路径,方便开发过程中的路径查找访问

image

上面是常规路径访问,如果文件的嵌套层次很深的话,用这种方式很容易出错,而且写起来很麻烦

下面是加了@别名之后的写法,相当于始终从src目录往下找,不容易报错,而且找起来也很方便

如何配置?

1. 针对路径转换,修改webpack别名路径配置 craco

2. 针对联想提示,修改VSCode配置 jsconfig.json

路径编译配置

(1)、安装 craco 工具包

npm i @craco/craco -D

(2)、在根目录下增加 craco.config.js 配置文件

const path = require('path')

module.exports = {
  // webpack 配置
  webpack: {
    // 配置别名
    alias: {
      // 约定:使用 @ 表示 src 文件所在路径
      '@': path.resolve(__dirname, 'src')
    }
  }
}

(3)、修改package.json中的 scripts 命令

修改之前

"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

修改之后

"scripts": {
  "start": "craco start",
  "build": "craco build",
  "test": "craco test",
  "eject": "react-scripts eject"
}

(4)、测试是否生效

import { createBrowserRouter } from 'react-router-dom'

import Login from '@/pages/Login'
import Layout from '@/pages/Layout'

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
  },
  {
    path: '/login',
    element: <Login />,
  },
])

export default router

VsCode提示配置

实现步骤

  1. 在项目根目录创建 jsconfig.json 配置文件

  2. 在配置文件中添加以下配置

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

说明:VSCode会自动读取jsconfig.json 中的配置,让vscode知道@就是src目录

六、登录模块

基本结构搭建

image

实现步骤

  1. 在 Login/index.js 中创建登录页面基本结构

  2. 在 Login 目录中创建 index.scss 文件,指定组件样式

  3. 将 logo.png 和 login.png 拷贝到 assets 目录中

代码实现

pages/Login/index.js

import './index.scss'
import { Card, Form, Input, Button } from 'antd'
import logo from '@/assets/logo.png'

const Login = () => {
  return (
    <div className="login">
      <Card className="login-container">
        <img className="login-logo" src={logo} alt="" />
        {/* 登录表单 */}
        <Form>
          <Form.Item>
            <Input size="large" placeholder="请输入手机号" />
          </Form.Item>
          <Form.Item>
            <Input size="large" placeholder="请输入验证码" />
          </Form.Item>
          <Form.Item>
            <Button type="primary" htmlType="submit" size="large" block>
              登录
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Login

pages/Login/index.scss

.login {
  width: 100%;
  height: 100%;
  position: absolute;
  left: 0;
  top: 0;
  background: center/cover url('~@/assets/login.png');

  .login-logo {
    width: 200px;
    height: 60px;
    display: block;
    margin: 0 auto 20px;
  }

  .login-container {
    width: 440px;
    height: 360px;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    box-shadow: 0 0 50px rgb(0 0 0 / 10%);
  }

  .login-checkbox-label {
    color: #1890ff;
  }
}

效果如下:

image

表单校验实现

实现步骤

  1. 为 Form 组件添加 validateTrigger 属性,指定校验触发时机的集合

  2. 为 Form.Item 组件添加 name 属性

  3. 为 Form.Item 组件添加 rules 属性,用来添加表单校验规则对象

代码实现

page/Login/index.js

 

import './index.scss'
import { Card, Form, Input, Button } from 'antd'
import logo from '@/assets/logo.png'

const Login = () => {
  return (
    <div className="login">
      <Card className="login-container">
        <img className="login-logo" src={logo} alt="" />
        {/* 登录表单 */}
        <Form validateTrigger={['onBlur']}>
          <Form.Item name="mobile"
            rules={[
            { required: true, message: '请输入手机号' },
            {
                pattern: /^1[3-9]\d{9}$/,
                message: '手机号码格式不对'
            }
            ]}>
            <Input size="large" placeholder="请输入手机号" />
          </Form.Item>
          <Form.Item name="code" rules={[
            { required: true, message: '请输入验证码' },
            ]}>
            <Input size="large" placeholder="请输入验证码"  maxLength={6} />
          </Form.Item>
          <Form.Item>
            <Button type="primary" htmlType="submit" size="large" block>
              登录
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Login

获取登录表单数据

实现步骤

  1. 为 Form 组件添加 onFinish 属性,该事件会在点击登录按钮时触发

  2. 创建 onFinish 函数,通过函数参数 values 拿到表单值

  3. Form 组件添加 initialValues 属性,来初始化表单值

 代码实现

pages/Login/index.js

import './index.scss'
import { Card, Form, Input, Button } from 'antd'
import logo from '@/assets/logo.png'

const Login = () => {
    // 点击登录按钮时触发 参数values即是表单输入数据
    const onFinish = formValue => {
        console.log(formValue)
    }
  return (
    <div className="login">
      <Card className="login-container">
        <img className="login-logo" src={logo} alt="" />
        {/* 登录表单 */}
        <Form validateTrigger={['onBlur']} onFinish={ onFinish } 
            initialValues={{
                mobile: '15342218161',
                code: '123456',
            }}>
          <Form.Item name="mobile"
            rules={[
            { required: true, message: '请输入手机号' },
            {
                pattern: /^1[3-9]\d{9}$/,
                message: '手机号码格式不对'
            }
            ]}>
            <Input size="large" placeholder="请输入手机号" />
          </Form.Item>
          <Form.Item name="code" rules={[
            { required: true, message: '请输入验证码' },
            ]}>
            <Input size="large" placeholder="请输入验证码"  maxLength={6} />
          </Form.Item>
          <Form.Item>
            <Button type="primary" htmlType="submit" size="large" block>
              登录
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Login

七、封装request工具模块

 业务背景: 前端需要和后端拉取接口数据,axios是使用最广的工具插件,针对于项目中的使用,我们需要做一些简单的封装

在整个项目中会发送很多网络请求,使用axios三方库做好统一封装,方便统一管理和复用

1. 几乎所有的接口都是一样的接口域名

2. 几乎所有的接口都需要设置一样的超时时间

3. 几乎所有的接口都需要做Token权限处理

....

image

实现步骤

(1)、安装 axios 到项目

npm i axios

(2)、创建 utils/request.js 文件

(3)、创建 axios 实例,配置 baseURL,请求拦截器,响应拦截器

import axios from 'axios'

const request = axios.create({
  baseURL: 'http://geek.itheima.net/v1_0',
  timeout: 5000
})

// 添加请求拦截器
request.interceptors.request.use((config)=> {
    return config
  }, (error)=> {
    return Promise.reject(error)
})

// 添加响应拦截器
request.interceptors.response.use((response)=> {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response.data
  }, (error)=> {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error)
})

export { request }

(4)、在 utils/index.js 中,统一导出request

import { request } from './request'
export { request }

八、使用Redux管理token

Token作为一个用户的标识数据,需要在很多个模块中共享,Redux可以方便的解决状态共享问题

image

1. Redux中编写获取Token的异步获取和同步修改

2. Login组件负责提交action并且把表单数据传递过来

安装Redux相关工具包

npm i react-redux @reduxjs/toolkit

store目录下新建index.js,store目录下新建modules文件夹,modules文件夹下面新建user.js文件,

user.js中配置Redux

import { createSlice } from '@reduxjs/toolkit'
import { request } from '@/utils'
const userStore = createSlice({
  name: 'user',
  // 数据状态
  initialState: {
    token:''
  },
  // 同步修改方法
  reducers: {
    setToken (state, action) {
      state.token = action.payload
    },
  }
})

// 解构出actionCreater
const { setToken } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
  return async (dispatch) => {
    const res = await request.post('/authorizations', loginForm)
    dispatch(setToken(res.data.token))
  }
}

export { fetchLogin }

export default userReducer

store/index.js

import { configureStore } from '@reduxjs/toolkit'

import userReducer from './modules/user'

export default configureStore({
  reducer: {
    // 注册子模块
    user: userReducer
  }
})

redux与react组件进行关联

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.scss';
import router from './router'
import { RouterProvider } from 'react-router-dom'
import {Provider} from 'react-redux'
import store from './store'
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <RouterProvider router={router} />
    </Provider>
  </React.StrictMode>
);

 

解决跨域:

Access to XMLHttpRequest at 'http://geek.itheima.net/v1_0/authorizations' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.

第 1 步:前端请求走相对路径

  // 使用相对路径 /api,由 devServer 代理转发,避免浏览器跨域
  // 真实地址:http://geek.itheima.net/v1_0/...
  baseURL: '/api/v1_0',
  timeout: 5000

调用处不再手写任何前缀:

const res = await request.post('/authorizations', loginForm)

第 2 步:配置代理文件

在 src/setupProxy.js(CRA 项目固定位置)声明转发规则:

const { createProxyMiddleware } = require('http-proxy-middleware')

// 注意:该文件必须位于 src 目录下,CRA 才会自动加载
module.exports = function (app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://geek.itheima.net', // 真实接口域名
      changeOrigin: true, // 修改请求头 Host,保证代理生效
      pathRewrite: { '^/api': '' } // 去掉 /api 前缀再转发
    })
  )
}

三个配置项含义:

image

完整链路验证

浏览器  POST http://localhost:3000/api/v1_0/authorizations   ← 同源 ✅
                    ↓ 匹配 /api 前缀
        pathRewrite: '^/api' → ''  ⇒  /v1_0/authorizations
                    ↓ changeOrigin 转发
        http://geek.itheima.net/v1_0/authorizations          ← 与真实地址一致 ✅

为什么必须用 /api 这种前缀

前端真实接口是 /v1_0/authorizations,而代理需要「只拦截接口请求,不拦截页面资源(如 /static/js/...)」。用一个自定义前缀 /api 做标识最稳妥:

前端:/api/v1_0/authorizations(多了层 /api 是故意的)

代理:pathRewrite 把 /api 去掉 → 还原成真实路径

前缀叫什么无所谓(/api、/proxy、/dev 都行),只要是不会和真实接口路径冲突的一段即可。

实现登录逻辑

在Login/index.js中触发异步action(fetchLogin),

业务逻辑:

  1. 跳转到首页

  2. 提示用户登录成功

import './index.scss'
import { Card, Form, Input, Button } from 'antd'
import logo from '@/assets/logo.png'
import { fetchLogin } from '@/store/modules/user'
import { useDispatch } from 'react-redux'
import { useNavigate } from "react-router-dom"
import { message } from 'antd'
const Login = () => {
    
    // 点击登录按钮时触发 参数values即是表单输入数据
    const dispatch = useDispatch()
    const navigate = useNavigate()
    const onFinish = async formValue => {
        console.log(formValue)
        await dispatch(fetchLogin(formValue))
        navigate('/')
        message.success('登录成功')
    }
    
  return (
    <div className="login">
      <Card className="login-container">
        <img className="login-logo" src={logo} alt="" />
        {/* 登录表单 */}
        <Form validateTrigger={['onBlur']} onFinish={ onFinish } 
            initialValues={{
                mobile: '15342218161',
                code: '246810',
            }}>
          <Form.Item name="mobile"
            rules={[
            { required: true, message: '请输入手机号' },
            {
                pattern: /^1[3-9]\d{9}$/,
                message: '手机号码格式不对'
            }
            ]}>
            <Input size="large" placeholder="请输入手机号" />
          </Form.Item>
          <Form.Item name="code" rules={[
            { required: true, message: '请输入验证码' },
            ]}>
            <Input size="large" placeholder="请输入验证码"  maxLength={6} />
          </Form.Item>
          <Form.Item>
            <Button type="primary" htmlType="submit" size="large" block>
              登录
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Login

await 的作用是:

  • 暂停当前函数的执行,等这个 Promise 完成(resolve 或 reject)

  • 等请求真正结束后,才继续执行后面的 navigate('/') 和 message.success

如果去掉 await 会怎样?

dispatch(fetchLogin(formValue))  // 没有 await
navigate('/')
message.success('登录成功')

这三行会几乎同时执行。dispatch 只是发出了一个登录请求,请求还在路上,代码就已经跳到 navigate('/') 了。

image

发现从后台获取的token已经存入Redux中了。

我们刷新浏览器,发现token丢失,如下所示:

image

token持久化

业务背景: Token数据具有一定的时效时间,通常在几个小时,有效时间内无需重新获取,而基于Redux的存储方式又是基于内存的,刷新就会丢失,为了保持持久化,我们需要单独做处理

现存问题

Redux存入Token之后如果刷新浏览器,Token会丢失(持久化就是防止刷新时丢失Token)

问题原因

Redux是基于浏览器内存的存储方式,刷新时状态恢复为初始值

initialState: {
    token:''
  },
技术方案
整个方案分成两个步骤,第一步是获取并存token,不仅要存入redux,还要存入localStorage。第二步:在初始化的过程中,先不要直接赋值一个空字符串,而是先去LocalStorage中查看有没有,如果有就用LocalStorage中的,如果没有就赋值空字符串,这样的话,刷新的时候先从LocalStorage中取,从而不会丢失了。

image

第一步:将token存入localStorage中

const userStore = createSlice({
  name: 'user',
  // 数据状态
  initialState: {
    token:''
  },
  // 同步修改方法
  reducers: {
    setToken (state, action) {
      state.token = action.payload
      // localStorage中存一份
      localStorage.setItem('token_key',action.payload)
    }
  }
})

如下所示:

image

第二步:初始化时先从localStorage中取

initialState: {
    token:localStorage.getItem('token_key') || ''
  },

此时再刷新浏览器,则redux中的token仍然存在

image

封装Token的存取删方法

封装的原因?

对于Token的各类操作在项目多个模块中都有用到,为了共享复用可以封装成工具函数

image

封装存取方法,在utils目录下新增token.js文件

// 封装存取方法

const TOKENKEY = 'token_key'

function setToken (token) {
  return localStorage.setItem(TOKENKEY, token)
}

function getToken () {
  return localStorage.getItem(TOKENKEY)
}

function removeToken () {
  return localStorage.removeItem(TOKENKEY)
}

export {
  setToken,
  getToken,
  removeToken
}

utils/index.js中添加如下代码

import { request } from './request'
import {getToken,setToken,removeToken} from './token'
export { request,getToken,setToken,removeToken }

实现持久化逻辑

import { createSlice } from '@reduxjs/toolkit'
import { request,getToken,setToken as _setToken } from '@/utils'
const userStore = createSlice({
  name: 'user',
  // 数据状态
  initialState: {
    token:getToken() || ''
  },
  // 同步修改方法
  reducers: {
    setToken (state, action) {
      state.token = action.payload
      // localStorage中存一份
    //   localStorage.setItem('token_key',action.payload)
        _setToken(action.payload)
    }
  }
})

// 解构出actionCreater
const { setToken } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
  return async (dispatch) => {
    const res = await request.post('/authorizations', loginForm)
    console.log(res)
    dispatch(setToken(res.data.token))
  }
}

export { fetchLogin }

export default userReducer

请求拦截器注入token

业务背景: Token作为用户的数据标识,在接口层面起到了接口权限控制的作用,也就是说后端有很多接口都需要通过查看当前请求头信息中是否含有token数据,来决定是否正常返回数据

为什么要做这件事儿?

Token作为用户的一个标识数据,后端很多接口都会以它作为接口权限判断的依据;请求拦截器注入Token之后,所有用到Axios实例的接口请求都自动携带了Token 

image

拼接方式:config.headers.Authorization = Bearer ${token}}

utils/request.js

// 添加请求拦截器
request.interceptors.request.use(config => {
  // if not login add token
  const token = getToken()
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

测试token是否注入

import {request}  from '@/utils'
import { useEffect } from 'react'
const Layout = () => {
  useEffect(()=>{ // useEffect组件渲染完毕之后从后台获取数据
    request.get('/user/profile')
  },[])
  return <div>this is layout</div>
}
export default Layout

效果如下:

image

路由鉴权实现

业务背景:封装 AuthRoute 路由鉴权高阶组件,实现未登录拦截,并跳转到登录页面

实现思路:判断本地是否有token,如果有,就返回子组件,否则就重定向到登录Login

具体要做什么事儿?

有些路由页面内的内容信息比较敏感,如果用户没有经过登录获取到有效Token,是没有权限跳转的,根据Token的有无控制当前路由是否可以跳转就是路由的权限控制

技术方案:

image

实现步骤

  1. 在 components 目录中,创建 AuthRoute/index.jsx 文件

  2. 登录时,直接渲染相应页面组件

  3. 未登录时,重定向到登录页面

  4. 将需要鉴权的页面路由配置,替换为 AuthRoute 组件渲染

代码实现

components/AuthRoute/index.jsx

import { getToken } from '@/utils'
import { Navigate } from 'react-router-dom'

const AuthRoute = ({ children }) => {
  const isToken = getToken()
  if (isToken) {
    return <>{children}</>
  } else {
    return <Navigate to="/login" replace />
  }
}

export default AuthRoute

src/router/index.jsx

import { createBrowserRouter } from 'react-router-dom'

import Login from '@/pages/Login'
import Layout from '@/pages/Layout'
import AuthRoute from '@/components/AuthRoute'


const router = createBrowserRouter([
  {
    path: '/',
    element: <AuthRoute><Layout /></AuthRoute>,
  },
  {
    path: '/login',
    element: <Login />,
  },
])

export default router

测试有无token情况下的高阶组件

当删除localStorage中的token时,会跳转到登录页面

image

image

这样根据token的有无控制路由权限的跳转就完成了

九、Layout模块

结构创建

image

实现步骤

  1. 打开 antd/Layout 布局组件文档,找到示例:顶部-侧边布局-通栏

  2. 拷贝示例代码到我们的 Layout 页面中

  3. 分析并调整页面布局

代码实现

pages/Layout/index.js

import { Layout, Menu, Popconfirm } from 'antd'
import {
  HomeOutlined,
  DiffOutlined,
  EditOutlined,
  LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'

const { Header, Sider } = Layout

const items = [
  {
    label: '首页',
    key: '1',
    icon: <HomeOutlined />,
  },
  {
    label: '文章管理',
    key: '2',
    icon: <DiffOutlined />,
  },
  {
    label: '创建文章',
    key: '3',
    icon: <EditOutlined />,
  },
]

const GeekLayout = () => {
  return (
    <Layout>
      <Header className="header">
        <div className="logo" />
        <div className="user-info">
          <span className="user-name">柴柴老师</span>
          <span className="user-logout">
            <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
              <LogoutOutlined /> 退出
            </Popconfirm>
          </span>
        </div>
      </Header>
      <Layout>
        <Sider width={200} className="site-layout-background">
          <Menu
            mode="inline"
            theme="dark"
            defaultSelectedKeys={['1']}
            items={items}
            style={{ height: '100%', borderRight: 0 }}></Menu>
        </Sider>
        <Layout className="layout-content" style={{ padding: 20 }}>
          内容
        </Layout>
      </Layout>
    </Layout>
  )
}
export default GeekLayout

pages/Layout/index.scss

 

.ant-layout {
  height: 100%;
}

.header {
  padding: 0;
}

.logo {
  width: 200px;
  height: 60px;
  background: url('~@/assets/logo.png') no-repeat center / 160px auto;
}

.layout-content {
  overflow-y: auto;
}

.user-info {
  position: absolute;
  right: 0;
  top: 0;
  padding-right: 20px;
  color: #fff;
  
  .user-name {
    margin-right: 20px;
  }
  
  .user-logout {
    display: inline-block;
    cursor: pointer;
  }
}
.ant-layout-header {
  padding: 0 !important;
}

效果如下:

image

样式重置:样式的全局初始化

npm install normalize.css

在入口文件index.js文件中引入

import 'normalize.css'

现在来让当前视图占满整个屏幕,在入口样式文件index.scss中添加下面的代码:

html,
body {
  margin: 0;
  height: 100%;
}

#root {
  height: 100%;
}

效果如下:

image

二级路由配置

image

 

使用步骤

  1. 在 pages 目录中,分别创建:Home(数据概览)/Article(内容管理)/Publish(发布文章)页面文件夹

  2. 分别在三个文件夹中创建 index.jsx 并创建基础组件后导出

  3. 在router/index.js 中配置嵌套子路由,在Layout中配置二级路由出口

  4. 使用 Link 修改左侧菜单内容,与子路由规则匹配实现路由切换

代码实现

pages/Home/index.js

const Home = () => {
  return <div>Home</div>
}
export default Home

pages/Article/index.js

const Article = () => {
  return <div>Article</div>
}
export default Article

pages/Publish/index.js

const Publish = () => {
  return <div>Publish</div>
}
export default Publish

router/index.js

 

import { createBrowserRouter } from 'react-router-dom'

import Login from '@/pages/Login'
import Layout from '@/pages/Layout'
import AuthRoute from '@/components/AuthRoute'
import Home from '@/pages/Home'
import Article from '@/pages/Article'
import Publish from '@/pages/Publish'
const router = createBrowserRouter([
  {
    path: '/',
    element:  <AuthRoute><Layout /></AuthRoute>,
    children: [
      {
        index: true,
        element: <Home />,
      },
      {
        path: 'article',
        element: <Article />,
      },
      {
        path: 'publish',
        element: <Publish />,
      },
    ],
  },
  {
    path: '/login',
    element: <Login />,
  },
])

export default router

Layout组件中配置二级路由出口

import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet } from 'react-router-dom'
import {
  HomeOutlined,
  DiffOutlined,
  EditOutlined,
  LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'

const { Header, Sider } = Layout

const items = [
  {
    label: '首页',
    key: '1',
    icon: <HomeOutlined />,
  },
  {
    label: '文章管理',
    key: '2',
    icon: <DiffOutlined />,
  },
  {
    label: '创建文章',
    key: '3',
    icon: <EditOutlined />,
  },
]

const GeekLayout = () => {
  return (
    <Layout>
      <Header className="header">
        <div className="logo" />
        <div className="user-info">
          <span className="user-name">柴柴老师</span>
          <span className="user-logout">
            <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
              <LogoutOutlined /> 退出
            </Popconfirm>
          </span>
        </div>
      </Header>
      <Layout>
        <Sider width={200} className="site-layout-background">
          <Menu
            mode="inline"
            theme="dark"
            defaultSelectedKeys={['1']}
            items={items}
            style={{ height: '100%', borderRight: 0 }}></Menu>
        </Sider>
        <Layout className="layout-content" style={{ padding: 20 }}>
          {/* 二级路由的出口 */}
          <Outlet />
        </Layout>
      </Layout>
      
    </Layout>
  )
}
export default GeekLayout

效果如下:

image

路由菜单点击交互实现

点击菜单跳转路由

实现效果:点击左侧菜单可以跳转到对应的目标路由

思路分析
1.左侧菜单要和路由形成一一对应的关系(知道点了谁)

2.点击时拿到路由路径调用路由方法跳转(跳转到对应的路由下面)

image

import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet , useNavigate} from 'react-router-dom'
import {useState} from 'react'
import {
  HomeOutlined,
  DiffOutlined,
  EditOutlined,
  LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'

const { Header, Sider } = Layout

const items = [
  {
    label: '首页',
    key: '/',
    icon: <HomeOutlined />,
  },
  {
    label: '文章管理',
    key: '/article',
    icon: <DiffOutlined />,
  },
  {
    label: '创建文章',
    key: '/publish',
    icon: <EditOutlined />,
  },
]

const GeekLayout = () => {
  const navigate = useNavigate()
  const [selectedKey, setSelectedKey] = useState('1')  // 默认选中首页
  const menuClick = (route) => {
    navigate(route.key)
    setSelectedKey(route.key)  // 点击时更新选中项
  }
  return (
    <Layout>
      <Header className="header">
        <div className="logo" />
        <div className="user-info">
          <span className="user-name">柴柴老师</span>
          <span className="user-logout">
            <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
              <LogoutOutlined /> 退出
            </Popconfirm>
          </span>
        </div>
      </Header>
      <Layout>
        <Sider width={200} className="site-layout-background">
          <Menu
            mode="inline"
            theme="dark"
            selectedKeys={selectedKey}
            items={items}
            style={{ height: '100%', borderRight: 0 }}
            onClick={menuClick}></Menu>
        </Sider>
        <Layout className="layout-content" style={{ padding: 20 }}>
          {/* 二级路由的出口 */}
          <Outlet />
        </Layout>
      </Layout>
      
    </Layout>
  )
}
export default GeekLayout

菜单反向高亮

实现效果:页面在刷新时可以根据当前的路由路径让对应的左侧菜单高亮显示

image

思路分析

1. 获取当前url上的路由路径

2. 找到菜单组件负责高亮的属性,绑定当前的路由路径

import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet , useNavigate,useLocation} from 'react-router-dom'
import {useState} from 'react'
import {
  HomeOutlined,
  DiffOutlined,
  EditOutlined,
  LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'

const { Header, Sider } = Layout

const items = [
  {
    label: '首页',
    key: '/',
    icon: <HomeOutlined />,
  },
  {
    label: '文章管理',
    key: '/article',
    icon: <DiffOutlined />,
  },
  {
    label: '创建文章',
    key: '/publish',
    icon: <EditOutlined />,
  },
]

const GeekLayout = () => {
  const navigate = useNavigate()
  //const [selectedKey, setSelectedKey] = useState('1')  // 默认选中首页
  const menuClick = (route) => {
    navigate(route.key)
    //setSelectedKey(route.key)  // 点击时更新选中项
  }
  // 反向高亮
  // 1、获取当前路由路径
  const location = useLocation()
  console.log(location.pathname)
  const selectedKey = location.pathname
  return (
    <Layout>
      <Header className="header">
        <div className="logo" />
        <div className="user-info">
          <span className="user-name">柴柴老师</span>
          <span className="user-logout">
            <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
              <LogoutOutlined /> 退出
            </Popconfirm>
          </span>
        </div>
      </Header>
      <Layout>
        <Sider width={200} className="site-layout-background">
          <Menu
            mode="inline"
            theme="dark"
            selectedKeys={selectedKey}
            items={items}
            style={{ height: '100%', borderRight: 0 }}
            onClick={menuClick}></Menu>
        </Sider>
        <Layout className="layout-content" style={{ padding: 20 }}>
          {/* 二级路由的出口 */}
          <Outlet />
        </Layout>
      </Layout>
      
    </Layout>
  )
}
export default GeekLayout

展示个人信息

image

实现步骤

(1)、在Redux的store中编写获取用户信息的相关逻辑

const fetchUserInfo = () => {
  return async (dispatch) => {
    const res = await http.get('/user/profile')
    dispatch(setUserInfo(res.data))
  }
}

 

 

(2)、在Layout组件中触发action的执行

(3)、在Layout组件使用使用store中的数据进行用户名的渲染

代码实现

store/userStore.js

import { createSlice } from '@reduxjs/toolkit'
import { request,getToken,setToken as _setToken } from '@/utils'
const userStore = createSlice({
  name: 'user',
  // 数据状态
  initialState: {
    token:getToken() || '',
    userInfo: {}
  },
  // 同步修改方法
  reducers: {
    setToken (state, action) {
      state.token = action.payload
      // localStorage中存一份
      _setToken(action.payload)
    },
    setUserInfo (state, action) {
      state.userInfo = action.payload
    }
  }
})

// 解构出actionCreater
const { setToken, setUserInfo } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
  return async (dispatch) => {
    const res = await request.post('/authorizations', loginForm)
    console.log(res)
    dispatch(setToken(res.data.token))
  }
}

const fetchUserInfo = () => {
  return async (dispatch) => {
    const res = await request.get('/user/profile')
    console.log(res)
    dispatch(setUserInfo(res.data))
  }
}

export { fetchLogin, fetchUserInfo }

export default userReducer

pages/Layout/index.js

import { Layout, Menu, Popconfirm } from 'antd'
import { Outlet , useNavigate,useLocation} from 'react-router-dom'
import {useEffect} from 'react'
import {useSelector,useDispatch} from 'react-redux'
import {fetchUserInfo} from '@/store/modules/user'
import {
  HomeOutlined,
  DiffOutlined,
  EditOutlined,
  LogoutOutlined,
} from '@ant-design/icons'
import './index.scss'

const { Header, Sider } = Layout

const items = [
  {
    label: '首页',
    key: '/',
    icon: <HomeOutlined />,
  },
  {
    label: '文章管理',
    key: '/article',
    icon: <DiffOutlined />,
  },
  {
    label: '创建文章',
    key: '/publish',
    icon: <EditOutlined />,
  },
]

const GeekLayout = () => {
  const navigate = useNavigate()
  //const [selectedKey, setSelectedKey] = useState('1')  // 默认选中首页
  const menuClick = (route) => {
    navigate(route.key)
    //setSelectedKey(route.key)  // 点击时更新选中项
  }
  // 反向高亮
  // 1、获取当前路由路径
  const location = useLocation()
  console.log(location.pathname)
  const selectedKey = location.pathname
  // React组件中修改store中的数据需要借助useDispatch
  const dispatch = useDispatch()
  
  useEffect(() => { // 渲染完毕后获取后台数据
      dispatch(fetchUserInfo())
  }, []) // 空数组表示执行一次
  // useSelector的作用是把store中的数据映射到组件中
  const name = useSelector(state => state.user.userInfo.name)
  return (
    <Layout>
      <Header className="header">
        <div className="logo" />
        <div className="user-info">
          <span className="user-name">{name}</span>
          <span className="user-logout">
            <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消">
              <LogoutOutlined /> 退出
            </Popconfirm>
          </span>
        </div>
      </Header>
      <Layout>
        <Sider width={200} className="site-layout-background">
          <Menu
            mode="inline"
            theme="dark"
            selectedKeys={selectedKey}
            items={items}
            style={{ height: '100%', borderRight: 0 }}
            onClick={menuClick}></Menu>
        </Sider>
        <Layout className="layout-content" style={{ padding: 20 }}>
          {/* 二级路由的出口 */}
          <Outlet />
        </Layout>
      </Layout>
      
    </Layout>
  )
}
export default GeekLayout

退出登录实现

实现步骤

(1)、为气泡确认框添加确认回调事件

<span className="user-logout">
            <Popconfirm title="是否确认退出?" okText="退出" cancelText="取消" onConfirm={loginOut}>
              <LogoutOutlined /> 退出
            </Popconfirm>
          </span>

(2)、在store/userStore.js 中新增退出登录的action函数,在其中删除token

import { createSlice } from '@reduxjs/toolkit'
import { request,getToken,setToken as _setToken,removeToken } from '@/utils'
const userStore = createSlice({
  name: 'user',
  // 数据状态
  initialState: {
    token:getToken() || '',
    userInfo: {}
  },
  // 同步修改方法
  reducers: {
    setToken (state, action) {
      state.token = action.payload
      // localStorage中存一份
      _setToken(action.payload)
    },
    setUserInfo (state, action) {
      state.userInfo = action.payload
    },
    clearUserInfo (state) {
      state.token = ''
      state.userInfo = {}
      removeToken() // 删除localStorage中的token
    }
  }
})

// 解构出actionCreater
const { setToken, setUserInfo, clearUserInfo } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
  return async (dispatch) => {
    const res = await request.post('/authorizations', loginForm)
    console.log(res)
    dispatch(setToken(res.data.token))
  }
}

const fetchUserInfo = () => {
  return async (dispatch) => {
    const res = await request.get('/user/profile')
    console.log(res)
    dispatch(setUserInfo(res.data))
  }
}

export { fetchLogin, fetchUserInfo, clearUserInfo }

export default userReducer

(3)、在回调事件中,调用userStore中的退出action

(4)、清除用户信息,返回登录页面

// 退出登录
  const loginOut = () => {
    dispatch(clearUserInfo())
    navigate('/login')
  }

处理Token失效

业务背景:如果用户一段时间不做任何操作,到时之后应该清除所有过期用户信息跳回到登录

什么是Token失效?

为了用户的安全和隐私考虑,在用户长时间未在网站中做任何操作且规定的失效时间到达之后,当前的Token就会失效,一旦失效,不能再作为用户令牌标识请求隐私数据

前端如何知道Token已经失效了?

通常在Token失效之后再去请求接口,后端会返回401状态码,前端可以监控这个状态做后续的操作

Token失效了前端做什么?

image

// 添加响应拦截器
request.interceptors.response.use((response)=> {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response.data
  }, (error)=> {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    console.dir(error)
    if (error.response.status === 401) {
      removeToken()
      router.navigate('/login')
      window.location.reload()
    }
    return Promise.reject(error)
})

image

十、首页Home图表展示

image

图表基础Demo实现

image

点击“快速入门”

image

 点击“基础柱状图”

image

 

图表类业务渲染,我们可以通过下面的顺序来实现

(1)、跑通基础DEMO

(2)、按照实际业务需求进行修改

注意:

1. 图表的初始化在useEffect中完成,在useEffect中初始化,保证dom节点是可用的

2. 挂载渲染的节点需要有宽高

 安装echarts

npm i echarts

实现基础Demo

import * as echarts from 'echarts';
import {useState,useEffect} from 'react'

const Home = () => {
  useEffect(() => { // 渲染完毕后获取后台数据
      // 保证dom可用后再渲染
      let chartDom = document.getElementById('main');
      let myChart = echarts.init(chartDom);
      let option;

      option = {
        xAxis: {
          type: 'category',
          data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
        },
        yAxis: {
          type: 'value'
        },
        series: [
          {
            data: [120, 200, 150, 80, 70, 110, 130],
            type: 'bar'
          }
        ]
      };

option && myChart.setOption(option);
  }, []) // 空数组表示执行一次
  return <div>
    <div id="main" style={{ width: 400, height: 400 }}></div>
  </div>
}
export default Home

效果:

image

上面的代码中,我们用的是原生的dom操作,如下所示:

let chartDom = document.getElementById('main');

我们可以用React中的方式进行dom操作

import * as echarts from 'echarts';
import {useState,useEffect,useRef} from 'react'

const Home = () => {
  const chartRef = useRef(null)
  useEffect(() => { // 渲染完毕后获取后台数据
      // 保证dom可用后再渲染
      // let chartDom = document.getElementById('main');
      const chartDom = chartRef.current
      let myChart = echarts.init(chartDom);
      let option;

      option = {
        xAxis: {
          type: 'category',
          data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
        },
        yAxis: {
          type: 'value'
        },
        series: [
          {
            data: [120, 200, 150, 80, 70, 110, 130],
            type: 'bar'
          }
        ]
      };

option && myChart.setOption(option);
  }, []) // 空数组表示执行一次
  return <div>
    <div ref={chartRef} style={{ width: 500, height: 400 }}></div>
  </div>
}
export default Home

效果如下:

image

按照实际业务需求进行修改

import * as echarts from 'echarts';
import {useState,useEffect,useRef} from 'react'

const Home = () => {
  const chartRef = useRef(null)
  useEffect(() => { // 渲染完毕后获取后台数据
      // 保证dom可用后再渲染
      // let chartDom = document.getElementById('main');
      const chartDom = chartRef.current
      let myChart = echarts.init(chartDom);
      let option;

      option = {
        xAxis: {
          type: 'category',
          data: ['Vue', 'React', 'Angular']
        },
        yAxis: {
          type: 'value'
        },
        series: [
          {
            data: [10, 40, 70],
            type: 'bar'
          }
        ]
      };

option && myChart.setOption(option);
  }, []) // 空数组表示执行一次
  return <div>
    <div ref={chartRef} style={{ width: 500, height: 400 }}></div>
  </div>
}
export default Home

为什么要做组件封装?

组件封装主要解决了复用的问题

image

以上俩个图表除了Title不一样之外,其余部分都一样,把不一样的部分抽象成props参数做适配

image

在Home目录下新增components目录,这个组件只为Home来服务。在components目录下新增BarChart.js

BarChart.js

import * as echarts from 'echarts';
import {useState,useEffect,useRef} from 'react'

const BarChart = () => {
    const chartRef = useRef(null)
    useEffect(() => { // 渲染完毕后获取后台数据
        // 保证dom可用后再渲染
        // let chartDom = document.getElementById('main');
        const chartDom = chartRef.current
        let myChart = echarts.init(chartDom);
        let option;

        option = {
        title: {
            text: '三大框架满意度'
        },
        xAxis: {
            type: 'category',
            data: ['Vue', 'React', 'Angular']
        },
        yAxis: {
            type: 'value'
        },
        series: [
            {
            data: [10, 40, 30],
            type: 'bar'
            }
        ]
    };

    option && myChart.setOption(option);
    }, []) // 空数组表示执行一次
  return <div>
    <div ref={chartRef} style={{ width: 500, height: 400 }}></div>
  </div>
};

export default BarChart

Home/index.js

import BarChart from './components/BarChart'
const Home = () => {

    return <div>
      <BarChart/>
    </div>
}
export default Home

效果:

image

抽象可变参数

把可变部分抽象成prop参数

BarChart.js

import * as echarts from 'echarts';
import {useState,useEffect,useRef} from 'react'

const BarChart = ({title,xData, sData, style = { width: '400px', height: '300px' }}) => {
    const chartRef = useRef(null)
    useEffect(() => { // 渲染完毕后获取后台数据
        // 保证dom可用后再渲染
        // let chartDom = document.getElementById('main');
        const chartDom = chartRef.current
        let myChart = echarts.init(chartDom);
        let option;

        option = {
        title: {
            text: title
        },
        xAxis: {
            type: 'category',
            data: xData
        },
        yAxis: {
            type: 'value'
        },
        series: [
            {
            data: sData,
            type: 'bar'
            }
        ]
    };

    option && myChart.setOption(option);
    }, []) // 空数组表示执行一次
  return <div>
    <div ref={chartRef} style={{ width: 500, height: 400 }}></div>
  </div>
};

export default BarChart

Home/index.js

import BarChart from './components/BarChart'
const Home = () => {

    return <div>
      <BarChart title={"三大框架满意度调查"} xData={['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']} sData={[120, 200, 150, 80, 70, 110, 130]}/>
    <BarChart title={"三大框架使用度调查"}  xData={['Vue', 'React', 'Angular']} sData={[10, 40, 30]}/>
    </div>
}
export default Home

效果:

image

API模块封装

现存问题

当前的接口请求放到了功能实现的位置,没有在固定的模块内维护,后期查找维护困难

解决思路

把项目中的所有接口按照业务模块以函数的形式统一封装到apis模块中

image

在apis目录下新增user.js

import {request} from '@/utils'
export function loginApi(formData){
    return request({
        url: '/authorizations',
        method: 'POST',
        data: formData
    })
}

export function getProfileApi(){
    return request({
        url: '/user/profile',
        method: 'GET'
    })
}

修改store/modules/user.js

import { createSlice } from '@reduxjs/toolkit'
import { loginApi,getProfileApi} from '@/apis/user'
import { request,getToken,setToken as _setToken,removeToken } from '@/utils'
const userStore = createSlice({
  name: 'user',
  // 数据状态
  initialState: {
    token:getToken() || '',
    userInfo: {}
  },
  // 同步修改方法
  reducers: {
    setToken (state, action) {
      state.token = action.payload
      // localStorage中存一份
      _setToken(action.payload)
    },
    setUserInfo (state, action) {
      state.userInfo = action.payload
    },
    clearUserInfo (state) {
      state.token = ''
      state.userInfo = {}
      removeToken() // 删除localStorage中的token
    }
  }
})

// 解构出actionCreater
const { setToken, setUserInfo, clearUserInfo } = userStore.actions

// 获取reducer函数
const userReducer = userStore.reducer

// 异步方法封装
const fetchLogin = (loginForm) => {
  return async (dispatch) => {
    const res = await loginApi(loginForm)
    console.log(res)
    dispatch(setToken(res.data.token))
  }
}

const fetchUserInfo = () => {
  return async (dispatch) => {
    const res = await getProfileApi()
    console.log(res)
    dispatch(setUserInfo(res.data))
  }
}

export { fetchLogin, fetchUserInfo, clearUserInfo }

export default userReducer

十一、发布文章模块

功能演示说明

image

基础文章发布-创建并熟悉基础结构

image

1. 面包屑导航组件 Breadcrumb

2. 表单组件 Form

3. 输入框组件 Input

4. 下拉框组件 Select - Option

5. 按钮组件 Button

Publish/index.js

import {
  Card,
  Breadcrumb,
  Form,
  Button,
  Radio,
  Input,
  Upload,
  Space,
  Select
} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import './index.scss'

const { Option } = Select

const Publish = () => {
  return (
    <div className="publish">
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '发布文章' },
          ]}
          />
        }
      >
        <Form
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ type: 1 }}
        >
          <Form.Item
            label="标题"
            name="title"
            rules={[{ required: true, message: '请输入文章标题' }]}
          >
            <Input placeholder="请输入文章标题" style={{ width: 400 }} />
          </Form.Item>
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: '请选择文章频道' }]}
          >
            <Select placeholder="请选择文章频道" style={{ width: 400 }}>
              <Option value={0}>推荐</Option>
            </Select>
          </Form.Item>
          <Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: '请输入文章内容' }]}
          ></Form.Item>

          <Form.Item wrapperCol={{ offset: 4 }}>
            <Space>
              <Button size="large" type="primary" htmlType="submit">
                发布文章
              </Button>
            </Space>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Publish

Publish/index.scss

.publish {
  position: relative;
}

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

效果如下:

image

准备富文本编辑器

实现步骤

(1)、安装富文本编辑器

npm install react-quill-new

(2)、导入富文本编辑器组件以及样式文件

import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';

(3)、渲染富文本编辑器组件

<Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: '请输入文章内容' }]}
          >
            <ReactQuill
              className="publish-quill"
              theme="snow"
              placeholder="请输入文章内容"
            />
          </Form.Item>

(4)、调整富文本编辑器的样式

.publish-quill {
  .ql-editor {
    min-height: 300px;
  }
}

效果如下:

image

频道数据获取

image

实现步骤

(1)、使用useState初始化数据和修改数据的方法

(2)、在useEffect中调用接口并保存数据

(3)、使用数据渲染对应模版

代码实现

 

import {
  Card,
  Breadcrumb,
  Form,
  Button,
  Radio,
  Input,
  Upload,
  Space,
  Select
} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import './index.scss'
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import {useState,useEffect} from 'react'
import {request} from '@/utils'

const { Option } = Select

const Publish = () => {
  // 频道列表
  const [channels, setChannels] = useState([])
    // 调用接口
  useEffect(() => {
      async function fetchChannels() {
        const res = await request.get('/channels')
        setChannels(res.data.channels)
      }
      fetchChannels()
  }, [])
  return (
    <div className="publish">
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '发布文章' },
          ]}
          />
        }
      >
        <Form
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ type: 1 }}
        >
          <Form.Item
            label="标题"
            name="title"
            rules={[{ required: true, message: '请输入文章标题' }]}
          >
            <Input placeholder="请输入文章标题" style={{ width: 400 }} />
          </Form.Item>
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: '请选择文章频道' }]}
          >
            <Select placeholder="请选择文章频道" style={{ width: 400 }}>
              {channels.map(item => (
                <Option key={item.id} value={item.id}>
                  {item.name}
                </Option>
              ))}
            </Select>
          </Form.Item>
          <Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: '请输入文章内容' }]}
          >
            <ReactQuill
              className="publish-quill"
              theme="snow"
              placeholder="请输入文章内容"
            />
          </Form.Item>

          <Form.Item wrapperCol={{ offset: 4 }}>
            <Space>
              <Button size="large" type="primary" htmlType="submit">
                发布文章
              </Button>
            </Space>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Publish

发布文章

 

 

import {
  Card,
  Breadcrumb,
  Form,
  Button,
  Radio,
  Input,
  Upload,
  Space,
  Select
} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import './index.scss'
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import {useState,useEffect} from 'react'
import {request} from '@/utils'
import { message } from 'antd'

const { Option } = Select

const Publish = () => {
  // 频道列表
  const [channels, setChannels] = useState([])
    // 调用接口
  useEffect(() => {
      async function fetchChannels() {
        const res = await request.get('/channels')
        setChannels(res.data.channels)
      }
      fetchChannels()
  }, [])
  // 发布文章
const onFinish = async (formValue) => {
  const { channel_id, content, title } = formValue
  const params = {
    channel_id,
    content,
    title,
    type: 1,
    cover: {
      type: 1,
      images: []
    }
  }
  await request.post('/mp/articles?draft=false', params)
  message.success('发布文章成功')
}
  return (
    <div className="publish">
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '发布文章' },
          ]}
          />
        }
      >
        <Form
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ type: 1 }}
           onFinish={ onFinish }
        >
          <Form.Item
            label="标题"
            name="title"
            rules={[{ required: true, message: '请输入文章标题' }]}
          >
            <Input placeholder="请输入文章标题" style={{ width: 400 }} />
          </Form.Item>
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: '请选择文章频道' }]}
          >
            <Select placeholder="请选择文章频道" style={{ width: 400 }}>
              {channels.map(item => (
                <Option key={item.id} value={item.id}>
                  {item.name}
                </Option>
              ))}
            </Select>
          </Form.Item>
          <Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: '请输入文章内容' }]}
          >
            <ReactQuill
              className="publish-quill"
              theme="snow"
              placeholder="请输入文章内容"
            />
          </Form.Item>

          <Form.Item wrapperCol={{ offset: 4 }}>
            <Space>
              <Button size="large" type="primary" htmlType="submit">
                发布文章
              </Button>
            </Space>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Publish

上传封面实现

准备上传结构

image

<Form.Item label="封面">
  <Form.Item name="type">
    <Radio.Group>
      <Radio value={1}>单图</Radio>
      <Radio value={3}>三图</Radio>
      <Radio value={0}>无图</Radio>
    </Radio.Group>
  </Form.Item>
  <Upload
    listType="picture-card"
    showUploadList
  >
    <div style={{ marginTop: 8 }}>
      <PlusOutlined />
    </div>
  </Upload>
</Form.Item>

实现基础上传

实现步骤

(1)、为 Upload 组件添加 action 属性,配置封面图片上传接口地址

(2)、为 Upload组件添加 name属性, 接口要求的字段名

(3)、为 Upload 添加 onChange 属性,在事件中拿到当前图片数据,并存储到React状态中

// 上传图片
  const [imageList, setImageList] = useState([])
  const onUploadChange = (info) => {
      setImageList(info.fileList)
  }

代码实现

import {
  Card,
  Breadcrumb,
  Form,
  Button,
  Radio,
  Input,
  Upload,
  Space,
  Select
} from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import './index.scss'
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import {useState,useEffect} from 'react'
import {request} from '@/utils'
import { message } from 'antd'

const { Option } = Select

const Publish = () => {
  // 频道列表
  const [channels, setChannels] = useState([])
    // 调用接口
  useEffect(() => {
      async function fetchChannels() {
        const res = await request.get('/channels')
        setChannels(res.data.channels)
      }
      fetchChannels()
  }, [])
  // 发布文章
  const onFinish = async (formValue) => {
    const { channel_id, content, title } = formValue
    const params = {
      channel_id,
      content,
      title,
      type: 1,
      cover: {
        type: 1,
        images: []
      }
    }
    await request.post('/mp/articles?draft=false', params)
    message.success('发布文章成功')
  }
  // 上传图片
  const [imageList, setImageList] = useState([])
  const onUploadChange = (info) => {
      setImageList(info.fileList)
  }

  return (
    <div className="publish">
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '发布文章' },
          ]}
          />
        }
      >
        <Form
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ type: 1 }}
           onFinish={ onFinish }
        >
          <Form.Item
            label="标题"
            name="title"
            rules={[{ required: true, message: '请输入文章标题' }]}
          >
            <Input placeholder="请输入文章标题" style={{ width: 400 }} />
          </Form.Item>
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: '请选择文章频道' }]}
          >
            <Select placeholder="请选择文章频道" style={{ width: 400 }}>
              {channels.map(item => (
                <Option key={item.id} value={item.id}>
                  {item.name}
                </Option>
              ))}
            </Select>
          </Form.Item>
          <Form.Item label="封面">
          <Form.Item name="type">
            <Radio.Group>
              <Radio value={1}>单图</Radio>
              <Radio value={3}>三图</Radio>
              <Radio value={0}>无图</Radio>
            </Radio.Group>
          </Form.Item>
          <Upload
            name="image"
            listType="picture-card"
            showUploadList
            action={'http://geek.itheima.net/v1_0/upload'}
            onChange={onUploadChange}
          >
            <div style={{ marginTop: 8 }}>
              <PlusOutlined />
            </div>
          </Upload>
        </Form.Item>
          <Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: '请输入文章内容' }]}
          >
            <ReactQuill
              className="publish-quill"
              theme="snow"
              placeholder="请输入文章内容"
            />
          </Form.Item>

          <Form.Item wrapperCol={{ offset: 4 }}>
            <Space>
              <Button size="large" type="primary" htmlType="submit">
                发布文章
              </Button>
            </Space>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Publish

切换图片Type

实现效果:只有当前模式为单图或者三图模式时才显示上传组件

image

实现步骤

(1)、点击单选框时拿到当前的类型value

<Form.Item name="type">
            <Radio.Group onChange={onTypeChange}>
              <Radio value={1}>单图</Radio>
              <Radio value={3}>三图</Radio>
              <Radio value={0}>无图</Radio>
            </Radio.Group>
          </Form.Item>

(2)、根据value控制上传组件的显示(大于零时才显示)

// 控制图片Type
  const [imageType, setImageType] = useState(0)

  const onTypeChange = (e) => {
    console.log(e)
    setImageType(e.target.value)
  }

{imageType > 0 &&<Upload
            name="image"
            listType="picture-card"
            showUploadList
            action={'http://geek.itheima.net/v1_0/upload'}
            onChange={onUploadChange}
          >
            <div style={{ marginTop: 8 }}>
              <PlusOutlined />
            </div>
          </Upload>}

控制最大上传图片数量

实现步骤

(1)、通过 maxCount 属性限制图片的上传图片数量

{imageType > 0 &&<Upload
            name="image"
            listType="picture-card"
            showUploadList
            action={'http://geek.itheima.net/v1_0/upload'}
            onChange={onUploadChange}
            maxCount={imageType}
            multiple={imageType > 1}
          >
            <div style={{ marginTop: 8 }}>
              <PlusOutlined />
            </div>
          </Upload>}

multiple 是布尔值,为 true 时,文件选择框允许按住 Ctrl/Shift 一次选中多个文件;为 false(或不写)时,一次只能选一个。

暂存图片列表实现

业务描述 如果当前为三图模式,已经完成了上传,选择单图只显示一张,再切换到三图继续显示三张,该如何实现?

实现思路 在上传完毕之后通过ref存储所有图片,需要几张就显示几张,其实也就是把ref当仓库,用多少拿多少

实现步骤

(1)、通过useRef创建一个暂存仓库,在上传完毕图片的时候把图片列表存入

const cacheImageList = useRef([])
  const [imageList, setImageList] = useState([])
  const onUploadChange = (info) => {
      setImageList(info.fileList)
      cacheImageList.current = info.fileList
  }

这段代码是 React 中管理图片上传列表的典型写法,同时用到了 useState 和 useRef 来保存同一份数据。

为什么同一份数据要存两份?

image

核心原因:setState 是异步的,且有闭包陷阱。

场景 1:提交表单时想拿“最新的图片列表”

如果只用 state,在某个回调(比如点击“发布”)里直接读 imageList,可能读到的是旧值(闭包捕获的是那次渲染时的变量),尤其是在依赖数组没写全、或异步操作里。

而 cacheImageList.current 永远指向最新的 info.fileList,因为它不经过 state 的异步更新流程,读到的一定是当前值

const handleSubmit = () => {
    // 安全地拿到最新图片列表
    const images = cacheImageList.current
    // 提交给后端...
}

场景 2:避免闭包陷阱

useEffect(() => {
    // 这里如果直接读 imageList,依赖没写对就是旧值
    // 读 cacheImageList.current 则始终是最新
    console.log(cacheImageList.current)
}, [])

(2)、如果是单图模式,就从仓库里取第一张图,以数组的形式存入fileList

(3)、如果是三图模式,就把仓库里所有的图片,以数组的形式存入fileList

const onTypeChange = (e) => {
    const type = e.target.value
    setImageType(type)
    if (type === 1) {
      // 单图,截取第一张展示
      const imgList = cacheImageList.current[0] ? [cacheImageList.current[0]] : []
      setImageList(imgList)
    } else if (type === 3) {
      // 三图,取所有图片展示
      setImageList(cacheImageList.current)
    }
  }
  • setImageList(imgList) 改了 state。

  • 但 UI 要跟着变,就必须让 Upload 的 fileList 绑定这个 state。

  • 不传 fileList 的话,Upload 只认自己内部的列表,setImageList 改了也没用,界面不会变。

代码实现

import {useState,useEffect,useRef} from 'react'....

const Publish = () => {// 上传图片
  const cacheImageList = useRef([]) // 创建一个 ref,初始值为空数组 []。
  const [imageList, setImageList] = useState([])
  const onUploadChange = (info) => {
      setImageList(info.fileList)  // 更新 state → 触发渲染 → UI 更新
      // ref.current 用来跨渲染保存数据,修改它不会触发组件重新渲染
      cacheImageList.current = info.fileList  // 同步更新 ref → 静默保存,不触发渲染
  }
  // 控制图片Type
  const [imageType, setImageType] = useState(0)

  const onTypeChange = (e) => {
    const type = e.target.value
    setImageType(type)
    if (type === 1) {
      // 单图,截取第一张展示
      const imgList = cacheImageList.current[0] ? [cacheImageList.current[0]] : []
      setImageList(imgList)
    } else if (type === 3) {
      // 三图,取所有图片展示
      setImageList(cacheImageList.current)
    }
  }
  return (
    <div className="publish">
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '发布文章' },
          ]}
          />
        }
      >
        <Form
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ type: 1 }}
           onFinish={ onFinish }
        >
          <Form.Item
            label="标题"
            name="title"
            rules={[{ required: true, message: '请输入文章标题' }]}
          >
            <Input placeholder="请输入文章标题" style={{ width: 400 }} />
          </Form.Item>
          <Form.Item
            label="频道"
            name="channel_id"
            rules={[{ required: true, message: '请选择文章频道' }]}
          >
            <Select placeholder="请选择文章频道" style={{ width: 400 }}>
              {channels.map(item => (
                <Option key={item.id} value={item.id}>
                  {item.name}
                </Option>
              ))}
            </Select>
          </Form.Item>
          <Form.Item label="封面">
          <Form.Item name="type">
            <Radio.Group onChange={onTypeChange}>
              <Radio value={1}>单图</Radio>
              <Radio value={3}>三图</Radio>
              <Radio value={0}>无图</Radio>
            </Radio.Group>
          </Form.Item>
          {imageType > 0 &&<Upload
            name="image"
            listType="picture-card"
            showUploadList
            action={'http://geek.itheima.net/v1_0/upload'}
            onChange={onUploadChange}
            maxCount={imageType}
            multiple={imageType > 1}
            fileList={imageList}
          >
            <div style={{ marginTop: 8 }}>
              <PlusOutlined />
            </div>
          </Upload>}
        </Form.Item>
          <Form.Item
            label="内容"
            name="content"
            rules={[{ required: true, message: '请输入文章内容' }]}
          >
            <ReactQuill
              className="publish-quill"
              theme="snow"
              placeholder="请输入文章内容"
            />
          </Form.Item>

          <Form.Item wrapperCol={{ offset: 4 }}>
            <Space>
              <Button size="large" type="primary" htmlType="submit">
                发布文章
              </Button>
            </Space>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Publish

注意:需要给Upload组件添加fileList属性,达成受控的目的

这里传入了 imageList,意味着:Upload 显示什么文件,完全由 imageList 决定,组件自己不再私自维护列表。

fileList={imageList}

受控组件的标准闭环是:

<Upload
  fileList={imageList}          // 1. 用 state 决定显示
  onChange={onUploadChange}     // 2. 用户操作后回调
/>

const onUploadChange = (info) => {
  setImageList(info.fileList)   // 3. 用最新列表更新 state
  cacheImageList.current = info.fileList
}

流程:

  1. 用户选文件 / 删文件 → 触发 onChange。

  2. onChange 里把最新的 info.fileList 存进 state。

  3. state 变化 → 组件重新渲染 → fileList={imageList} 传入新值 → UI 更新。

这就是单向数据流:onChange 改 state → state 驱动 fileList → fileList 驱动 UI。

上传图片解决跨域问题

<Form.Item label="封面">
          <Form.Item name="type">
            <Radio.Group onChange={onTypeChange}>
              <Radio value={1}>单图</Radio>
              <Radio value={3}>三图</Radio>
              <Radio value={0}>无图</Radio>
            </Radio.Group>
          </Form.Item>
          {imageType > 0 &&<Upload
            name="image"
            listType="picture-card"
            showUploadList
            action={'/api/v1_0/upload'}
            onChange={onUploadChange}
            maxCount={imageType}
            multiple={imageType > 1}
            fileList={imageList}
          >
            <div style={{ marginTop: 8 }}>
              <PlusOutlined />
            </div>
          </Upload>}
        </Form.Item>

发布带封面的文章

校验图片类型和数量是否吻合

// 发布文章
  const onFinish = async (formValue) => {
    if (imageType !== imageList.length) return message.warning('图片类型和数量不一致')
    const { channel_id, content, title } = formValue
    const params = {
      channel_id,
      content,
      title,
      type: imageType,
      cover: {
        type: imageType,
        images: imageList.map(item => item.response.data.url)
      }
    }
    await request.post('/mp/articles?draft=false', params)
    message.success('发布文章成功')
  }

十二、文章列表模块

静态结构创建

筛选区结构搭建

image

(1)、如何让RangePicker日期范围选择框选择中文

(2)、Select组件配合Form.Item使用时,如何配置默认选中项

<Form initialValues={{ status: null }} >` 

Article/index.js

import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'

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

const Article = () => {
  return (
    <div>
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '文章列表' },
          ]} />
        }
        style={{ marginBottom: 20 }}
      >
        <Form initialValues={{ status: '' }}>
          <Form.Item label="状态" name="status">
            <Radio.Group>
              <Radio value={''}>全部</Radio>
              <Radio value={0}>草稿</Radio>
              <Radio value={2}>审核通过</Radio>
            </Radio.Group>
          </Form.Item>

          <Form.Item label="频道" name="channel_id">
            <Select
              placeholder="请选择文章频道"
              defaultValue="lucy"
              style={{ width: 120 }}
            >
              <Option value="jack">Jack</Option>
              <Option value="lucy">Lucy</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: 40 }}>
              筛选
            </Button>
          </Form.Item>
        </Form>
      </Card>
    </div>
  )
}

export default Article

效果:

image

表格区域结构

image

代码实现

import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'
// 导入资源
import { Table, Tag, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'

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

const Article = () => {
  // 准备列数据
  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',
      render: data => <Tag color="green">审核通过</Tag>
    },
    {
      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 />} />
            <Button
              type="primary"
              danger
              shape="circle"
              icon={<DeleteOutlined />}
            />
          </Space>
        )
      }
    }
  ]
  // 准备表格body数据
  const data = [
    {
      id: '8218',
      comment_count: 0,
      cover: {
        images: [],
      },
      like_count: 0,
      pubdate: '2019-03-11 09:00:00',
      read_count: 2,
      status: 2,
      title: 'wkwebview离线化加载h5资源解决方案'
    }
  ]
  return (
    <div>
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '文章列表' },
          ]} />
        }
        style={{ marginBottom: 20 }}
      >
        <Form initialValues={{ status: '' }}>
          <Form.Item label="状态" name="status">
            <Radio.Group>
              <Radio value={''}>全部</Radio>
              <Radio value={0}>草稿</Radio>
              <Radio value={2}>审核通过</Radio>
            </Radio.Group>
          </Form.Item>

          <Form.Item label="频道" name="channel_id">
            <Select
              placeholder="请选择文章频道"
              defaultValue="lucy"
              style={{ width: 120 }}
            >
              <Option value="jack">Jack</Option>
              <Option value="lucy">Lucy</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: 40 }}>
              筛选
            </Button>
          </Form.Item>
        </Form>
      </Card>
      {/*        */}
      <Card title={`根据筛选条件共查询到 count 条结果:`}>
        <Table rowKey="id" columns={columns} dataSource={data} />
      </Card>
    </div>
  )
}

export default Article

效果如下:

image

渲染频道数据

实现步骤

(1)、使用axios获取数据

// 频道列表
  const [channels, setChannels] = useState([])
    // 调用接口
  useEffect(() => {
      async function fetchChannels() {
        const res = await request.get('/channels')
        setChannels(res.data.channels)
      }
      fetchChannels()
  }, [])

(2)、将使用频道数据列表改写下拉框组件

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

渲染表格数据

实现步骤

(1)、声明列表相关数据管理

// 文章列表数据管理
  const [article, setArticleList] = useState({
      list: [],
      count: 0
  })

(2)、使用useState声明参数相关数据管理

const [params, setParams] = useState({
    page: 1,
    per_page: 4,
    begin_pubdate: null,
    end_pubdate: null,
    status: null,
    channel_id: null
  })

(3)、调用接口获取数据

useEffect(() => {
    async function fetchArticleList () {
      const res = await request.get('/mp/articles', { params })
      const { results, total_count } = res.data
      setArticleList({
        list: results,
        count: total_count
      })
    }
    fetchArticleList()
  }, [params])

(4)、使用接口数据渲染模板

{/*        */}
      <Card title={`根据筛选条件共查询到  ${article.count}  条结果:`}>
        <Table rowKey="id" columns={columns} dataSource={article.list} />
      </Card>

筛选功能实现

实现步骤

(1)、为表单添加onFinish属性监听表单提交事件,获取参数

(2)、根据接口字段格式要求格式化参数格式

(3)、修改params 参数并重新使用新参数重新请求数据

代码实现

import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'
// 导入资源
import { Table, Tag, Space } from 'antd'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'
import {useState,useEffect} from 'react'
import {request} from '@/utils'
import {getArticleListApi} from '@/apis/article'

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

const Article = () => {
  // 准备列数据
  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',
      render: data => <Tag color="green">审核通过</Tag>
    },
    {
      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 />} />
            <Button
              type="primary"
              danger
              shape="circle"
              icon={<DeleteOutlined />}
            />
          </Space>
        )
      }
    }
  ]
  // 准备表格body数据
  const data = [
    {
      id: '8218',
      comment_count: 0,
      cover: {
        images: [],
      },
      like_count: 0,
      pubdate: '2019-03-11 09:00:00',
      read_count: 2,
      status: 2,
      title: 'wkwebview离线化加载h5资源解决方案'
    }
  ]
  // 频道列表
  const [channels, setChannels] = useState([])
    // 调用接口
  useEffect(() => {
      async function fetchChannels() {
        const res = await request.get('/channels')
        setChannels(res.data.channels)
      }
      fetchChannels()
  }, [])
  // 文章列表数据管理
  // const [article, setArticleList] = useState({
  //     list: [],
  //     count: 0
  // })
  const [list, setList] = useState([])
  const [count, setCount] = useState(0)
  const [params, setParams] = useState({
    page: 1,
    per_page: 4,
    begin_pubdate: null,
    end_pubdate: null,
    status: null,
    channel_id: null
  })
  async function getList (reqData = {}) {
      const res = await getArticleListApi(reqData)
      // const { results, total_count } = res.data
      // setArticleList({
      //   list: results,
      //   count: total_count
      // })
      console.log(res)
      setList(res.data.results)
      setCount(res.data.total_count)
    }
  useEffect(() => {
    getList()
  }, [])
  const onFinish = async formValue => {
        console.log(formValue)
        // 1. 准备参数
        const { channel_id, date, status } = formValue
        const reqData = {
          status,
          channel_id,
          begin_pubdate: date?.[0]?.format('YYYY-MM-DD') || undefined,
          end_pubdate: date?.[1]?.format('YYYY-MM-DD') || undefined,
        }
        console.log(reqData)
        // 2. 使用参数获取新的列表
        getList(reqData)
    }
  return (
    <div>
      <Card
        title={
          <Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: '文章列表' },
          ]} />
        }
        style={{ marginBottom: 20 }}
      >
        <Form initialValues={{ status: '' }}  onFinish={ onFinish }>
          <Form.Item label="状态" name="status">
            <Radio.Group>
              <Radio value={''}>全部</Radio>
              <Radio value={0}>草稿</Radio>
              <Radio value={2}>审核通过</Radio>
            </Radio.Group>
          </Form.Item>

          <Form.Item label="频道" name="channel_id">
            <Select
              placeholder="请选择文章频道"
              style={{ width: 120 }}
            >
              {channels.map(item => (
                <Option key={item.id} value={item.id}>
                  {item.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: 40 }}>
              筛选
            </Button>
          </Form.Item>
        </Form>
      </Card>
      {/*        */}
      <Card title={`根据筛选条件共查询到  ${count}  条结果:`}>
        <Table rowKey="id" columns={columns} dataSource={list} />
      </Card>
    </div>
  )
}

export default Article

分页功能实现

实现步骤

(1)、为Table组件指定pagination属性来展示分页效果

{/*        */}
      <Card title={`根据筛选条件共查询到  ${count}  条结果:`}>
        <Table rowKey="id" columns={columns} dataSource={list} pagination={{
          current: params.page,
          pageSize: params.per_page,
          onChange: pageChange,
          total: count
        }} />
      </Card>

(2)、在分页切换事件中获取到筛选表单中选中的数据

(3)、使用当前页数据修改params参数依赖引起接口重新调用获取最新数据

const pageChange = (page) => {
    // 拿到当前页参数 修改params 引起接口更新
    setParams({
      ...params,
      page
    })
  }

删除功能

实现步骤

(1)、给删除文章按钮绑定点击事件

(2)、弹出确认窗口,询问用户是否确定删除文章

先引入Popconfirm

import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select,Popconfirm } from 'antd'

使用Popconfirm

<Popconfirm
              title="确认删除该条文章吗?"
              onConfirm={() => delArticle(data)}
              okText="确认"
              cancelText="取消"
            >
              <Button
                type="primary"
                danger
                shape="circle"
                icon={<DeleteOutlined />}
              />
            </Popconfirm>

(3)、拿到参数调用删除接口,更新列表

article.js

// 删除文章
export function delArticleApi(id) {
  return request({
    url: `/mp/articles/${id}`,
    method: 'DELETE'
  })
}

article/index.js

async function delArticle (data) {
      await delArticleApi(data.id)
      // 更新列表
      setParams({
        page: 1,
        per_page: 2
      })
    }

编辑文章跳转

<Button type="primary" shape="circle" icon={<EditOutlined />} onClick={() => navigate(`/publish?id=${data.id}`)} />

十三、编辑文章

基础数据回填

image

(1)、调用接口获取后台数据

// 读写 URL 查询参数
  const [searchParams] = useSearchParams()
  const articleId = searchParams.get('id')
  const [form] = Form.useForm()
  useEffect(() => {
    async function getArticle () {
      const res = await getArticleApi(articleId)
      const { cover, ...formValue } = res.data
      // 设置表单数据
      form.setFieldsValue({ ...formValue, type: cover.type })
    }
    if (articleId) {
      // 拉取数据回显
      getArticle()
    }
  }, [articleId, form])

(2)、表单实例通过 form 属性绑到 <Form>

<Form
          form={form}
          labelCol={{ span: 4 }}
          wrapperCol={{ span: 16 }}
          initialValues={{ type: 0 }} // 默认无图
           onFinish={ onFinish }
        >

回填封面信息

image

// 读写 URL 查询参数
  const [searchParams] = useSearchParams()
  const articleId = searchParams.get('id')
  const [form] = Form.useForm()
  useEffect(() => {
    async function getArticle () {
      const res = await getArticleApi(articleId)
      console.log(res)
      const { cover, ...formValue } = res.data
      // 设置表单数据
      form.setFieldsValue({ ...formValue, type: cover.type })
      // 2. 回填封面图片
      setImageType(cover.type) // 封面类型
      setImageList(cover.images.map(url => ({ url }))) // 封面list
    }
    if (articleId) {
      // 拉取数据回显
      getArticle()
    }
  }, [articleId, form])

返回的数据如下:

image

适配不同状态下的文案

<Breadcrumb items={[
            { title: <Link to={'/'}>首页</Link> },
            { title: `${articleId ? '编辑文章' : '发布文章'}` },
          ]}
          />

更新文章

  const onFinish = async (formValue) => {
    
    console.log(imageList)
    if (imageType !== imageList.length) return message.warning('图片类型和数量不一致')
    const { channel_id, content, title } = formValue
    const formatUrl = (list) => {
      return list.map(item => {
        if (item.response) {
          return item.response.data.url
        } else {
          return item.url
        }
      })
    }
    const params = {
      channel_id,
      content,
      title,
      type: imageType,
      cover: {
        type: imageType,
        images: formatUrl(imageList)
      }
    }
    // await request.post('/mp/articles?draft=false', params)
    if (articleId) {
      // 编辑
      await request.put(`/mp/articles/${articleId}?draft=false`, params)
    } else {
      // 新增
      await request.post('/mp/articles?draft=false', params)
    }
    // message.success('发布文章成功')
    message.success(`${articleId ? '编辑' : '发布'}文章成功`)
  }

十四、项目打包

打包指的是将项目中的源代码和资源文件进行处理,生成可在生产环境中运行的静态文件的过程

命令

npm run build

 

项目本地预览(模拟服务器运行项目)

本地预览是指在本地通过静态服务器模拟生产服务器运行项目的过程

实现步骤

(1)、全局安装本地服务包 npm i -g serve  该包提供了serve命令,用来启动本地服务器

npm i -g serve

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

serve -s ./build

(3)、在浏览器中访问:http://localhost:3000/ 预览项目

 

优化-路由懒加载

什么是路由懒加载?

路由懒加载是指路由的JS资源只有在被访问时才会动态获取,目的是为了优化项目首次打开的时间

image

使用步骤

(1)、把路由修改为由React提供的 lazy 函数进行动态导入

(2)、使用React内置的 Suspense组件 包裹路由中element选项对应的组件

代码实现

router/index.js

 

 

 

查看效果 我们可以在打包之后,通过切换路由,监控network面板资源的请求情况,验证是否分隔成功

 

打包-打包体积分析

业务背景 通过分析打包体积,才能知道项目中的哪部分内容体积过大,方便知道哪些包如何来优化

通过可视化的方式,直观的体现项目中各种包打包之后的体积大小,方便做优化

怎么做到?source-map-explorer

使用步骤

(1)、安装分析打包体积的包:npm i source-map-explorer

(2)、在 package.json 中的 scripts 标签中,添加分析打包体积的命令

(3)、对项目打包:npm run build(如果已经打过包,可省略这一步)

(4)、运行分析命令:npm run analyze

(5)、通过浏览器打开的页面,分析图表中的包体积

核心代码:

 

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

优化-配置CDN

什么是CDN?

CDN是一种内容分发网络服务,当用户请求网站内容时,由离用户最近的服务器将缓存的资源内容传递给用户

哪些资源可以放到CDN服务器?

体积较大的非业务JS文件, 比如react、react-dom

1. 体积较大,需要利用CDN文件在浏览器的缓存特性,加快加载时间

2. 非业务JS文件,不需要经常做变动,CDN不用频繁更新缓存

项目中怎么做?

1. 把需要做CDN缓存的文件排除在打包之外(react、react-dom)

2. 以CDN的方式重新引入资源(react、react-dom)

分析说明:通过 craco 来修改 webpack 配置,从而实现 CDN 优化

核心代码

craco.config.js

// 添加自定义对于webpack的配置

const path = require('path')
const { whenProd, getPlugin, pluginByName } = require('@craco/craco')

module.exports = {
  // webpack 配置
  webpack: {
    // 配置别名
    alias: {
      // 约定:使用 @ 表示 src 文件所在路径
      '@': path.resolve(__dirname, 'src')
    },
    // 配置webpack
    // 配置CDN
    configure: (webpackConfig) => {
      let cdn = {
        js:[]
      }
      whenProd(() => {
        // key: 不参与打包的包(由dependencies依赖项中的key决定)
        // value: cdn文件中 挂载于全局的变量名称 为了替换之前在开发环境下
        webpackConfig.externals = {
          react: 'React',
          'react-dom': 'ReactDOM'
        }
        // 配置现成的cdn资源地址
        // 实际开发的时候 用公司自己花钱买的cdn服务器
        cdn = {
          js: [
            'https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js',
            'https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js',
          ]
        }
      })

      // 通过 htmlWebpackPlugin插件 在public/index.html注入cdn资源url
      const { isFound, match } = getPlugin(
        webpackConfig,
        pluginByName('HtmlWebpackPlugin')
      )

      if (isFound) {
        // 找到了HtmlWebpackPlugin的插件
        match.userOptions.files = cdn
      }

      return webpackConfig
    }
  }
}

public/index.html

<body>
  <div id="root"></div>
  <!-- 加载第三发包的 CDN 链接 -->
  <% htmlWebpackPlugin.options.files.js.forEach(cdnURL => { %>
    <script src="<%= cdnURL %>"></script>
  <% }) %>
</body>

 

 

 

 

 

 

 

 

 

posted on 2026-09-17 11:20  周文豪  阅读(7)  评论(0)    收藏  举报