react+vite+antD+reduce+echarts项目完整记录
之前写前端项目,都是用的vue,从最开始的vue2到后来的vue3,断断续续写了3年,打包工具也从webpack转到了vite,全局数据管理工具从vuex转到了pinia。总体而言,vue3对比vue2,有非常明显的提升,vite比webpack打包的速度更是快了无数倍,至于pinia和vuex,因人而异,我更喜欢pinia,组合式api的写法深得我心。总而言之一句话,我是全方面拥抱了vue3的新技术栈,当然,除了TS,TS对后端比较友好,我只能算半个后端,用不用无所谓。时代在前进,技术在发展,如果永远守着一套陈旧的技术,找各种理由为自己辩解,实在是不明智的选择。
一直想学一下react,中途学过几次,因为平时工作事情太多不得不停下来。刚开始接触jsx,我是抵制的,vue把html、js和css进行了严格的区分,并摆脱了原生的dom操作,jsx却又把这些混在一起,写代码的时候让我感觉像吃了si一样难受。学完之后依然觉得很难受,但为啥我还是坚持要学react呢,这么几个原因:
- 从全球来看,react是最火的前端框架,vue只在国内火,我在看国外的一些项目的源码时,发现自己完全看不懂在写什么,甚至国内,有些开源项目也只出了react的包,比如mapv
- 换一种框架,扩展一下自己的技能树
- 熟悉原生js
花了3天速刷了一遍B站黑马前端讲师的课,并跟着完整写了一个非常简单的项目,后端接口也是用的黑马的,感谢黑马,记录一下完整的过程,为自己后面写项目提供参考,也为后来人提供参考
项目最终界面:
- 登录界面
- 首页
- 文章管理
- 创建文章
目前就这些了,以下进入正题
〇、代码仓库地址及视频地址
https://gitee.com/hgandzl/react-vite
react+vite+redux+echarts前端练手小项目
一、创建项目并配置基础环境
1. vite创建项目
黑马老师是基于CRA创建项目,应该是和webpack相关的技术,没深入了解,我是用的vite
vite创建前端项目的指令
npm create vite@latest
创建过程如下:
vscode打开创建的项目,执行npm i
后执行npm run dev
,即可打开默认的vite+react项目
2. 整理项目目录
项目src文件夹下依次创建如下文件夹
-src
-apis 项目接口函数
-assets 项目资源文件,比如,图片等
-components 通用组件
-pages 页面组件
-router 路由
-store 集中状态管理
-utils 工具,比如,token、axios 的封装等
-App.jsx 根组件
-index.scss 全局样式
-main.jsx 项目入口
删除无关的文件,只保留App.jsx和main.jsx,并删除相关引入
删除main.jsx中的严格节点模式
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
删除App.jsx中无关的代码,保留基础组件
function App() {
return <>app</>;
}
export default App;
3. 使用scss预处理器
实现步骤
- 安装解析 sass 的包:
npm i sass -D
- 创建全局样式文件:
index.scss
index.scss
* {
margin: 0;
padding: 0;
}
项目入口文件引入index.scss
4. 使用Ant Design作为UI框架
实现步骤
- 安装 antd 组件库:
npm i antd
- 页面上导入并使用
5. 配置基础路由
实现步骤
安装路由包
npm i react-router-dom
准备
Layout
和Login
俩个基础组件pages目录下新建两个组件,分别是pages/Layout/index.jsx和pages/Login/index.jsx,并同步新建样式文件
pages/Layout/index.jsx
const Layout = () => { return <div>this is layout</div> } export default Layout
pages/Login/index.jsx
const Login = () => { return <div>this is login</div> } export default Login
配置路由
router文件夹下新建index.jsx文件,并配置如下基础路由
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
全局挂载路由
和vue项目类似,路由要全局挂载
main.jsx
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} /> );
二、编写登录页面
1. 使用antd搭建基本结构
实现步骤
在
Login/index.js
中创建登录页面基本结构import "./index.scss"; import { Card, Form, Input, Button } from "antd"; import logo from "../../assets/global.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;
在 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: 400px; 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; } }
启动项目,地址输入登录页面路由,显示如下
2. 实现表单校验功能
实现步骤
- 为 Form 组件添加
validateTrigger
属性,指定校验触发时机的集合 - 为 Form.Item 组件添加 name 属性,这是为了能取到表单项里面的值
- 为 Form.Item 组件添加
rules
属性,用来添加表单校验规则对象,这与elementplus的验证机制高度相似
整体实现代码
const Login = () => {
return (
<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>
)
}
3. 获取登录form的表单数据
实现步骤
- 为 Form 组件添加
onFinish
属性,该事件会在点击登录按钮时触发。其实这个onFinish也是button中的submit绑定的,也就是说点击submit按钮时,就会触发onFinish方法 - 创建 onFinish 函数,通过函数参数 values 拿到表单值,onFinish函数传递默认参数,参数就是表单内的每一项数据
const onFinish = (formData) => {
console.log(formData);
};
....
<Form validateTrigger={["onBlur"]} onFinish={onFinish}>
....
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block>
登录
</Button>
</Form.Item>
</Form>
....
export default Login;
4. aixos二次封装
因为需要向后端发起请求,涉及token认证的地方需要设置请求拦截器,也可能需要设置响应拦截器,所以需要对axios二次封装
在此之前,我曾详细记录过如何使用react+redux完成登录页面及token的存取和登录保持,因此,整个登录不再赘述,只上关键过程和重要代码
实现步骤
- 安装 axios 到项目
- 创建 utils/http.jsx 文件
- 创建 axios 实例,配置
baseURL,请求拦截器,响应拦截器
https.jsx
import axios from "axios";
const http = axios.create({
baseURL: "http://geek.itheima.net/v1_0",
timeout: 5000,
});
// axios请求拦截器
http.interceptors.request.use(
(config) => {
return config;
},
(e) => Promise.reject(e)
);
// axios响应式拦截器
http.interceptors.response.use(
(res) => res.data,
(e) => {
console.log(e);
return Promise.reject(e);
}
);
export default http;
5. 引入redux管理全局数据
react中的redux就相当于vue中的vuex,都用于管理全局数据,登录时后端返回的token数据就是全局需要的数据
实现步骤
安装redux相关包
npm i react-redux @reduxjs/toolkit
配置redux,配置redux在另一篇博客中有详细记录,不再具体说明
- 新建user模块
store/moduls/user.jsx
,填入以下代码
import { createSlice } from "@reduxjs/toolkit"; import http from "../../utils/http"; 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 http.post("/authorizations", loginForm); dispatch(setToken(res.data.token)); }; }; export { fetchLogin }; export default userReducer;
- 在index.jsx中注册子模块,
store/index.jsx
import { configureStore } from "@reduxjs/toolkit"; import userReducer from "./modules/user"; export default configureStore({ reducer: { user: userReducer } })
- 入口文件中全局注册store,
main.jsx
import ReactDOM from "react-dom/client"; import App from "./App.jsx"; import "./index.scss"; import { RouterProvider } from "react-router-dom"; import router from "./router/index.jsx"; import { Provider } from "react-redux"; import store from "./store/index.jsx"; ReactDOM.createRoot(document.getElementById("root")).render( <Provider store={store}> <RouterProvider router={router} /> </Provider> );
- 新建user模块
6. 实现登录逻辑
实现步骤
1. 收集表单信息,向后端发送登录请求
2. 登录成功后跳转到首页,提示用户登录成功
主要是修改上面的Login/index.jsx中的onFinish方法
如下:
// 省略其他代码
// .......
import { useDispatch } from "react-redux";
import { fetchLogin } from "../../store/modules/user";
import { useNavigate } from "react-router-dom";
// 省略其他代码
// .......
const onFinish = async (formData) => {
console.log(formData);
await dispatch(fetchLogin(formData))
navigate('/')
message.success('登录成功')
};
// 省略其他代码
// .......
7. 实现token持久化存储
其实就是登录时把token存到localstorage中去,react+redux完成登录页面及token的存取和登录保持–这篇博客中详细记录了,这里只上关键代码
1. 首先封装token的存、取、删方法,`utils/token.jsx`
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 };
localstorage中持久化存储token,逻辑就是在redux的同步方法中,存储token,同时,token的初始化不再是空值,当localstorage中有token时,就取出来,没有就是空值
store/moduls/user.jsx
import { createSlice } from "@reduxjs/toolkit";
import http from "../../utils/http";
import { setToken as _setToken, getToken } from "../../utils/token";
const userStore = createSlice({
name: "user",
// 数据状态
initialState: {
// 差异1
token: getToken() || "",
},
// 同步修改方法
reducers: {
setToken(state, action) {
state.token = action.payload;
// 存入本地
_setToken(state.token);
},
},
});
8. 请求拦截器中携带token
常规操作,在axios二次封装的http.jsx文件中添加以下代码
// axios请求拦截器
http.interceptors.request.use(
(config) => {
// 导入getToken方法
const token = getToken()
if (token) {
// 请求头携带token
config.headers.Authorization = "Bearer " + token;
}
return config;
},
(e) => Promise.reject(e)
);
9. 路由守卫
vue中的路由守卫是在router中实现的,react的做法是封装 AuthRoute
路由鉴权高阶组件,然后将需要鉴权的页面路由配置,替换为 AuthRoute 组件渲染
实现步骤
- 在 components 目录中,创建
AuthRoute/index.jsx
文件 - 登录时,直接渲染相应页面组件
- 未登录时,重定向到登录页面
- 将需要鉴权的页面路由配置,替换为 AuthRoute 组件渲染
AuthRoute/index.jsx
中的代码
import { getToken } from '../../utils/token'
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
Layout页面需要鉴权,所以在路由中修改页面的渲染配置,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;
10. 封装接口调用的api
因为后面涉及多个后端接口调用,所以好的做法是把后端接口进行统一的封装
新建apis/user.jsx文件,用于处理用户相关的接口
import http from "../utils/http";
export const loginAPI = (data) => {
return http({
url: "/authorizations",
method: "POST",
data,
});
};
把前面的第5节中redux异步方法请求改写一下
// 异步方法封装
const fetchLogin = (loginForm) => {
return async (dispatch) => {
const res = await loginAPI(loginForm); // 注意loginAPI要引入
dispatch(setToken(res.data.token));
};
};
后面其他api也就抽离出来了
三、Layout首页设计
1. 搭建首页基础框架
首页的基础框架长下面这个样子
先填入基础代码
import React, { useEffect, useState } from "react";
import "./index.scss";
import {
HomeOutlined,
DiffOutlined,
EditOutlined,
LogoutOutlined,
} from "@ant-design/icons";
import { Breadcrumb, Layout, Menu, theme, Popconfirm } from "antd";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
// import { fetchUserInfo, clearUserInfo } from "../../store/modules/user";
const { Header, Content, Sider } = Layout;
const items = [
{
label: "首页",
key: "/",
icon: <HomeOutlined />,
},
{
label: "文章管理",
key: "/article",
icon: <DiffOutlined />,
},
{
label: "创建文章",
key: "/publish",
icon: <EditOutlined />,
},
];
const GLayout = () => {
const [collapsed, setCollapsed] = useState(false);
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
return (
<Layout
style={{
minHeight: "100vh",
}}
>
<Sider
theme="light"
collapsible
collapsed={collapsed}
onCollapse={(value) => setCollapsed(value)}
>
<div className="demo-logo-vertical" />
<Menu
// theme="dark"
defaultSelectedKeys={["/"]}
// selectedKeys={selectedKey}
mode="inline"
items={items}
// onClick={clickMenu}
/>
</Sider>
<Layout>
<Header
className="header"
style={{
padding: 0,
background: colorBgContainer,
}}
>
<div className="logo"></div>
<div className="user-info">
<span className="user-name">React</span>
<span className="user-logout">
<Popconfirm
title="是否确认退出?"
okText="退出"
cancelText="取消"
// onConfirm={logout}
>
<LogoutOutlined /> 退出
</Popconfirm>
</span>
</div>
</Header>
<Content
className="content"
style={{
margin: "5px 5px",
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default GLayout;
补充对应的样式
.header {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
width: 200px;
height: 60px;
background: url('../../assets/global.png') no-repeat center / 160px auto;
}
.user-info {
margin-right: 20px;
color: #070707;
.user-name {
margin-right: 20px;
}
.user-logout {
display: inline-block;
cursor: pointer;
}
}
}
.content {
height: 100%;
}
2. 配置二级路由
就是把左侧的文章管理、创建文章和首页的路由给配置出来
使用步骤
在 pages 目录中,分别创建:Home(数据概览)/Article(内容管理)/Publish(发布文章)页面文件夹
分别在三个文件夹中创建 index.jsx 并创建基础组件后导出
在
router/index.js
中配置嵌套子路由,在Layout
中配置二级路由出口import { createBrowserRouter } from "react-router-dom"; import Layout from "../pages/Layout"; import Login from "../pages/Login"; 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: [ { // path: 'home', index: true, element: <Home /> }, { path: 'article', element: <Article /> }, { path: 'publish', element: <Publish /> }, ] }, { path: "/login", element: <Login />, }, ]); export default router;
使用 Link 修改左侧菜单内容,与子路由规则匹配实现路由切换,前面提供的代码中已经配置好了,就是
<Outlet />
3. 点击菜单跳转至对应的二级路由
- 在menu菜单中添加点击回调函数
const navigate = useNavigate();
const clickMenu = (route) => {
navigate(route.key);
}
菜单反向高亮
是个啥意思勒,目前点击菜单,菜单栏是高亮的,但是如果冲地址栏直接输入地址,对应的菜单并不能高亮。。
我在使用elementplus时经常遇到这个问题,一度以为是框架的bug,现在才搞明白,原来是自己没有处理好,处理逻辑是先获取页面当前的地址,然后把menu中的selectedKeys属性设置为当前地址
const GLayout = () => {
// 省略部分代码
// 获取当前页面地址-----------------1
const location = useLocation()
const selectedKey = location.pathname
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----------------2
selectedKeys={selectedKey}
items={items}
style={{ height: '100%', borderRight: 0 }}
onClick={menuClickHandler}></Menu>
</Sider>
<Layout className="layout-content" style={{ padding: 20 }}>
<Outlet />
</Layout>
</Layout>
</Layout>
)
}
4. 头部导航栏显示个人信息
这部分其实应该可以直接登录的时候就直接给了,写在user的store中,不过黑马提供的逻辑是重新调了一个接口,这个接口返回的才是用户信息。别人怎么提供就怎么来吧
实现步骤
编写获取个人信息的接口
export const getProfileAPI = () => { return http({ url: "/user/profile", }); };
在Redux的store中编写获取用户信息的相关逻辑
// 异步方法,获取用户个人信息 const fetchUserInfo = () => { return async (dispatch) => { try { const res = await getProfileAPI(); // console.log(res) dispatch(setUserInfo(res.data)); } catch (error) { message.error("登录信息失效,请重新登录"); } }; };
要把这个方法暴露出去
在Layout组件中触发action的执行
在Layout组件使用使用store中的数据进行用户名的渲染
以上两步代码如下:
import { fetchUserInfo } from "../../store/modules/user"; // 获取用户信息 const dispatch = useDispatch(); useEffect(() => { dispatch(fetchUserInfo()); }, []); const { userInfo } = useSelector((state) => state.user); <span className="user-name">{userInfo.name}</span>
5. 退出登录逻辑
也是常规操作,之前是在pinia中写一个删除store的方法,是个同步方法,redux中差不多
实现步骤
为气泡确认框添加确认回调事件,实际上就是onConfirm事件
<Popconfirm title="是否确认退出?" okText="退出" cancelText="取消" onConfirm={logout} > <LogoutOutlined /> 退出 </Popconfirm>
在
store/userStore.jsx
中新增退出登录的action函数,在其中删除token// 同步修改方法 reducers: { ........ clearUserInfo(state) { state.token = ""; state.userInfo = ""; clearToken(); },
注意对外暴露出去
在回调事件中,调用userStore中的退出action
清除用户信息,返回登录页面
const logout = () => { dispatch(clearUserInfo()) navigate("/login"); }
6. 处理token失效
一般是token过期后的处理逻辑,后端会返回401代码,响应拦截器中根据这个代码进行路由跳转至登录页面
utils/http.jsx中响应拦截器添加如下代码
import router from "../router";
// axios响应式拦截器
http.interceptors.response.use(
(res) => res.data,
(e) => {
console.log(e);
// 401 -- token失效
if(e.response.status === 401){
clearToken()
// router实例
router.navigate('/login')
}
return Promise.reject(e);
}
);
export default http;
7. 首页绘制echarts图
echarts图我在vue中画过无数遍了,这里的逻辑基本上一样
- 首先安装echarts
npm i echarts
封装一个画图组件
在Home目录下新建components目录,并创建BarChart.jsx组件
封装的代码如下:
import * as echarts from "echarts"; import { useEffect, useRef } from "react"; // 父子组件通讯props const BarChart = ({ title, xData, sData, style = { width: "400px", height: "300px" }, }) => { const chartRef = useRef(null); let initChart; const drawChart = () => { if (initChart != null && initChart != "" && initChart != undefined) { initChart.dispose(); //销毁 } initChart = echarts.init(chartRef.current); const option = { title: { text: title, }, xAxis: { type: "category", data: xData, }, yAxis: { type: "value", }, series: [ { data: sData, type: "bar", }, ], }; initChart.setOption(option); window.addEventListener("resize", () => { initChart.resize(); }); }; useEffect(() => drawChart(), [xData, sData]); return ( <> <div ref={chartRef} style={style}></div> </> ); }; export default BarChart;
几个要点记录一下:
- BarChart是子组件,父组件应传递 title, xData, sData, style 这几个属性
- react中获取dom是用的react中useRef钩子,vue中直接就是ref
- useEffect需要监听数据变化,然后重绘图
Home组件中调用子组件,并传递子组件所需的数据
import BarChart from "./components/BarChart"; const Home = () => { return ( <> <BarChart xData={["Vue", "React", "Angular"]} sData={[2000, 5000, 1000]} title={"三大框架使用率"} ></BarChart> <BarChart xData={["Vue", "React", "Angular"]} sData={[200, 500, 100]} title={"三大框架满意度"} style={{ width: "500px", height: "400px" }} ></BarChart> </> ); }; export default Home;
最终展示效果
四、发布文章模块
就是下面这个页面
1. 创建基础结构
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 {
position: relative;
.publish-quill {
.ql-editor {
min-height: 300px;
}
}
}
.ant-upload-list {
.ant-upload-list-picture-card-container,
.ant-upload-select {
width: 146px;
height: 146px;
background: #eee;
}
}
2. 添加富文本编辑器
实现步骤
安装富文本编辑器
npm i react-quill@2.0.0-beta.2
这里可能会报错,应该改成
npm i react-quill@2.0.0-beta.2 --force
导入富文本编辑器组件以及样式文件
渲染富文本编辑器组件
调整富文本编辑器的样式
publish.jsx中的代码
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"; import "react-quill/dist/quill.snow.css"; 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: "请输入文章内容" }]} > <ReactQuill className="publish-quill" theme="snow" placeholder="请输入内容" ></ReactQuill> </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;
3. antD中的select组件获取频道数据
这个数据是从后端获取的
实现步骤
使用useState初始化数据和修改数据的方法
const [channels, setChannels] = useState([]);
在useEffect中调用接口并保存数据
封装接口代码,apis目录新建article.jsx文件,填写接口请求函数
import http from "../utils/http"; export const getChannelAPI = () => { return http({ url: "/channels", }); };
Publish.jsx编写请求数据的函数,并在副作用钩子中调用
const fetchChannels = async () => { const res = await getChannelAPI(); console.log(res); setChannels(res.data.channels); }; useEffect(() => { fetchChannels(); }, []);
使用数据渲染对应模版
<Select placeholder="请选择文章频道" style={{ width: 400 }}> {channels.map((item) => ( <Option value={item.id} key={item.id}> {item.name} </Option> ))} </Select>
4. 发布文章
先封装发布文章的接口
// 新增 export const publishAPI = (data) => { return http({ url: "/mp/articles?draft=false", method: "POST", data, }); };
form提交submit的onFinish回调函数
const onFinish = async (values) => { const { channel_id, content, title } = values; const data = { channel_id, content, title, type: 1, cover: { type: 1, images: [], }, }; await publishAPI(data) };
5. 文章封面图片上传逻辑
(1)封面上传结构
在频道和内容之间插入如下代码
<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>
(2)实现基础上传功能
实现步骤
为 Upload 组件添加
action 属性
,配置封面图片上传接口地址<Upload name="image" listType="picture-card" showUploadList action={'http://geek.itheima.net/v1_0/upload'} onChange={onUploadChange} > <div style={{ marginTop: 8 }}> <PlusOutlined /> </div> </Upload>
为 Upload组件添加
name属性
, 接口要求的字段名为 Upload 添加
onChange 属性
,在事件中拿到当前图片数据,并存储到图片列表中// 上传图片的回调 const [imageList, setImageList] = useState([]); const onUploadChange = (info) => { setImageList(info.fileList); };
(3)切换图片选项
其实就是单图、无图和三图的切换
实现步骤
- 点击单选框时拿到当前的类型value
- 根据value控制上传组件的显示(大于零时才显示)
............
// radio选择切换回调
const [imageType, setImageType] = useState(1);
const onTypeChange = (value) => {
setImageType(value.target.value);
};
............
<Form.Item label="封面">
<Form.Item name="type" onChange={onTypeChange}>
<Radio.Group>
<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 }}>
<PlusOutlined />
</div>
</Upload>
)}
</Form.Item>
...........
(4)控制图片数量选择
upload设置maxCount 属性限制图片的上传图片数量
并通multiple属性确定是否允许多选图片
<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>
(5)发布带封面的文章
需要重写提交的onFinish方法
实现步骤
- 校验图片类型和数量是否吻合
- 处理图片列表为接口所需的数据格式
const onFinish = async (values) => {
if (imageType !== imageList.length)
return message.warning("图片类型和数量不一致");
const { channel_id, content, title } = formValue;
const data = {
channel_id,
content,
title,
type: imageType,
cover: {
type: imageType,
images: imageList.map((item) => item.response.data.url),
},
};
await publishAPI(data);
message.success("发布成功");
navigate("/article");
};
这里可能会有报错,就是传3图的时候,imageType='3’是字符串,而传单图时,imageType=1却是整数,我第一遍写的时候没有问题,后来传3图却怎么也传不上去,如果有问题,转换一下imageType的类型就行了
五、文章列表管理
分成两块,上面是文章筛选,下面是筛选的文章列表,如下:
1. 创建页面静态结构
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;
2. 渲染筛选form中的频道数据
前面发布文章已经做过了,掉下接口,然后渲染即可
import { useEffect, useState } from "react";
import { getChannelAPI } from "../../apis/article";
............
const [channels, setChannels] = useState([]);
const getChannel = async () => {
const res = await getChannelAPI();
setChannels(res.data.channels);
};
useEffect(() => {
getChannel();
}, []);
.............
<Select
placeholder="请选择文章频道"
style={{ width: 120 }}
>
{channels.map((item) => (
<Option value={item.id} key={item.id}>
{item.name}
</Option>
))}
</Select>
3. 渲染表格区域数据
实现步骤
编写获取数据的接口
export const getArticleListAPI = (params) => { return http({ url: "/mp/articles", params, }); };
注意这个接口是需要传参的,在调用接口时我再描述一下参数的结构
声明列表相关数据管理
使用useState声明参数相关数据管理
调用接口获取数据
使用接口数据渲染模板
页面上渲染数据的代码
.............
import { getChannelAPI, getArticleListAPI } from "../../apis/article";
const Article = () => {
// 筛选条件,也就是接口参数
const [reqData, setReqData] = useState({
status: "", // 文章审核状态
channel_id: "", // 频道id
begain_pubdate: "", // 开始日期
end_pubdate: "", // 结束日期
page: 1, // 页码
per_page: 4, // 每页显示数据
});
// 表格数据
const [list, setList] = useState([]);
// 数据总条数
const [count, setCount] = useState(0);
const getList = async () => {
const res = await getArticleListAPI(reqData);
setList(res.data.results);
setCount(res.data.total_count);
};
useEffect(() => {
getChannel();
getList();
}, [reqData]);
return (
............
<Card title={`根据筛选条件共查询到 ${count} 条结果:`}>
<Table rowKey="id" columns={columns} dataSource={list} />
</Card>
)
}
以上代码完成了接口参数的初始化、表格数据的初始化和获取、数据总条数的初始化和获取,并在副作用函数中调用相关数据的获取方法
4. 表格筛选功能实现
点击筛选按钮,表格区域的数据应该能改变
实现步骤
- 为表单添加
onFinish
属性监听表单提交事件,获取参数 - 根据接口字段格式要求格式化参数格式
- 修改
params
参数并重新使用新参数重新请求数据
代码实现
// 筛选功能实现
const onFinish = (formData) => {
setReqData({
...reqData,
channel_id: formData.channel_id,
status: formData.status,
begain_pubdate: formData.date[0].format("YYYY-MM-DD"),
end_pubdate: formData.date[1].format("YYYY-MM-DD"),
});
};
注意,这个onFinish其实仅仅改变了reqData中的数据,其实就是把reqData中部分字段改成了筛选选中的值,这样就能筛选数据了吗?
的确,因为我在副作用函数useEffect中监听了reqData,只要reqData发生变化,getList()就会重新执行
5. 表格分页功能实现
其实在上述reqData,已经设置了分页的参数,就是page和per_page: 4,现在只需要在antd中把功能实现就行了
实现步骤
为Table组件指定pagination属性来展示分页效果
<Table rowKey="id" columns={columns} dataSource={list} pagination={{ current: reqData.page, pageSize: reqData.per_page, total: count, onChange: onPageChange, }} />
添加了个pagination属性,是个对象,对象里面放总页数,以及当前页码,页码切换时的回调
在分页切换事件中获取到筛选表单中选中的数据
// 分页实现 const onPageChange = (page) => { setReqData({ ...reqData, page }) }
使用当前页数据修改params参数依赖引起接口重新调用获取最新数据
这一步在onPageChange方法中已经实现,因为改变了reqData,所以会重新获取数据
6. 表格数据删除
实现效果
实现步骤
- 给删除文章按钮绑定点击事件
- 弹出确认窗口,询问用户是否确定删除文章
- 拿到参数调用删除接口,更新列表
这里得仔细说一下,在vue中,删除表格中的数据,通常的做法是,首先使用框架比如elementPlus渲染表头,表格的最后一列设置固定的内容,就是删除(或者编辑)按钮,当点击按钮时,elementPlus会把这一行的数据传递出去,我只需要在删除方法中利用这条数据执行数据库删除就行了
看看vue中的写法
<el-table-column fixed="right" label="操作" width="250">
<template #default="scope">
<el-button type="primary" size="small" @click="handleApply(scope.$index, scope.row)"
:disabled="scope.row.product_out_status == '申请出库'">申请出库</el-button>
<el-button type="success" size="small" @click="handleEdit(scope.$index, scope.row)"
:disabled="scope.row.product_out_status == '申请出库'">修改</el-button>
<el-popconfirm title="确定删除该产品吗?" @confirm="confirmDelete(scope.row)">
<template #reference>
<el-button type="danger" size="small" :disabled="scope.row.product_out_status == '申请出库'">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
在vue中,执行删除当前行的数据,不需要表头提供,scope提供了当前行的数据,在confirmDelete中执行相关的逻辑就行了
但是在react+antD中,逻辑变化很大
- 删除的页面渲染放在了表头中
- 传递当前数据用的是render方法
- render传递默认参数就是当前行数据
先看看表头中是怎么渲染的
render: (data) => {
return (
<Space size="middle">
<Button
type="primary"
shape="circle"
icon={<EditOutlined />}
onClick={() => navigate(`/publish?id=${data.id}`)}
/>
<Popconfirm title="删除?" onConfirm={() => onDelete(data)}>
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
);
},
其实没啥好说的,思路差不多,但是写法实在是不能接受,相当混乱,在定义的数据中渲染html结构,以及包含处理逻辑,我凌乱了。。。
回到正文
其实现在就是要完成onDelete方法中的逻辑就行了
删除文章的接口
export const deleteArticleListAPI = (data) => { return http({ url: `/mp/articles/${data.id}`, method: "DELETE", }); };
接口需要传递文章id
页面调用接口并执行删除逻辑
// 删除 const onDelete = async row => { await deleteArticleListAPI({ id: row.id }); getList() }
7. 编辑文章跳转publish页面
点击编辑文章时,应该跳转到编辑文章页面,并携带当前行数据过去
先实现跳转
{
title: "操作",
render: (data) => {
return (
<Space size="middle">
<Button
type="primary"
shape="circle"
icon={<EditOutlined />}
onClick={() => navigate(`/publish?id=${data.id}`)}
/>
<Popconfirm title="删除?" onConfirm={() => onDelete(data)}>
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
);
},
},
六、编辑文章
1. 数据回填
从管理文章页面跳转过来的,需要把文章信息回填Form表单
这里就需要根据id获取数据了,需要用到router中的useSearchParams方法
实现步骤
编写根据id获取文章的接口
export const getArticleAPI = (data) => { return http({ url: `/mp/articles/${data.id}`, }); };
根据路由参数获取id,并从后端获取文章数据
数据回填
// 回填数据 const [searchParams] = useSearchParams(); const articleId = searchParams.get("id"); const [form] = Form.useForm(); const getArticle = async () => { // console.log(articleId) const res = await getArticleAPI({ id: articleId }); const data = res.data; const { cover } = data; // console.log(res); form.setFieldsValue({ ...data, type: cover.type, }); // 回填封面 setImageType(cover.type); setImageList( cover.images.map((item) => { return { url: item }; }) ); };
副作用函数中调用,并监控form数据的变化
useEffect(() => { fetchChannels(); if (articleId) getArticle(); }, [articleId, form]);
2. 面包屑适配标题
根据是否有articleId适配
<Breadcrumb
items={[
{ title: <Link to={"/"}>首页</Link> },
{ title: `${articleId ? '编辑文章' : '发布文章'}` },
]}
/>
}
3. 更新文章
需要修改之前发布文章的代码,根据articleId调用不同的接口
实现步骤
编写编辑文章的接口
// 编辑 export const editAPI = (data) => { return http({ url: `/mp/articles/${data.id}?draft=false`, method: "PUT", data, }); };
修改onFinish方法,根据articleId调用不同的数据接口
const onFinish = async (values) => { if (imageList.length !== imageType) return message.warning("封面图片和数量不一致"); const { channel_id, content, title } = values; const data = { channel_id, content, title, cover: { type: imageType, images: imageList.map((item) => { if (item.response) { // 新增逻辑 return item.response.data.url; } else { // 回填逻辑 return item.url; } }), }, }; if (articleId) { // 编辑接口 await editAPI({ ...data, id: articleId }); message.success('编辑完成') } else { // 新增接口 await publishAPI(data); message.success('发布成功') } navigate('/article') };
至此,完成了这个react项目的所有实现逻辑
而我,大概会继续选用vue3作为我的前端框架写下去~~~
最后上一下演示视频