React进阶练习项目: 文章管理系统

引言

📒📒📒欢迎来到小冷的代码学习世界
博主的微信公众号想全栈的小冷,分享一些技术上的文章,以及解决问题的经验
当前专栏react系列
当前专栏博客小项目练习

极客博客

项目配置

初始化项目 这里依赖的使用:

  1. react & react-dom 18

规范src目录

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

image-20241218140717964

路径别名

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

安装 npm i @craco/craco -D

然后创建 craco.config.js

const path = require('path')

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

替换packge.json的启动方式 就可以使用了

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

配置代码编辑器识别

在跟目录创建 jsconfig.json

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

这样就有路径提示了

安装scss

  1. 安装解析 sass 的包:npm i sass -D
  2. 创建全局样式文件:index.scss

安装完之后在index.scss中写下样式查看是否安装成功

image-20241218141014055

组件库antd

组件库帮助我们提升开发效率,其中使用最广的就是antD

导入依赖: npm i antd

安装图标库: npm install @ant-design/icons --save

测试

import {Button} from "antd";

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

export default App;

效果

image-20241218141415282

配置路由

导入依赖

  • 安装路由包 react-router-dom
  • 准备基础路由组件 LayoutLogin
  • 编写配置

pages中创建好对应的文件夹和组件

image-20241218141719222

然后配置对应的路由文件

  • router文件夹中创建 index.js
  • 配置对应的组件路由映射
import {createBrowserRouter} from "react-router-dom";
import {Layout} from "../pages/Layout";
import {Login} from "../pages/Login";

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

之后使用 provider 将路由放入根文件 使用

index.js:

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

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

配置完重启 这样基础的路由就配置好了

image-20241218142213871

封装requset请求模块

因为项目中会发送很多网络请求,所以我们可以将 axios做好统一封装 方便统一管理和复用

image-20241218150947403

导入依赖

npm i axios

然后在utils中编写 request配置js

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}

utils中创建 index.js 作为统一的工具类使用入口,方便管理工具类

import {request} from "@/utils/request";

export {request}

登录模块

@/pages/login/index.jsx 使用 antd 创建登录页面的内容解构

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

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

样式文件 index.css

.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;
  }
}

表单校验

使用 antd form组件中的表单校验属性来完成 表单校验

image-20241218145050289

现在在login组件中加入基础的表单校验

  {/* 登录表单 */}
                <Form>
                    <Form.Item
                        name="mobile"
                        rules={[
                            {
                                required: true,
                                message: '请输入11位手机号'
                            }
                        ]}>
                        <Input size="large" placeholder="请输入手机号"/>
                    </Form.Item>
                    <Form.Item
                        name="code"
                        rules={[
                            {
                                required: true,
                                message: '请输入验证码'
                            }
                        ]}>
                        <Input size="large" placeholder="请输入验证码"/>
                    </Form.Item>

image-20241218145511598

基础校验设置好之后 我们需要根据业务来设计定制校验 如

  • 手机号必须是11位并且必须是数字 正则表达式
  • 并且输入框失去焦点也出发校验 在Form标签添加属性 validateTrigger="onBlur"
                  <Form.Item
                        name="mobile"
                        rules={[
                            {
                                required: true,
                                message: '请输入手机号'
                            },
                            {
                                pattern: /^1[3-9]\d{9}$/,
                                message: '请输入正确的手机号'
                            }
                        ]}>
                        <Input size="large" placeholder="请输入手机号"/>
                    </Form.Item>

提交数据

继续查看官方文档 案例 里面有一个 onFinish 的回调方法 ,并且放到form组件的属性里就可以看到传递的信息了

image-20241218150515014

代码修改

  const onFinish = (values) => {
        console.log('Success:', values);
    };

 <Form onFinish={onFinish} validateTrigger="onBlur"></Form>

设置好之后我们再次点击登录按钮就可以在控制台看到传递的json信息了

image-20241218150849701

使用Redux管理token

token可以作为用户表示数据 其实一般我们的登录操作就是为了获取对应账号下的token权限,这个token需要我们在前端全局化的共享 所以需要使用 redux来管理

依赖

npm i react-redux @reduxjs/toolkit

配置redux

store文件夹创建对应的文件结构

image-20241220142828152

然后编写 user.js

import {createSlice} from '@reduxjs/toolkit'
import {request} from '@/utils'

const userStore = createSlice({
    name: 'user',
    // 数据状态
    initialState: {
        token: ''
    },
    // 同步修改方法
    reducers: {
        setToken(state, action) {
            state.userInfo = 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

index.js配置统一管理reducer

import {configureStore} from '@reduxjs/toolkit'

import userReducer from './modules/user'

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

在src下目录中的index.js注入store

import {Provider} from "react-redux";
import store from "./store";

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

触发登录操作

我们使用的是黑马的后端模版 所以需要使用它提供的数据

手机号 13888888888
code 246810

输入之后就可以看到成功的拿到了 该用户的 token

image-20241220143845001

redux也成功的保存的token数据

image-20241220144307990

登陆后的操作

  • 我们需要跳转到主页
  • 提示用户登录状态

在login jsx中修改onfinish方法内容实现跳转

PS: 篇幅问题只展示了js代码 return中的样式就不再过多展示

import './index.scss'
import {Button, Card, Form, Input, message} from "antd";
import logo from "@/assets/logo.png"
import {useDispatch} from "react-redux";
import {fetchLogin} from "@/store/modules/user";
import {useNavigate} from "react-router-dom";

export const Login = () => {
    const dispatch = useDispatch();
    const navigate = useNavigate();
    const onFinish = async (values) => {
        await dispatch(fetchLogin(values))
        //     跳转到主页
        navigate('/')
        message.success('登陆成功')
    };
}

效果

image-20241220144618910

token持久化

使用localStorage+redux管理token

编写逻辑 :先查询本地有没有 如果没有就请求,然后保存在本地

修改reducer请求token的方法内容

这里为什么没有用sessionStorage而是选择用localStorage呢 因为我们需要更长时间的持久化 session关闭浏览器就被清空了,之后登出的时候会显式的清除token

const userStore = createSlice({
    name: 'user',
    // 数据状态
    initialState: {
        token: sessionStorage.getItem('token_key') || ''
    },
    // 同步修改方法
    reducers: {
        setToken(state, action) {
            state.token = action.payload
            sessionStorage.setItem('token_key', state.token)
        }
    }
})
封装token操作方法

创建工具类

image-20241220145847204

// 封装存取方法

const TOKENKEY = 'token_key'

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

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

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

export {
  setToken,
  getToken,
  clearToken
}

然后在入口index导入工具类

import {request} from "@/utils/request";
import {clearToken, getToken, setToken} from "@/utils/token";

export {request, getToken, setToken, clearToken}

修改获取的token的代码改为使用工具类

const userStore = createSlice({
    name: 'user',
    // 数据状态
    initialState: {
        token: getToken() || ''
    },
    // 同步修改方法
    reducers: {
        setToken(state, action) {
            state.token = action.payload
            //这里是使用别名的setToken方法 是再import setToken as _setToken
            _setToken(action.payload)
        }
    }
})

在Axios请求中携带token

后端需要token来判断是否能够使用接口 ,所以我们需要修改request工具来让他携带token请求

image-20241220150647679

在请求拦截其中拿到token并且注入token

// 添加请求拦截器
request.interceptors.request.use((config) => {
    // 如果有token就携带没有就正常
    const token = getToken()
    // 按照后端的要求加入token
    if (token) {
        config.headers.Authorization = `Bearer ${token}`
    }
    return config
}, (error) => {
    return Promise.reject(error)
})

测试

image-20241220151207539

使用token做路由权限控制

在没有token的时候 不允许访问需要权限的路由

image-20241220151553164

创建组件 AuthRoute

image-20241220152217542

// 封装高级组件
//核心逻辑:根据token控制跳转
import {getToken} from "@/utils";
import {Navigate} from "react-router-dom";

export function AuthRoute({children}) {
    const token = getToken();
    if (token) {
        return <>{children}</>
    } else {
        return <Navigate to={'/login'} replace={true}/>
    }
}

修改router.js

import {createBrowserRouter} from "react-router-dom";
import {Layout} from "../pages/Layout";
import {Login} from "../pages/Login";
import {AuthRoute} from "@/components/AuthRoute";

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

删除token 之后刷新界面 就会被强制定向到 login

主页面

依赖

image-20241220153439026

用来初始化样式的第三方库

npm install normalize.css

然后将其引入到程序入门 index.js

实现步骤

  1. 打开 antd/Layout 布局组件文档,找到示例:顶部-侧边布局-通栏
  2. 拷贝示例代码到我们的 Layout 页面中
  3. 分析并调整页面布局

主页面模版

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

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 onMenuClick = (router) => {
        console.log(router)
        navigate(router.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"
                        defaultSelectedKeys={['1']}
                        items={items}
                        onClick={onMenuClick}
                        style={{height: '100%', borderRight: 0}}></Menu>
                </Sider>
                <Layout className="layout-content" style={{padding: 20}}>
                    <Outlet/>
                </Layout>
            </Layout>
        </Layout>
    )
}
export default GeekLayout

主页面样式文件

.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-20241220153841966

image-20241220153853223

配置二级路由
const router = createBrowserRouter([
    {
        path: '/',
        element: <AuthRoute><GeekLayout/></AuthRoute>,
        children: [{
            path: '/',
            element: <Home></Home>
        }, {
            path: 'article',
            element: <Article></Article>
        }, {
            path: 'publish',
            element: <Publish></Publish>
        }]
    },
     <!--....省略-->

渲染对应关系

     <Layout className="layout-content" style={{padding: 20}}>
                    <Outlet></Outlet>
                </Layout>
路由联动

将路由的key设置成路由的跳转地址

const items = [
    {
        label: '首页',
        key: '/',
        icon: <HomeOutlined/>,
    },
    {
        label: '文章管理',
        key: '/article',
        icon: <DiffOutlined/>,
    },
    {
        label: '创建文章',
        key: '/publish',
        icon: <EditOutlined/>,
    },
]
const GeekLayout = () => {
    const navigate = useNavigate();
    const onMenuClick = (router) => {
        console.log(router)
        navigate(router.key)
    }
    return (
        <Layout>
            <!--省略-->
            <Layout>
                <Sider width={200} className="site-layout-background">
                    <Menu
                        mode="inline"
                        theme="dark"
                        defaultSelectedKeys={['1']}
                        items={items}
                        onClick={onMenuClick}
                        style={{height: '100%', borderRight: 0}}></Menu>
                </Sider>
                <Layout className="layout-content" style={{padding: 20}}>
                    <Outlet/>
                </Layout>
            </Layout>
        </Layout>
    )
}
菜单点击高亮

ueslocation获取当前的路由位置,并且将MENU中的属性defaultSelectedKeys -> SelectedKeys内容为获取到的pathname

const GeekLayout = () => {
    const navigate = useNavigate();
    const onMenuClick = (router) => {
        console.log(router)
        navigate(router.key)
    }
    // 获取到当前点击的路由
    const location = useLocation();
    const selectedKey = location.pathname;
    return (
        <Layout>
            <Header className="header">
          <!--省略-->
            </Header>
            <Layout>
                <Sider width={200} className="site-layout-background">
                    <Menu
                        mode="inline"
                        theme="dark"
                        SelectedKeys={selectedKey}
                        items={items}
                        onClick={onMenuClick}
                        style={{height: '100%', borderRight: 0}}></Menu>
                </Sider>
  <!--省略-->
            </Layout>
        </Layout>
    )
}
export default GeekLayout

效果

image-20241220160701656

展示个人信息

实现步骤

  1. 在Redux的store中编写获取用户信息的相关逻辑
  2. 在Layout组件中触发action的执行
  3. 在Layout组件使用使用store中的数据进行用户名的渲染

修改 store/module/user.js

import {createSlice} from '@reduxjs/toolkit'
import {getToken, request, setToken as _setToken} from '@/utils'

const userStore = createSlice({
    name: 'user',
    // 数据状态
    initialState: {
        token: getToken() || '',
        userInfo: {}
    },
    // 同步修改方法
    reducers: {
        setToken(state, action) {
            state.token = action.payload
            _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)
        dispatch(setToken(res.data.token))
    }
}
const fetchUserInfo = () => {
    return async (dispatch) => {
        const res = await request.get('/user/profile')
        dispatch(setUserInfo(res.data))
    }
}
export {fetchLogin, fetchUserInfo}

export default userReducer

主页面布局显示

这里展示的是新增的代码 需要去修改header里的user-name的内容改为我们获取到的username

    const dispatch = useDispatch()
    const name = useSelector(state => state.user.userInfo.name)
    useEffect(() => {
        dispatch(fetchUserInfo())
    }, [dispatch])

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

image-20241220161204553

退出登录

  • 需要二次确认退出登录
  • 清除用户信息
  • 跳转回login页面

绑定事件

layout.jsx中找到退出相关的组件Popconfirm

这个组件有是否确认事件的绑定方法 onConfirm={onConfirm}

​ 在store文件夹下user.jsreducer中增加清除用户信息的方法

// 同步修改方法
    reducers: {
        clearUserInfo(state) {
            state.token = ''
            state.userInfo = {}
            clearToken()
        }

在响应事件方法中调用方法 清除用户信息

   const onConfirm = () => {
        dispatch(clearUserInfo())
        navigate('/login')
    }

image-20241226160632157

效果

点击确认退出后 成功被定向到登录页面

image-20241226160656121

处理失效token

为了方便管理以及控制性能 token一般都会有一个有效时间, 通常后端token失效都会返回401 所以我们可以监控后端返回的状态码 来做后续操作 如 退出登录 或 续费token

image-20241226160857875

来到 request工具类中的响应拦截器 拿到响应结果并且校验状态码是否是401

request.interceptors.response.use((response) => {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response.data
}, (error) => {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 401代表token失效 需要清除当前token
    if (error.response.status === 401) {
        clearToken()
        // 这里有问题 是因为使用createBrownRouter创建的实例无法使用navigate,暂时先这么写 后续会修改
        router.navigate('/login').then(() => {
            window.location.reload()
        })
    }
    return Promise.reject(error)
})

如何查看效果?

在控制台将本地的token修改几位 刷新就可以触发401 之后查看效果是否成功

主页可视化图表

使用 echarts

npm i echarts

基础demo

从官方文档复制个demo进来

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

export const Home = () => {
    const chartRef = useRef(null)
    useEffect(() => {
        // 1. 生成实例
        const myChart = echarts.init(chartRef.current)
        // 2. 准备图表参数
        const 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'
                }
            ]
        }
        // 3. 渲染参数
        myChart.setOption(option)
    }, [])

    return (
        <div>
            <div ref={chartRef} style={{width: '400px', height: '300px'}}/>
        </div>
    )

}
封装echarts组件

将内容抽象出来,将不一样的部分抽象为参数适配

image-20241226162624923

然后将图标代码提取出来 开始修改: 将title, x数据, y数据, 样式作为参数

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

const BarChart = ({title, xData, sData, style = {width: '400px', height: '300px'}}) => {
    const chartRef = useRef(null)
    useEffect(() => {
        // 1. 生成实例
        const myChart = echarts.init(chartRef.current)
        // 2. 准备图表参数
        const option = {
            title: {
                text: title
            },
            xAxis: {
                type: 'category',
                data: xData
            },
            yAxis: {
                type: 'value'
            },
            series: [
                {
                    data: sData,
                    type: 'bar'
                }
            ]
        }
        // 3. 渲染参数
        myChart.setOption(option)
    }, [sData, xData])
    return <div ref={chartRef} style={style}></div>
}

export {BarChart}

修改home内容

import {BarChart} from "@/pages/Home/components/BarChat";

export const Home = () => {
    return (
        <div>
            <BarChart
                title={'三个框架满意度'}
                xData={['Vue', 'React', 'Angular']}
                sData={[2000, 5000, 1000]}/>

            <BarChart
                title={'三个框架使用数量'}
                xData={['Vue', 'React', 'Angular']}
                sData={[200, 500, 100]}
                style={{width: '500px', height: '400px'}}/>
        </div>
    )

}

API封装

我们需要优化项目格式, 需要将接口请求维护在一个固定的模块里,但是如何编写每个团队都有区别 仅提供参考

image-20241226164312404

image-20241226163642447

// 用户相关的所有请求
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中user.js的调用方式

// 异步方法封装
const fetchLogin = (loginForm) => {
    return async (dispatch) => {
        const res = await loginAPI(loginForm)
        dispatch(setToken(res.data.token))
    }
}
const fetchUserInfo = () => {
    return async (dispatch) => {
        const res = await getProfileAPI()
        dispatch(setUserInfo(res.data))
    }
}

文章发布

基础文章结构

开发三个步骤:

  1. 基础的文章发布
  2. 封面上传
  3. 带封面的文章

静态结构

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

index.scss

.publish {
  position: relative;
}

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

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

效果

image-20241226165048854

富文本编辑器

导入依赖:

npm i react-quill@2.0.0-beta.2

开发方式:

  1. 安装依赖 导入编辑器和配置文件
  2. 渲染组件调整编辑器样式和数据链接

在需要放入富文本编辑器的位置放入代码

//在文章头部导入需要的样式       
import 'react-quill/dist/quill.snow.css'

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

效果

image-20241226165938872

频道数据渲染

  • 添加新的接口到 apis
  • 使用 useState维护数据
  • 使用 useEffect将数据存入state
  • 绑定到下拉框

添加apis

image-20241226172159279

import {request} from "@/utils";

// 获取文章频道列表
export function getChannels() {
    return request({
        url: '/channels',
        method: 'GET'
    })

}

发布界面

  • 使用 usestate维护列表 并且使用 useEffect请求数据
  • 渲染数据
    const [channels, setChannels] = useState([]);
    useEffect(() => {
        async function getChannelList() {
            const res = await getChannels();
            setChannels(res.data.channels)
        }

        getChannelList()
    }, []);

  return (    <Form.Item
                 label="频道"
                 name="channel_id"
                 rules={[{required: true, message: '请选择文章频道'}]}
                >
                        <Select placeholder="请选择文章频道" style={{width: 300}}>
                            {channels.map((item) => (
                                <Option key={item.id} value={item.id}>{item.name}</Option>
                            ))}
                        </Select>
               </Form.Item>)

提交接口

  • 使用 form组件收集数据
  • 根据文档处理表单数据

这里由于react和富文本的兼容问题 我们需要手动的获取到富文本的内容将他放入到对应表单属性的value中

    const [form] = Form.useForm();
    const onFinish = (formValue) => {
        console.log(formValue)
    }
    const onRichTextChange = (value) => {
        form.setFieldsValue({content: value});
    };
return(
           {/*富文本编辑器*/}
                    <Form.Item
                        label="内容"
                        name="content"
                        rules={[{required: true, message: '请输入文章内容'}]}
                    > <ReactQuill
                        className="publish-quill"
                        theme="snow"
                        placeholder="请输入文章内容"
                        onChange={onRichTextChange}
                    ></ReactQuill></Form.Item>)

效果

image-20241226180920253

发布基础文章

在文章apis中新增请求方法

// 提交文章表单
export function createArticleAPI(data) {
    return request({
        url: '/mp/articles?draft=false',
        method: 'POST',
        data
    })
}

提交表单

    const onFinish = (formValue) => {
        const {channel_id, content, title} = formValue
        const reqData = {
            content,
            title,
            cover: {
                type: 0,
                images: []
            }, channel_id
        }
        //   提交数据
        createArticleAPI(reqData)
    }

效果

image-20241226181425586

上传封面

基础上传

我们需要一个上传小组件 类似下图:

image-20250103160106608

结构代码

将代码放入 publish组件内容标签的上面 ,

  • 这里我们需要编写upload的上传地址
  • 上传后后端回给到我们一个文件列表我们需要保存用于添加文章信息
import { useState } from 'react'

const Publish = () => {
  // 上传图片
  const [imageList, setImageList] = useState([])
  const onUploadChange = (info) => {
      setImageList(info.fileList)
  }
  return (
   	<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>
  )
}

效果

image-20250103161651056

上传成功了

切换封面类型

我们需要根据封面的是三个单选框的选项来决定是否需要显示上传图标

  • 选择单图或者三图就展示上传图标
  • 选择无图 就隐藏

通过 Radio组件的onChange回调函数就可以拿到我们的对应选项 ,

这样在选择无图的时候 上传组件就会隐藏

// 记录图片上传类型选择
    const [imageType, setImageType] = useState(0)
    // 类型选择回调
    const onTypeChange = (value) => {
        setImageType(value.target.value)
    }

<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}
                        >
                            <div style={{marginTop: 8}}>

                            </div>
                        </Upload>}
                    </Form.Item>

效果

无图:

image-20250103162658047

有图:

image-20250103162707227

这里需要注意就是我们之前的静态模版有一个默认属性 type是1 这会导致上传组件的显示有问题,改为和 state一样的 0 即可

image-20250103162833115

控制上传图片的数量

我们需要控制 如:

  • 单图:就一张
  • 三图:就三张

只需要将上传绑定的type显示他的最大数量就行了,

ps: 问题 安全性不高 而且之前替换掉的图片还是会占用信息

image-20250103163750884

发表带图片的文章

我们之前上传基础文章的时候 有一个属性 : cover是空白的 现在我们需要将imagelist和这个cover绑定 就可以上传封面了

  • 我们需要从新组装一下图片列表的信息 上传只需要我们提供 url

修改方法 onFinish

    const onFinish = (formValue) => {
        // 判断type和图片数量是否相等
        if (imageList.length !== imageType) {
            return message.warning('封面类型和图片数量不匹配')
        }
        const {channel_id, content, title} = formValue
        const reqData = {
            content,
            title,
            cover: {
                type: imageType,
                images: imageList.map(item => item.response.data.url)
            }, channel_id
        }
        //   提交数据
        createArticleAPI(reqData).then(data => {
            if (data.message === 'OK') {
                message.success('文章发布成功')
                form.resetFields()
                setImageType(0)
            }
        })
    }

效果

image-20250103164257941

提交之后的信息

image-20250103164446703

上传成功

校验类型

我们需要避免 三图封面只上传了两张图片的情况 所以还需要在上传方法中增加一些判断

 const onFinish = (formValue) => {
        // 判断type和图片数量是否相等
        if (imageList.length !== imageType) {
            return message.warning('封面类型和图片数量不匹配')
        }
        const {channel_id, content, title} = formValue
        const reqData = {
            content,
            title,
            cover: {
                type: imageType,
                images: imageList.map(item => item.response.data.url)
            }, channel_id
        }
        //   提交数据
        createArticleAPI(reqData)
    }

文章列表

放入结构

小细节:

  1. 导入语言包 让日期选择可以识别中文
  2. Select组件配合Form.Item使用时,如何配置默认选中项
    <Form initialValues={{ status: null }} >
import {Link} from 'react-router-dom'
// 导入资源
import {Breadcrumb, Button, Card, DatePicker, Form, Radio, Select, Space, Table, Tag} from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'
import {DeleteOutlined, EditOutlined} from "@ant-design/icons";

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

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

频道模块渲染

我们这次采用 自定义业务hook的方式实现获取频道信息

  • 创建一个use打头的函数
  • 在函数中封装业务逻辑并且导出状态数据
  • 组件中导入函数和执行解构状态数据使用

image-20250103171229511

代码

// 封装获取频道列表的逻辑
import {useEffect, useState} from "react";
import {getChannels} from "@/apis/article";

function useChannel() {
//     1. 获取频道列表的所有逻辑
    const [channels, setChannels] = useState([]);
    useEffect(() => {
        async function getChannelList() {
            const res = await getChannels();
            setChannels(res.data.channels)
        }

        getChannelList()
    }, [])
//     2. 把数据导出
    return {channels};
}

export {useChannel}

这样就可以去改造一下之前的publish获取频道的逻辑 也可以在新的组件中直接使用频道数据

将数据放入文章编辑中

找到频道标签 修改options

  {channels.map(item => <Option value={item.id}>{item.name}</Option>)}

效果

image-20250103171814720

渲染文章列表数据

  • 声明请求方法
  • useEffect拿到数据
  • 渲染数据

请求方法 /apis/article.js

//获取文章列表
export function getArticleAPI(params) {
    return request({
        url: '/mp/articles',
        method: 'GET',
        params
    })
}

Article 组件

import {Link} from 'react-router-dom'
// 导入资源
import {Breadcrumb, Button, Card, DatePicker, Form, Radio, Select, Space, Table, Tag} from 'antd'
import locale from 'antd/es/date-picker/locale/zh_CN'
import {useChannel} from "@/hooks/useChannel";
import {useEffect, useState} from "react";
import {getArticleAPI} from "@/apis/article";
import {DeleteOutlined, EditOutlined} from "@ant-design/icons";

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

export const Article = () => {
    // 获取频道数据
    const {channels} = useChannel()
    // 准备列数据
    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>
                )
            }
        }
    ]
    // 获取文章列表
    const [list, setList] = useState([])
    useEffect(() => {
        async function getList() {
            const res = await getArticleAPI();
            setList(res.data.results)
        }

        getList()
    }, []);
    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="请选择文章频道"
                            style={{width: 120}}
                        >
                            {channels.map(item => <Option 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={`根据筛选条件共查询到 ${list.length} 条结果:`}>
                <Table rowKey="id" columns={columns} dataSource={list}/>
            </Card>
        </div>
    )
}


文章状态

我们需要根据不同的文章状态显示不同的tag , 我们在用枚举渲染的方式实现这个多种状态的显示,

我们之前的代码中有专门控制每一列显示的数组

image-20250104155009184

这里我们就可以根据 拿到的数据 利用 render属性 来渲染出来需要的tag

通过接口文档我们知道目前支持两种状态 :

  • 1 待审核
  • 2 通过

文章列表组件中添加

  • 枚举代码
  • 并且将状态对象的 render 关联到输出枚举内容即可
    // 文章状态枚举
    const status = {
        1:<Tag color={"warning"}>待审核</Tag>,
        2:<Tag color={"success"}>审核通过</Tag>
    }
    
            {

            title: '状态',
            dataIndex: 'status',
            render: data => status[data]
        }

效果

image-20250104155609389

文章筛选

我们需要根据 :

  • 频道
  • 日期
  • 状态

来筛选需要的文章

本质就是给请求列表的接口传递不同的参数

接口文档的参数

image-20250104155926390

    // 查询筛选参数
    const [reqData, setReqData] = useState(
        {
            status: '',
            channel_id: '',
            begin_pubdate: '',
            end_pubdate: '',
            page: 1,
            per_page: 4,
        }
    );

这里我们利用 useEffect的机制 维护的依赖项有变动 就会重新执行内部代码 ,拉取文章数据 所以我们需要将reqdata放入之前请求列表的参数中个,之前这个参数是没有传递的

完整代码

  // 查询筛选参数
    const [reqData, setReqData] = useState(
        {
            status: '',
            channel_id: '',
            begin_pubdate: '',
            end_pubdate: '',
            page: 1,
            per_page: 4,
        }
    );
    const onReqFinish = (formValue) => {
        // 1. 准备参数
        const {channel_id, date, status} = formValue
        setReqData({
            status,
            channel_id,
            begin_pubdate: date[0].format('YYYY-MM-DD'),
            end_pubdate: date[1].format('YYYY-MM-DD'),
        })
    }
    // 获取频道数据
    const {channels} = useChannel()
    // 获取文章列表
    const [list, setList] = useState([])
    useEffect(() => {
        async function getList() {
            const res = await getArticleAPI(reqData);
            setList(res.data.results)
        }

        getList()
    }, [reqData]);

效果

image-20250104161050120

分页实现

分页公式 : 页数 = 总数/每条数

思路 : 将页数作为请求参数从新渲染文章列表

找到文章列表对应的table标签 配置 pagination属性

补充 维护一个count

image-20250104164504872

在请求文章列表的时候 把这个属性放入count维护即可

    useEffect(() => {
        async function getList() {
            const res = await getArticleAPI(reqData);
            setList(res.data.results)
            setCount(res.data.total_count)
        }

        getList()
    }, [reqData]);

代码

简单的分页就完成了 :

  • 设置总数
  • 每页数量
            {/*表格区域*/}
            <Card title={`根据筛选条件共查询到 ${count} 条结果:`}>
                <Table rowKey="id" columns={columns} dataSource={list} pagination={{
                    total: count,
                    pageSize: reqData.per_page,
                }}/>
            </Card>

image-20250104164656351

根据对应的页数来请求对应文章

pagination中使用 onchange 事件来完成对应页数的请求

标签改动:

               <Table rowKey="id" columns={columns} dataSource={list} pagination={{
                    total: count,
                    pageSize: reqData.per_page,
                    onChange: onPageChange
                }}/>

新增方法:

page 参数会拿到点击的对应页数 ,根据特性我们只需要改变参数 就会触发useEffect来更新数据

    const onPageChange = (page) => {
        setReqData({
            ...reqData,
            page: page
        })
    }

文章删除

/APIS/Article.js新增请求方法

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

添加静态文件

在行数据数组中找到 操作 添加确认组件 绑定onConfirm事件

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

事件代码

    const delArticle = async (data) => {
        await deleteArticleAPI(data)
        // 更新列表
        setReqData({
            ...reqData
        })
    }

编辑文章

我们点击编辑按钮的时候 需要携带文章id 跳转到文章编写页面,

 const navigate = useNavigate();
//样式代码
<Button type="primary" shape="circle" icon={<EditOutlined/>} onClick={() => navigate(`/publish?id=${data.id}`)}/>

效果

image-20250104171000631

载入文章数据

通过传入的id获取到文章数据 使用表单组件的实例方法 setFieldsValue填进去即可

/APIS/Article.js新增请求方法

//获取文章数据
export function getArticleById(id) {
    return request({
        url: `/mp/articles/${id}`,
    })
}

使用 钩子来做到刷新就回填数据

    // 载入文章数据
    const [searchParams] = useSearchParams();
    // 文章数据
    const articleId = searchParams.get('id');
    useEffect(() => {
        async function getArticleDetail() {
            const res = await getArticleById(articleId)
            const {cover, ...infoValue} = res.data
            form.setFieldsValue({...infoValue, type: cover.type})
            setImageType(cover.type)
            setImageList(cover.images.map(url => ({url})))
        }

        if (articleId) {
            getArticleDetail()
        }
    }, [articleId, form])

这里需要在 上传框加入一个属性 fileList

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

根据id 展示状态

找到 title中的发布文章 判断是否有id

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

更新文章

做完内容修改后 需要确认更新文章内容 并且校对文章数据 然后更新文章

我们需要适配url参数 因为我们的图片每个接口的传递需要的格式不同

新增更新文章方法

/apis/article.js

// 修改文章表单
export function updateArticleAPI(data) {
    return request({
        url: `/mp/articles/${data.id}?draft=false`,
        method: 'PUT',
        data
    })
}

修改 onfinish方法

    const onFinish = (formValue) => {
        // 判断type和图片数量是否相等
        if (imageList.length !== imageType) {
            return message.warning('封面类型和图片数量不匹配')
        }
        const {channel_id, content, title} = formValue
        const reqData = {
            content,
            title,
            cover: {
                type: imageType,
                // 编辑url的时候也需要做处理
                images: imageList.map(item => {
                    if (item.response) {
                        return item.response.data.url
                    } else {
                        return item.url
                    }
                })
            }, channel_id
        }
        //   提交数据
        // 需要判断 新增和修改接口的调用
        if (articleId) {
            updateArticleAPI({...reqData, id: articleId}).then(data => {
                if (data.message === 'OK') {
                    message.success('文章修改成功')
                }
            })
        } else {
            createArticleAPI(reqData).then(data => {
                if (data.message === 'OK') {
                    message.success('文章发布成功')
                    form.resetFields()
                    setImageType(0)
                }
            })
        }

    }

效果

image-20250104181320356

打包优化

CRA自带的打包命令

npm run build

# 静态服务器
npm install -g serve
#启动
serve -s build

之后就可以在项目文件夹看到

image-20250104185436568

我们需要安装一个本地服务器 就可以跑起来打包好的项目了

配置路由懒加载

就是使路由在需要js的时候 才会获取 可以提高项目的首次启动时间

  • 把路由修改为React提供的 lazy函数进行动态导入
  • 使用 react 内置的 Suspense组件 包裹路由中的element

将路由中组件的导入方式改为lazy

import {createBrowserRouter} from "react-router-dom";

import {Login} from "@/pages/Login";
import {AuthRoute} from "@/components/AuthRoute";
import GeekLayout from "@/pages/Layout";
import {lazy, Suspense} from "react";

// 使用 lazy进行导入

const Home = lazy(() => import("@/pages/Home"));
const Article = lazy(() => import('@/pages/Article'))
const Publish = lazy(() => import('@/pages/Publish'))

const router = createBrowserRouter([
    {
        path: '/',
        element: <AuthRoute><GeekLayout/></AuthRoute>,
        children: [{
            path: '/',
            element: <Suspense fallback={'加载中'}><Home></Home></Suspense>
        }, {
            path: 'article',
            element: <Suspense fallback={'加载中'}><Article></Article></Suspense>
        }, {
            path: 'publish',
            element: <Suspense fallback={'加载中'}><Publish></Publish></Suspense>
        }]
    },
    {
        path: '/login',
        element: <Login/>
    }
])
export default router

只能看看语法了 目前有React18 不知道为什么提示我使用的不对

CDN

意义就是 加载离本地最近的服务器上的文件

image-20250104191954563

Hooks

ueslocation获取当前的路由位置

 // 获取到当前点击的路由
    const location = useLocation();
    const selectedKey = location.pathname;
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冷环渊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值