项目功能和技术栈介绍
首页展示了用户的信息, 以及各种图标; 左边有一些 Tab 标签, 有商品管理, 用户管理以及其他标签页. 用户可以增删改查. 菜单有多级菜单的显示, 同时有展开和收起的功能. 另外, 系统有鉴权的功能.
技术栈和功能见下图:

创建项目
这里直接使用 vite 进行快速创建项目:
直接选择最简单的 React+JS 即可, 不选择其他花里胡哨的东西了.
[!note] NPX 是什么
是一个高版本 NodeJS 的工具, 只要版本在 18.0 以上就好.
开发工具推荐 WebStorm, 当前最好用的, 最强大的同时免费的前端开发工具! 使用 WebStorm 打开刚才创建的项目, 应该可以看到这样的布局

然后直接启动项目, 安装依赖后就可以直接启动啦!
1 2 3 4 5
| npm install
npm run dev
|

这样一个最简单的项目就创建起来了.
为了方便, 我们可以对代码进行一定的简化, 保留基础的代码模板即可.
1 2 3 4 5 6 7 8 9 10
| function App() { return ( <> Hello World </> ) }
export default App
|
路由
介绍 & 安装路由
我们之前开发页面都使用的是各种页面组合起来, 但是我们的 React 是开发单页面应用的, 所以我们不可能写一堆的页面出来. 我们需要使用路由来实现对应的操作.
为了使用 Router, 我们需要下载 router 的依赖:
[!important] 注意
这里的版本号一定一致, 否则可能遇到无法解决的问题.
1
| npm i react-router-dom@6.22.2
|
至此路由就安装完毕了, 你可以在 package.json 中看到安装的内容:

主路由和次路由的实现
引言
我们直接使用 Router V6 的现代化路由编写方式: 配置路由的方式. 我们需要引入 createBrowserRouter 方法, 使用路由编写的方式来创建对应的路由. 我们可以按照 Vue 框架的路由编写习惯来实现.
[!note]
我们这里推荐不带哈希的路由模式
我们一般会用一个单独的模块来编写路由, 在 src 目录下面创建一个新的目录: router, 随后创建对应的 index.jsx 文件. 其中我们按照最新的写法来配置路由.
这里先明确一下: / 路由包裹了左边的路由区域以及上面的 Header 区域, 中间的子路由都是属于对应区域的. 这里我先创建一下对应的页面, 不然路由中无法引用这些配置项.
创建子页面
页面, 直接新建一个 pages 文件夹进行存放即可. 这里直接按照 Hooks 的写法来创建组件了, 大概页面有这些, 内容如下:

1 2 3 4 5 6 7 8 9
| const Main = () => { return ( <> <div>Main</div> </> ) }
export default Main
|
挂载路由
随后根据页面完善路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
import {createBrowserRouter} from 'react-router-dom';
import Main from "../pages/Main.jsx"; import Home from "../pages/Home/index.jsx";
const routes = [ { path: '/', element: <Main/>, children: [ { path: 'home', element: <Home/> } ] } ]
export default createBrowserRouter(routes)
|
挂载路由到页面上
我们的路由是全局的路由, 所以直接在 App 组件中进行包裹即可.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
import {RouterProvider} from "react-router-dom";
import router from "./router/index.jsx";
function App() { return ( <div className={"app"}> {/*使用路由容器组件 包裹整个东西*/} <RouterProvider router={router}/> </div> ) }
export default App
|
尝试查看效果:

似乎没有效果! 这是因为子路由的组件需要一个插槽放进去, home 是 / 的子路由, 所以我们来到 Main 中, 引入一个 Route 6 的新东西: <Outlet/> 代表子路由即将到达的地方即可:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import {Outlet} from "react-router-dom";
const Main = () => { return ( <> <div>Main</div> <Outlet/> </> ) }
export default Main
|
现在路由已经可以正常访问了.

剩余页面路由配置 & 重定向功能
还是一样的, 新的创建的页面如下:

页面的内容就不再多说, 还是基本的代码框架. 另外, 配置一下路由.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import {createBrowserRouter} from 'react-router-dom';
import Main from "../pages/Main.jsx"; import Home from "../pages/Home/index.jsx"; import Mall from "../pages/Mall/index.jsx"; import User from "../pages/User/index.jsx"; import PageOne from "../pages/Other/PageOne.jsx"; import PageTwo from "../pages/Other/PageTwo.jsx";
const routes = [ { path: '/', element: <Main/>, children: [ { path: 'home', element: <Home/> }, { path: 'mall', element: <Mall/> }, { path: 'user', element: <User/> }, { path: 'other', children: [ { path: 'page-one', element: <PageOne/> }, { path: 'page-two', element: <PageTwo/> } ] } ] } ]
export default createBrowserRouter(routes)
|
页面的路由已经可以正常访问了.

一般来说, 我们直接进入页面, 应当被直接重定向到 Home 页面. 这里只需要添加另外一个路由配置. 这里使用到一个新的路由组件: <Navifate/> , 重定向到某个路由, 使用 replace 方法.
1 2 3 4
| { path: '/', element: <Navigate to={'home'} replace={true}/> },
|
现在直接访问网站, 就会跳转到 home 目录了.
总体布局
引入现有的 layout 组件
这里使用 antd 组件库即可. 首先我们需要安装组件库以及对应的图标库:
1 2 3
| npm install antd --save npm install @ant-design/icons@5.x --save
|
安装后, 我们查阅官方文档, 可以找到一个和目标布局类似的 layout:

直接打开代码, 在 Main 组件中引入我们需要的代码块.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
| import React, {useState} from 'react'; import { MenuFoldOutlined, MenuUnfoldOutlined, UploadOutlined, UserOutlined, VideoCameraOutlined, } from '@ant-design/icons'; import {Button, Layout, Menu, theme} from 'antd';
const {Header, Sider, Content} = Layout;
const Main = () => { const [collapsed, setCollapsed] = useState(false); const { token: {colorBgContainer, borderRadiusLG}, } = theme.useToken(); return ( <Layout> <Sider trigger={null} collapsible collapsed={collapsed}> <div className="demo-logo-vertical"/> <Menu theme="dark" mode="inline" defaultSelectedKeys={['1']} items={[ { key: '1', icon: <UserOutlined/>, label: 'nav 1', }, { key: '2', icon: <VideoCameraOutlined/>, label: 'nav 2', }, { key: '3', icon: <UploadOutlined/>, label: 'nav 3', }, ]} /> </Sider> <Layout> <Header style={{padding: 0, background: colorBgContainer}}> <Button type="text" icon={collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>} onClick={() => setCollapsed(!collapsed)} style={{ fontSize: '16px', width: 64, height: 64, }} /> </Header> <Content style={{ margin: '24px 16px', padding: 24, minHeight: 280, background: colorBgContainer, borderRadius: borderRadiusLG, }} > Content </Content> </Layout> </Layout> ); }
export default Main
|
现在页面整体的布局以及出来了:

调整样式
对于 layout 组件, 我们需要添加以下 className, 让他的样式发生变化.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| * { padding: 0; margin: 0; box-sizing: border-box; font-size: 14px; }
.main-container { min-height: calc(100vh - 64px); }
.main-container .app-name { background-color: #001529; text-align: center; color: white; line-height: 64px; font-weight: bold; font-size: 16px; }
|
在 App 中使用这些样式即可.
1 2 3 4 5 6 7 8 9 10
| <Layout className={'main-container'}> <Sider trigger={null} collapsible collapsed={collapsed}> <div className="demo-logo-vertical"/> <Menu theme="dark" mode="inline" style={{ height: '100%' }} {...}
|
添加标题
在侧边栏的最上面是有一个标题的, 直接修改上面的 <div className="demo-logo-vertical"/> 为我们想要的标题就好, 这里用 <h3> 标签.
1 2
| {} <h3 className={"app-name"}>通用后台管理系统</h3>
|
现在页面已经初见雏形了:

左侧侧边栏
拆分为单个组件
根据组件化的开发流程, 我们自然要将侧边栏直接轴向出来, 作为一个单独的组件. 组件, 我们就在 src 的 components 目录下面进行开发了. 创建对应目录, 并且创建对应的组件:

我们将刚才的 Menu 组件部分直接放进去, 得到如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import React from 'react'; import { UploadOutlined, UserOutlined, VideoCameraOutlined, } from '@ant-design/icons'; import {Layout, Menu} from 'antd';
const {Sider} = Layout;
const CommonAside = () => { return ( <Sider trigger={null} collapsible> <h3 className={"app-name"}>通用后台管理系统</h3> <Menu theme="dark" mode="inline" style={{ height: '100%' }} defaultSelectedKeys={['1']} items={[ { key: '1', icon: <UserOutlined/>, label: 'nav 1', }, { key: '2', icon: <VideoCameraOutlined/>, label: 'nav 2', }, { key: '3', icon: <UploadOutlined/>, label: 'nav 3', }, ]} /> </Sider> ); };
export default CommonAside;
|
随后, 在 Main 中引入并且使用即可.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import React from 'react'; import {Button, Layout, theme} from 'antd';
const {Header, Content} = Layout;
import CommonAside from "../components/CommonAside/index.jsx";
const Main = () => { const { token: {colorBgContainer, borderRadiusLG}, } = theme.useToken(); return ( <Layout className={'main-container'}> {/*使用自定义侧边栏*/} <CommonAside/> <Layout> <Header style={{padding: 0, background: colorBgContainer}}> <Button type="text" style={{ fontSize: '16px', width: 64, height: 64, }} /> </Header> <Content style={{ margin: '24px 16px', padding: 24, minHeight: 280, background: colorBgContainer, borderRadius: borderRadiusLG, }} > Content </Content> </Layout> </Layout> ); }
export default Main
|
[!question] 这里遇到了一个问题
是否折叠的属性出现了跨组件的情况, 这里先按下不表, 删除对应的内容.
实现动态数据
刚才我们已经实现了组件的拆分, 但是数据是写死的, 我们不希望这样子实现. 这里将菜单的相关信息写在一个单独的 config 模块中. 新建文件夹并创建对应的 index.jsx, 写入配置项.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| import { HomeOutlined, ShopOutlined, UserOutlined, SettingOutlined } from '@ant-design/icons';
export default [ { path: '/home', name: 'home', label: '首页', icon: <HomeOutlined/>, url: '/home/index' }, { path: '/mall', name: 'mall', label: '商品管理', icon: <ShopOutlined/>, url: '/mall/index' }, { path: '/user', name: 'user', label: '用户管理', icon: <UserOutlined/>, url: '/user/index' }, { path: '/other', label: '其他', icon: <SettingOutlined/>, children: [ { path: '/other/page-one', name: 'page1', label: '页面1', icon: <SettingOutlined/> }, { path: '/other/page-two', name: 'page2', label: '页面2', icon: <SettingOutlined/> }, ] }, ]
|
这里面储存了图标以及路径, 我们在侧边栏中进行获取.
1 2
| import MenuConfig from '../../config/index.js';
|
这里我们操作的其实就是 Menu 标签, 需要传递一个数组结构的数据, 包含了各种属性, 比如 Icon 的图标之类的东西. 这里的数据肯定是需要我们进行处理的, 直接进行遍历处理. 随后拿到数据, 修改一下下面使用的 Items 就好, 最后代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| import React from 'react'; import {Layout, Menu} from 'antd';
import MenuConfig from '../../config';
const items = MenuConfig.map((item) => { const child = { key: item.path, icon: item.icon, label: item.label } if (item.children) { child.children = item.children.map((childItem) => { return { key: childItem.path, label: childItem.label, icon: childItem.icon } }) } return child; })
const {Sider} = Layout;
const CommonAside = () => { return ( <Sider trigger={null} collapsible> <h3 className={"app-name"}>通用后台管理系统</h3> <Menu theme="dark" mode="inline" style={{ height: '100%' }} defaultSelectedKeys={['1']} items={items} /> </Sider> ); };
export default CommonAside;
|

页面正常实现!
顶部区域
拆分组件
这里同理, 抽象为一个组件即可.

内容直接拿过来, 不过把原来的 style 直接删掉, 保留如下代码就好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import React from 'react'; import {Button, Layout} from 'antd';
const {Header} = Layout;
const CommonHeader = () => { return ( <Header> <Button type="text" style={{ fontSize: '16px', width: 64, height: 64, }} /> </Header> ); };
export default CommonHeader;
|
实现右侧头像
我们优先查看有没有对应的组件, 很幸运地找到了这样的组件: Avatar

既然找到了, 就开始使用吧. 既然是图片, 我们自然需要有一个能放图片的地方. 这里可以使用在线的图床, 也可以使用本地的文件夹, 效果一致.
1 2
| {} <Avatar src={'https://pic1.imgdb.cn/item/682827f958cb8da5c8f88858.png'}/>
|

头像加载到了, 但是位置似乎不对. 我们接下来就修改这个图片的位置. 最快的方式是使用 Flex 布局.
1 2 3 4 5 6 7
| .header-container { display: flex; justify-content: space-between; align-items: center; }
|
引入样式并且对其他的组件进行调整, 可以得到如下完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import React from 'react'; import {Button, Layout, Avatar} from 'antd'; import {MenuFoldOutlined} from "@ant-design/icons";
const {Header} = Layout;
import './index.css'
const CommonHeader = () => { return ( <Header className={"header-container"}> {/*显示图标Node组件就好*/} <Button type="text" icon={<MenuFoldOutlined/>} style={{ fontSize: '16px', width: 64, height: 32, backgroundColor: '#fff' }} /> {/*实现头像*/} <Avatar size={36} src={'https://pic1.imgdb.cn/item/682827f958cb8da5c8f88858.png'}/> </Header> ); };
export default CommonHeader;
|
右侧图标下拉菜单
还是一样查找文档就好.

回到代码中, 引入 DropDown, 并且定义菜单的数据.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const logout = () => {
}
const items = [ { key: '1', label: ( <a target={"_blank"} rel={"noopener noreferrer"}>个人中心</a> ) }, { key: '2', label: ( <a onClick={logout} target={"_blank"} rel={"noopener noreferrer"}>退出</a> ) }, ]
|
对于 dropdown 组件, 需要包裹想要有下拉菜单的组件并且配置菜单即可:
1 2 3
| <Dropdown menu={{items}}> <Avatar size={36} src={'https://pic1.imgdb.cn/item/682827f958cb8da5c8f88858.png'}/> </Dropdown>
|
[!important] 注意
这里需要写两层 {{}}, 因为属性包含了对象, 否则会报错的.
至此, Header 样式已经实现了.

引入全局状态管理
介绍
我们虽然可以使用 props 配合函数调用来实现, 但是比较复杂, 并不推荐. React 中, 存在这样一个插件来解决问题: redux, 这是一个用于 JS 的可预测状态容器.
同理, 我们先安装需要的依赖:
1
| npm install @reduxjs/toolkit react-redux
|
创建 store
创建一个新的目录: store 用来实现状态管理. 另外, 状态管理的本质是 reducers, 所以也创建对应的文件夹, 得到了如下目录结构:

正式开始编写代码. 来到 index.jsx 中, 首先引入需要的 toolkit, 使用方法创建 store; 不过不过, 创建 store 的方法需要一个 reducer, 所以我们还需要在 reducer 目录中创建对应的 reducer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import {createSlice} from '@reduxjs/toolkit'
const tabSlice = createSlice({ name: 'tab', initialState: { isCollapse: false }, reducers: { collapseMenu: (state) => { state.isCollapse = !state.isCollapse; } } })
export const {collapseMenu} = tabSlice.actions export default tabSlice.reducer
|
随后, 在 store 中引入使用就好.
1 2 3 4 5 6 7 8
| import {configureStore} from '@reduxjs/toolkit' import tab_reducer from "./reducers/tab_reducer.js";
export default configureStore({ reducer: { tab_reducer } })
|
挂载 store
我们需要进行挂载, 找到根目录, 挂载到根节点就好, 也就是 main.jsx 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import {createRoot} from 'react-dom/client' import App from './App.jsx'
import {Provider} from "react-redux";
import store from "./store/index.jsx";
createRoot(document.getElementById('root')).render( <Provider store={store}> <App/> </Provider> )
|
使用 store
直接来到刚才的顶栏, 找到我们的点击按钮, 添加一个点击事件.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const setCollapsed = () => { }
...
<Button type="text" icon={<MenuFoldOutlined/>} style={{ fontSize: '16px', width: 64, height: 32, backgroundColor: '#fff' }} onClick={setCollapsed} />
|
这里想一想, 这些状态不知自己需要用到, 其实兄弟组件也是需要用到的, 那么为什么不直接在父组件中获取属性, 然后再进行传递呢? 这样效率会更高一些的. 干脆来到 Main 组件, 使用钩子来获取状态: useSelector, 获取后传递给子组件.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| ...
import {useSelector} from 'react-redux'
const Main = () => { ... const collapse = useSelector((state) => { return state.tab_reducer.isCollapse })
return ( <Layout className={'main-container'}> {/*使用自定义侧边栏*/} <CommonAside collapsed={collapse}/> <Layout> {/*使用自定义顶栏*/} <CommonHeader collapsed={collapse}/> ... </Layout> ... </Layout> ); }
export default Main
|
子组件中, 通过解构的方式来获取传入的属性.
1 2
| const CommonHeader = ({collapsed}) => {...}
|
实现菜单展开收起
基于刚才的 store 属性, 就可以实现该功能了. 先实现修改状态的部分, 来到点击事件, 通过 useDispatch 以及对应的 action 配合实现对应的功能.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import {useDispatch} from "react-redux"; import {collapseMenu} from '../../store/reducers/tab_reducer.js'
const CommonHeader = ({collapsed}) => { ... const dispatch = useDispatch(); const setCollapsed = () => { console.log(collapsed); dispatch(collapseMenu()); } ... };
|
菜单的展开和收起是一个参数, 直接配合这个属性即可实现效果. 这里的属性是 Sider 组件的属性, 不要添加错了. 回到 Sider, 做出如下代码修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const CommonAside = ({collapsed}) => { return ( {} <Sider trigger={null} collapsible collapsed={collapsed}> <h3 className={"app-name"}>通用后台管理系统</h3> <Menu theme="dark" mode="inline" style={{ height: '100%' }} defaultSelectedKeys={['1']} items={items} /> </Sider> ); };
|
查看当前样式, 展开收起以及基本实现:

这里的文本还没有设置好, 只需要添加一个三元表达式来解决问题就好.
1
| <h3 className={"app-name"}>{collapsed ? "后台" : '通用后台管理系统'}</h3>
|
现在的基本效果以及实现了.

首页部分
首页用户 card 显示
直接来到首页, Home 组件进行代码编写. 这里我们需要使用到栅格组件, 其实就是 Col 和 Row 的组合, 从 antd 引入就好.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import './home.css'
import {Col, Row} from "antd";
const Home = () => { return ( <> <Row className={"home"}> {/*实现左右布局 通过span配置比例*/} <Col span={8}> {/*实现用户的卡片*/} <div></div> </Col> <Col span={16}></Col> </Row> </> ) }
export default Home
|
我们先实现用户的卡片. 用户卡片有一个鼠标移动上去就有阴影的效果, 通过查阅文档, 找到了类似组件:

那么直接引入, 使用就好.
[!todo] 修复Bug
之前再Main.jsx中, 没有给子路由留接口, 使用<Outlet/>, 让子路由进来:
为了修改头像样式, 添加一个 css 到 home.css 中; 同时为了好看, 可以实现如下布局以及 css.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| .home img { width: 10rem; border-radius: 50%; margin-right: 4rem; }
.home .name { font-size: 32px; margin-bottom: 10px; }
.home .user { display: flex; align-items: center; padding-bottom: 2rem; margin-bottom: 2rem; border-bottom: 1px solid #ccc; }
.home .login-info { font-size: 14px; line-height: 28px; color: #999999; }
.home .login-info p span { line-height: 28px; color: #666666; margin-left: 60px; }
|
Home 组件主要如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const Home = () => { return ( <> <Row className={"home"}> {/*实现左右布局 通过span配置比例*/} <Col span={8}> {/*实现用户的卡片*/} <Card hoverable={true}> <div className="user"> {/*一行内的左右布局 还是flex 另外这里面有用户头像之类的东西*/} <img src="https://pic1.imgdb.cn/item/682827f958cb8da5c8f88858.png"/> <div className="userinfo"> {/*用户相关的内容信息*/} <p className="name">Admin</p> <p className="access">超级管理员</p> </div> </div> {/*下面的登陆信息*/} <div className="login-info"> <p>上次登陆时间: <span>2025-5-17</span></p> <p>上次登陆地点: <span>深圳</span></p> </div> </Card> </Col> <Col span={16}></Col> </Row> </> ) }
|
现在页面显示已经初见雏形了.

首页用户 table 显示
axios 介绍与封装
我们的数据肯定是从后台拿过来的, 所以这里使用 axios 来获取数据. 这是一个网络请求库, 自带很多的网络请求以及逻辑处理工具. 使用 npm 进行安装即可.
为什么要做 axios 进行二次封装呢? 其实我们的封装都是基于 axios 的实例对象. 不过我们可以直接定义一些固定的东西, 这样修改就只需要修改一个部分, 不需要挨个修改了.
代码中, axios 相关的东西, 我们会创建一个文件夹叫做 api, 其中定义一个文件叫做 axios.js, 随后我们将 axios 的二次封装逻辑写在这个文件当中.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| import axios from "axios";
const baseUrl = '/api'
class HttpRequest { constructor(baseUrl) { this.baseUrl = baseUrl; }
getInsideConfig() { const config = { baseUrl: this.baseUrl, header: {} } return config; }
interception(instance) { instance.interceptors.request.use(function (config) { return config; }, function (error) { return Promise.reject(error); });
instance.interceptors.response.use(function (response) { return response; }, function (error) { return Promise.reject(error); }); }
request(options) { options = {...this.getInsideConfig(), ...options} const instance = axios.create() this.interception(instance) return instance(options) } }
export default new HttpRequest(baseUrl)
|
随后, 定义 index.js 作为 api 的引入, 直接返回对应的接口调用逻辑就好.
1 2 3 4 5 6 7 8
| import http from './axios.js'
export const getData = ()=>{ return http.request({ url: '/home/getData', method: 'get' }) }
|
mock 实现请求拦截 & 模拟接口数据
回到 Home 组件中, 引入我们实现的 API. 现在我们没有后端的数据, 就只能通过 mock 来实现数据的模拟. 可以来到 mock 的官网看看:
1 2 3
| url: http://mockjs.com/ title: "Mock.js" host: mockjs.com
|
我们主要使用的是 Mock.mock() 方法, 传入需要拦截的 url, 或者 url 正则, 以及拦截的请求类型, 以及拦截需要做的事情, 就可以进行拦截了.
还是一样, 首先安装 mock
随后, 我们需要定义 mock 的数据, 数据直接写在 api 目录下面了. 创建 mock.js, 写入如下代码:
1 2 3 4 5 6 7 8
| import Mock from 'mockjs'
Mock.mock(/home\/getData/, () => { console.log("被拦截的 getData 接口") })
|
随后在入口文件进行引入, 来到 main.jsx ,写入如下代码:
现在控制台已经能够接收到拦截的请求了:

导入 mock 数据
这里直接使用已经准备好的伪数据就好, 点我下载:

直接解压在 api 目录下就好:

文件内容不需要更改, 直接前往 mock.js 进行引入就好.
1 2 3 4 5 6 7 8 9
|
import Mock from 'mockjs'
import HomeApi from './mockServeData/home.js'
Mock.mock(/home\/getData/, HomeApi.getStatisticalData)
|
随后前往 Home 组件中, 在副作用函数中输出这些数据看看:
1 2 3 4 5 6
| useEffect(() => { getData().then((res) => { console.log(res) }) }, []);
|

数据已经全部拿到了, 存在 data.data 中.
table 数据展示
根据请求得到的数据, 就可以开始进行渲染了. 列表功能, 其实官方也是有的: Tabel 表格:

这里我们可以通过输出观察这个 tableData 在哪里, 然后使用 state 配合解构赋值, 获取 tableState

1 2 3 4 5 6 7 8 9 10 11
| const [tableData, setTableData] = useState([])
useEffect(() => { getData().then(({data}) => { const {tableData} = data.data; setTableData(tableData); }) }, []);
|
另外, 还需要列的数据, 这里的列是静态的, 直接使用下面的这些就好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const columns = [ { title: '课程', dataIndex: 'name' }, { title: '今日购买', dataIndex: 'todayBuy' }, { title: '本月购买', dataIndex: 'monthBuy' }, { title: '总购买', dataIndex: 'totalBuy' } ]
|
随后, 使用组件就好:
1 2 3 4 5
| {} <Card> {} <Table columns={columns} dataSource={tableData}/> </Card>
|
回到页面, 效果已经实现了:

取消分页
我们其实不需要这个分页, 直接传递一个属性就好.
1
| <Table columns={columns} dataSource={tableData} pagination={false}/>
|

添加 key 以解决报错
虽然看起来已经成功了, 但是不妨打开控制台看看情况:

还需要一个 key 属性. 通过查阅文档, 只需要添加一个 rowKey 的属性就好, 非常简单:
1
| <Table rowKey={"name"} columns={columns} dataSource={tableData} pagination={false}/>
|
到这里, 就算实现 table 了.
首页订单统计实现
其实就是六个栅格的事情. 我们还是直接使用一个栅格布局来实现对应的效果.
实现图标
另外, 这里的数据也是静态的数据, 直接写就好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| import { CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined } from "@ant-design/icons";
const countData = [ { "name": "今日支付订单", "value": 1234, "icon": <CheckCircleOutlined/>, "color": "#2ec7c9" }, { "name": "今日收藏订单", "value": 3421, "icon": <ClockCircleOutlined/>, "color": "#ffb980" }, { "name": "今日未支付订单", "value": 1234, "icon": <CloseCircleOutlined/>, "color": "#5ab1ef" }, { "name": "本月支付订单", "value": 1234, "icon": <CheckCircleOutlined/>, "color": "#2ec7c9" }, { "name": "本月收藏订单", "value": 3421, "icon": <ClockCircleOutlined/>, "color": "#ffb980" }, { "name": "本月未支付订单", "value": 1234, "icon": <CloseCircleOutlined/>, "color": "#5ab1ef" } ]
|
随后, 我们遍历这个列表, 返回需要的组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <Col span={16}> <div className={"num"}> { /*这里需要走列表渲染 所以写一个花括号 进行列表渲染*/ countData.map((item, index) => { // 通过return 返回子项目 return ( /*每个组件都是一个卡片*/ <Card key={index}> {/*卡片内部分为左侧的图标和右侧的文本*/} <div className={"icon-box"}> {/*直接使用传入的图标组件就好*/} {item.icon} </div> <div className={"detail"}></div> </Card> ) }) } </div> </Col>
|
现在图标已经可以正常进行引入了:

实现文本
其实就是直接传递即可, 重要一些的还是布局.
1 2 3 4 5 6 7 8 9 10 11
| <Card key={index}> {} <div className={"icon-box"}> {} {item.icon} </div> <div className={"detail"}> <p className={'num'}>¥{item.value}</p> <p className={'txt'}>{item.name}</p> </div> </Card>
|
实现样式
这里显然使用的是 flex 布局. 无论如何, HOME 组件可以参考如下的 css 代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
| .home { } .home .user { display: flex; align-items: center; padding-bottom: 20px; margin-bottom: 20px; border-bottom: 1px solid #ccc; } .home img { width: 150px; height: 150px; border-radius: 50%; margin-right: 40px; } .home .login-info p{ line-height: 28px; font-size: 14px; color: #999999; } .home .login-info p span{ color: #666666; margin-left: 60px; } .home .num { margin-left: 5px; display: flex; flex-wrap: wrap; justify-content: space-between; } .home .num .el-card { width: 32%; margin-bottom: 20px; } .home .num .icon { font-size: 30px; width: 80px; height: 80px; text-align: center; line-height: 80px; color: #fff; } .home .num .detail { margin-left: 15px; display: flex; flex-direction: column; justify-content: center; } .home .num .detail .num { font-size: 30px; margin-bottom: 10px; } .home .num .detail .txt { font-size: 14px; text-align: center; color: #999999; } .home .graph { margin-top: 20px; display: flex; } .home .graph .el-card { width: 48%; } .home .userinfo .name { font-size: 32px; margin-bottom: 10px; } .home .anticon { font-size: 30px; } .home .icon-box { width: 80px; height: 80px; text-align: center; line-height: 85px; color: #fff; border-top-left-radius: 5px; border-bottom-left-radius: 5px; } .home .num .ant-card { width: 33%; } .home .num .ant-card-body { padding: 10px 25px; display: flex; }
|
现在观察页面, 其实内容已经有了, 但是图标的颜色没了:

这里要写的是一个动态的 style, 直接在代码中进行实现即可.
1 2 3 4
| <div style={{background: item.color}} className={"icon-box"}> {} {item.icon} </div>
|
现在主页面已经可以看出来点东西了!

首页图表统计部分
Echarts 介绍与引入
我们需要用到各种图表, 肯定不可能自己写, 所以我们使用一个专门做图表统计的库, 直接安装:
这里我们直接来到 Home 主页进行编写. 首先引入 echarts:
1 2
| import * as echarts from 'echarts'
|
ECharts 的使用需要有一个组件. 我们直接在最下面写一个 div 以供使用.
1 2
| {} <div id={"main"}></div>
|
随后, 我们需要对应的初始化逻辑.
[!caution] 注意
ECharts的渲染是DOM渲染之后, 所以需要使用钩子 useEffect
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| useEffect(() => { getData().then(({data}) => {...}) const myChart = echarts.init(document.getElementById('main')); myChart.setOption({ title: { text: 'ECharts 入门图表' }, xAxis: { data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子'] }, yAxis: {}, series: [ { name: '销量', type: 'bar', data: [5, 20, 15, 16, 30, 15] } ] }) }, []);
|
回到页面:

然而什么都没有出现. 这是因为默认的 div 高度为 0, 我们需要手动的指定高度.
1 2
| {} <div id={"main"} style={{height: '300px'}}></div>
|
指定高度后, 已经可以正常显示出来这个表格.

Echarts 组件封装
如果有多个图表, 全部写在 Home 组件中, 就会出现一些问题. 我们不如直接封装一个组件出来. 在 components 中, 创建一个新的组件: ECharts.

我们定义属性的关键是功能. 首先实现最基本的代码模板, 然后引入 echarts, 同时写好 echarts 的配置数据. 这里的配置数据直接是静态的, 方便使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| import React from 'react';
import * as echarts from 'echarts';
const axisOption = { textStyle: { color: "#333", }, tooltip: { trigger: "axis", }, xAxis: { type: "category", data: [], axisLine: { lineStyle: { color: "#17b3a3", }, }, axisLabel: { interval: 0, color: "#333", }, }, yAxis: [ { type: "value", axisLine: { lineStyle: { color: "#17b3a3", }, }, }, ], color: ["#2ec7c9", "#b6a2de", "#5ab1ef", "#ffb980", "#d87a80", "#8d98b3"], series: [], }
const normalOption = { tooltip: { trigger: "item", }, color: [ "#0f78f4", "#dd536b", "#9462e5", "#a6a6a6", "#e1bb22", "#39c362", "#3ed1cf", ], series: [], }
const ECharts = () => { return ( <div> </div> ); };
export default ECharts;
|
[!note] 关于两种数据
一个是有坐标系的图, 另外一个是没有坐标系的图.
完善代码可以得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| const ECharts = ({style, chartData, isAxisChart = true}) => { const echartRef = useRef() const echartObj = useRef(null)
useEffect(() => { let option; if (!echartObj.current) { echartObj.current = echarts.init(echartRef.current) } if (isAxisChart) { axisOption.xAxis.data = chartData.xData; axisOption.series = chartData.series; option = axisOption; } else { normalOption.series = chartData.series; option = normalOption; } echartObj.current.setOption(option, true); }, [chartData]);
return ( <div style={style} ref={echartRef}></div> ); };
export default ECharts;
|
随后, 回到 Home 中, 可以删除刚才的测试代码, 引入我们自己封装好的组件.
1 2
| import MyECharts from "../../components/ECharts/index.jsx";
|
到下面应当放入图表的地方, 直接写一个表格组件就可以了. 另外, 这里还缺少了数据, 数据也是来源于 mock 的, 所以我们还是在副作用函数中进行获取.
实现折线图
根据 mock 数据的需求, 修改副作用函数; 考虑到需要传参, 使用钩子函数创建响应式数据.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| const [EChartsData, setEChartsData] = useState({});
...
useEffect(() => { getData().then(({data}) => { const {tableData, orderData} = data.data; setTableData(tableData); const order = orderData; const xData = order.date; const keyArray = Object.keys(order.data[0]) const series = []; keyArray.forEach(key => { series.push({ name: key, data: order.data.map(item => item[key]), type: 'line' }) }) setEChartsData({ order: { xData, series } }); }) }, []);
|
下方随即使用该数据:
1
| <MyECharts style={{height: '280px'}} chartData={EChartsData.order}/>
|
回到页面尝试查看:

报错了! 没有这个 data 属性. 这是因为我们的初始值为空对象, 没有 order 属性, 所以会直接报错. 我们需要做一个容错的判断: 只有当 order 有数据的时候再进行渲染.
1
| {eChartsData.order && <MyECharts style={{height: '280px'}} chartData={eChartsData.order}/>}
|
至此, 折线图已经可以正常显示了:

[!failure] 注意点
务必保证拼写正确, 否则无法显示效果.
比如注意 serise 和 series, 以及 data 和 date 的使用.
实现柱状图和饼状图
获取数据
用户部分的柱状图, 这个比较简单, 直接在设置的时候进行获取就好.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| setEChartsData({ order: { xData, series }, user: { xData: userData.map(item => item.date), series: [ { name: '新增用户', data: userData.map(item => item.new), type: 'bar' }, { name: '活跃用户', data: userData.map(item => item.active), type: 'bar' }, ] } });
|
另外, 饼状图需要的就是一个 name 一个 value, 我们的数据刚好是符合规范的, 所以直接追加在同级后面就可以了.
1 2 3 4 5 6 7 8 9 10 11
| order: {...}, user: {...},
video: { series: [ { data: videoData, type: 'pie' } ] }
|
实现图表部分
考虑到两个图是一行内的, 所以需要单独实现对应的类的效果. 使用 flex 布局:
1 2 3 4
| .home .graph { margin-top: 20px; display: flex; }
|
随后分别显示对应的图表就好.
1 2 3 4 5 6 7 8 9 10 11
| {} {eChartsData.order && <MyECharts style={{height: '280px'}} chartData={eChartsData.order}/>} <div className={"graph"}> {} {eChartsData.user && <MyECharts style={{height: '240px', width: '50%'}} chartData={eChartsData.user}/>} {} {eChartsData.video && <MyECharts style={{height: '260px', width: '50%'}} isAxisChart={false} chartData={eChartsData.video}/>} </div>
|

用户部分
菜单点击跳转
现在我们实际上还不能切换菜单, 因为我们没有实现对应的逻辑. 这里我们可以来到侧边栏的部分实现这个逻辑. 打开 CommonAside 组件进行代码编写.
这里是菜单的点击, 所以我们给菜单添加 onClick 属性. 通过查阅文档, 发现点击后, 会自动传入一个事件对象. 尝试 console 也是一样的:

这里的 key 就是我们需要的路径, 所以我们只需要根据这个 key 进行路由的跳转就好. 这里可以使用编程式路由来实现对应的效果. 我们引入 useNavigate.
1 2
| import {useNavigate} from "react-router-dom";
|
随后实例化, 并且结合点击事件就可以进行路由的跳转了.
1 2 3 4 5 6 7 8 9
| const navigate = useNavigate();
const selectMenu = (event) => { navigate(event.key); }
|
效果还是非常不错的!

用户界面上面的按钮
就是最上面的一条内容. 这里肯定是 flex 实现最为简单, 所以包裹一个 flex 布局就好. 这里写点击事件的时候, 需要考虑到复用性, 所以可以按照函数柯里化的写法, 或者直接写一个箭头函数, 进而传参.
1
| <Button type={"primary"} onClick={() => handleClick('add')}>新增</Button>
|
接下来是右边的 form 表单, 这个部分可以直接使用 antd 自带的组件.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| const User = () => { const handleClick = () => { }
return ( <div className={"user"}> <div className="flex-box"> {/*NOTE 左侧 新增按钮*/} <Button type={"primary"} onClick={() => handleClick('add')}>新增</Button> {/*NOTE 右边 Form表单*/} {/*希望显示在一行内 所以需要设置layout*/} <Form layout={"inline"}> {/*里面有一些东西 */} </Form> </div> </div> ); };
|
这里的 Form 组件存在一个表单域 Form.Item, 只要表单域其中有提交, 就会触发一个 onFinish 的函数. 也可以写一下.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| return ( <div className={"user"}> <div className="flex-box"> {/*NOTE 左侧 新增按钮*/} <Button type={"primary"} onClick={() => handleClick('add')}>新增</Button> {/*NOTE 右边 Form表单*/} {/*希望显示在一行内 所以需要设置layout*/} <Form layout={"inline"} onFinish={handleFinish}> {/* 里面表单的内容 这里需要使用一个东西包裹起来 */} <Form.Item name='keyword'> {/*这里面就是表单域了 可以添加表单元素*/} <Input placeholder={'请输入用户名'}/> </Form.Item> <Form.Item> <Button htmlType={'submit'} type={'primary'}>搜索</Button> </Form.Item> </Form> </div> </div> );
|
尝试触发回调函数看看传入了什么东西.

表单里面的东西我们已经拿到了, 但是这里发现样式不太对, 改一下 css 就好:
1 2 3 4 5 6 7
| .user .flex-box { display: flex; } .user .space-between { justify-content: space-between; }
|
组件中使用样式:
1 2 3 4 5
| <div className={"user"}> <div className="flex-box space-between"> ... </div> </div>
|
用户列表数据
引入 MOCK
自然还是通过接口返回的. 我们需要在页面首次加载的时候访问接口. 自然使用钩子函数来模拟页面首次加载. 另外数据的来源是 mock, 所以我们回到 mock 文件进行适当修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import Mock from 'mockjs'
import HomeApi from './mockServeData/home.js'
import UserAPI from './mockServeData/user.js'
Mock.mock(/home\/getData/, HomeApi.getStatisticalData)
Mock.mock(/user\/getUser/, UserAPI.getUserList)
|
定义 API
我们有了 mock 还不够, 还需要在 api 中进行使用. 在 api 的 index.js 中, 实现一个请求的方法.
1 2 3 4 5 6 7
| export const getUser = (params) => { return http.request({ url: '/user/getUser', method: 'get', params }) }
|
一般来说都是需要传参的, 所以直接写上 params 参数就好.
获取数据
随后, 在 User 中调用接口, 尝试输出返回值进行查看:
1 2 3 4 5 6 7 8 9 10
| import {getUser} from '../../api'
const getTableData = () => { getUser().then((res) => { console.log(res); }) }
|

数据已经获取成功.
[!note] 为什么 params 参数未定义也能获取?
因为 mock 定义的时候存在默认参数, 我们只需要传入 name, 也就是需要搜索的名称就可以了.
这里我们改一下, 定义一个名称, 这个名称就是搜索时候的名称了.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| const getTableData = (params) => { getUser(params).then((res) => { console.log(res); }) }
...进入类中...
const [listData, setListData] = useState({ name: '' })
const handleFinish = (e) => { setListData({ name: e.name, }) }
useEffect(() => { getTableData(listData); }, []);
|
实现 table 效果
同理, 直接引入一个 table 进行渲染就好. 对于表格组件, 我们需要三个必要的东西. 数据来源于接口, 那么就需要 state 进行储存.
1 2
| const [tableData, setTableData] = useState([])
|
随后, 结合刚才的获取数据函数, 将数据进行保存.
1 2 3 4 5 6 7
| const getTableData = (params) => { getUser(params).then(({data}) => { setTableData(data.list) }) }
|
另外, 如果想要使用表格, 则需要三个必要的属性: rowKey, dataSource, 以及 columns. 这里的 rowKey 很显然, 就是 id; dataSource 就是上面我们以及定义好的状态; 但是 columns 比较特殊.
这里的列中, 性别是需要进行特殊判断的, 因为传入的是 0 或者 1, 我们要的是男或者女. 这里使用 render 单独设置就好:
1 2 3 4 5 6 7 8
| { title: '性别', dataIndex: 'sex', render: (val) => { return val ? '女' : '男'; } },
|
另外, 最后一列为操作列, 比较特殊, 也需要进行修改. 其中, 修改按钮的点击回调函数还是刚才的函数, 所以需要单独设置; 另外删除按钮需要包裹, 进而实现一个气泡弹窗的效果.
最后可以得到如下 User 的完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
| const User = () => { const [listData, setListData] = useState({ name: '' })
const [tableData, setTableData] = useState([])
const getTableData = (params) => { getUser(params).then(({data}) => { setTableData(data.list) }) }
const handleClick = (type, rowData) => { }
const handldDelete = (rowData) => {
}
const columns = [ { title: '姓名', dataIndex: 'name' }, { title: '年龄', dataIndex: 'age' }, { title: '性别', dataIndex: 'sex', render: (val) => { return val ? '女' : '男'; } }, { title: '出生日期', dataIndex: 'birth' }, { title: '地址', dataIndex: 'addr' }, { title: '操作', render: (rowData) => { return ( <div className={'flex-box'}> <Button style={{marginRight: "5px"}} onClick={() => handleClick('edit', rowData)}>编辑</Button> <Popconfirm title={'提示'} description={'此操作将会删除该用户, 是否确认'} okText={"确认"} cancelText={"取消"} onConfirm={() => handldDelete(rowData)} > <Button type={"primary"} danger>删除</Button> </Popconfirm> </div> ) } }, ]
const handleFinish = (e) => { setListData({ name: e.name, }) }
useEffect(() => { getTableData(listData); }, []);
return ( <div className={"user"}> <div className="flex-box space-between"> {/*NOTE 左侧 新增按钮*/} <Button type={"primary"} onClick={() => handleClick('add')}>新增</Button> {/*NOTE 右边 Form表单*/} {/*希望显示在一行内 所以需要设置layout*/} <Form layout={"inline"} onFinish={handleFinish}> {/* 里面表单的内容 这里需要使用一个东西包裹起来 */} <Form.Item name='keyword'> {/*这里面就是表单域了 可以添加表单元素*/} <Input placeholder={'请输入用户名'}/> </Form.Item> <Form.Item> <Button htmlType={'submit'} type={'primary'}>搜索</Button> </Form.Item> </Form> </div> {/*表格组件*/} <Table columns={columns} dataSource={tableData} rowKey={'id'}/> </div> ); };
|
回到页面上, 效果已经正常进行显示了.

用户新增数据
实现弹窗显示 & 关闭
这里的新增表单和修改表单, 弹出的内容其实是一样的, 不过使用的接口不一样. 这里的弹窗其实就是一个模态框, 直接查阅文档就可以找到了:

直接引入, 写在 Table 的下面就好.
1 2 3 4 5
| {} <Table columns={columns} dataSource={tableData} rowKey={'id'}/> {} <Modal> </Modal>
|
这里的 Modal 对话框, 需要一个 title 属性, 我们应当根据状态来进行确认. 再次定义一些状态.
1 2 3 4 5 6 7
| const [modalType, setModalType] = useState(0);
...
<Modal title={modalType ? '编辑用户' : "新增用户"}> </Modal>
|
什么时候进行状态的修改呢? 当然是点击按钮的时候.
1 2 3 4 5 6 7 8 9
| const handleClick = (type, rowData) => { if (type === 'add') { setModalType(0) } else { setModalType(1) } }
|
另外, 我们需要判断这个弹窗什么时候打开, 需要传入一个 open 属性. 根据状态判断, 再写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const [isModalOpen, setIsModalOpen] = useState(false); const handleClick = (type, rowData) => { if (type === 'add') { setModalType(0) } else { setModalType(1) } setIsModalOpen(!isModalOpen); }
<Modal title={modalType ? '编辑用户' : "新增用户"} open={isModalOpen}> </Modal>
|
这里再添加一下点击 OK 和点击取消的按钮文本以及对应的事件.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const handleOK = () => { }
const handleCancel = () => { }
...
<Modal title={modalType ? '编辑用户' : "新增用户"} open={isModalOpen} onOk={handleOK} onCancel={handleCancel} okText={"确定"} cancelText={"取消"} ></Modal>
|
另外, 我们在确认或者关闭的时候, 都是需要关闭弹窗的, 直接设置一下弹窗的属性就好.
1 2 3 4 5 6 7 8 9
| const handleOK = () => { setIsModalOpen(false); }
const handleCancel = () => { setIsModalOpen(false); }
|
实现弹窗内容
其实弹窗的内容就是一个表单, 表单存在类型的校验, 限制. 我们直接写 Form 组件就好.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| {} {
} <Form labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign={"left"} > </Form>
|
另外我们是需要拿到这个 Form 实例的, 所以设置一个 form 属性, 使用 antd 提供的一个钩子函数. 首先我们需要定义一个 Form, 在函数上面定义就好; 随后在这里进行使用即可.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const [modalForm] = Form.useForm()
...
<Form labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign={"left"} form={modalForm} > </Form>
|
其中的组件可以按照如下进行布局:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| {} {} <Form.Item label='姓名' name='name' rules={ [ { required: true, message: '请输入姓名' } ] } > {} <Input placeholder='请输入姓名'/> </Form.Item>
|
现在打开页面, 可以看到这个表单以及呈现出来了.

剩下的都是一样的, 参考代码如下, 如果有注意点, 参考注释即可.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
| <Form labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} labelAlign={"left"} form={modalForm} > {} {} <Form.Item label='姓名' name='name' rules={ [ { required: true, message: '请输入姓名' } ] } > {} <Input placeholder='请输入姓名'/> </Form.Item> {} <Form.Item label='年龄' name='age' rules={ [ { required: true, message: '请输入年龄', type: 'number' } ] } > {} <InputNumber placeholder='请输入年龄'/> </Form.Item> {} <Form.Item label='性别' name='sex' rules={ [ { required: true, message: '请输入姓名' } ] } > {} {} <Select placeholder='请选择性别' options={[ { value: 0, label: '男' }, { value: 1, label: '女' } ]} /> </Form.Item> {} <Form.Item label='出生日期' name='birth' rules={ [ { required: true, message: '请选择出生日期' } ] } > {} {} <DatePicker placeholder='请选择' format="YYYY/MM/DD"/> </Form.Item> {} <Form.Item label='地址' name='addr' rules={ [ { required: true, message: '请输入地址' } ] } > <Input placeholder='请输入地址'/> </Form.Item> </Form>
|
现在, 我们页面中的这个弹窗以及非常好看了.

实现弹窗功能
获取数据
首先, 我们需要获取数据. 表单的数据可以通过刚才的 form 来获取. 阅读文档我们知道了, 可以使用一个 API: getFieldValue 来获取刚才的表单数据. 不妨在确认的时候输出看看情况.
不过这里需要注意, 首先拿到表单, 我们需要进行校验, 不能直接就获取数据了. 所以这里使用一个校验 API: validateFields. 使用方式如下:
1 2 3 4 5 6 7 8 9
| const handleOK = () => { setIsModalOpen(false); modalForm.validateFields().then((value) => { console.log("value", value); }) }
|
我们拿到了这样一个对象.

如果我们表单校验没有通过, 就会报错:

格式化日期
这里的日期是一个特殊的格式, 我们传递给后端的其实是一个字符串. 我们可以使用一个插件: Day.js 来实现日期的转换. 安装该库:
随后我们引入, 并且在表单校验的地方对日期进行处理.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import dayjs from "dayjs";
...
const handleOK = () => { setIsModalOpen(false); modalForm.validateFields().then((value) => { console.log("value", value); value.birth = dayjs(value.birth).format("YYYY-MM-DD"); console.log(value) }) }
|

现在我们的完整参数就实现了. 可以直接调用一下接口, 根据当前的状态进行判断.
实现接口
这里的接口我们需要在 mock 中进行定义.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import Mock from 'mockjs'
import HomeApi from './mockServeData/home.js'
import UserAPI from './mockServeData/user.js'
Mock.mock(/home\/getData/, HomeApi.getStatisticalData) Mock.mock(/user\/getUser/, UserAPI.getUserList)
Mock.mock(/user\/addUser/, 'post', UserAPI.createUser) Mock.mock(/user\/editUser/, 'post', UserAPI.updateUser)
|
随后, 定义 API, 在 API 的最后追加就好.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| export const addUser = (data) => { return http.request({ url: '/user/addUser', method: 'post', data }) }
export const editUser = (data) => { return http.request({ url: '/user/editUser', method: 'post', data }) }
|
调用接口
直接在 User 中引入刚才写好的 API:
1 2
| import {getUser, addUser, editUser} from '../../api'
|
然后调用接口即可.
[!note] 更新逻辑
- 调用 API, 提交新的数据
- 关闭弹窗
- 重新请求 API, 获取新的数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const handleOK = () => { setIsModalOpen(false); modalForm.validateFields().then((value) => { value.birth = dayjs(value.birth).format("YYYY-MM-DD"); if (modalType) { } else { addUser(value).then(() => { handleCancel(); getTableData(); }) } }) }
|
效果已经实现了!

清空表单数据
如果我们再次直接打开弹窗, 会发现数据还是存在的, 这显然不是我们想要的结果. 所以我们回到刚才的逻辑, 还需要清空表单. 好消息是, Form 提供了这个方法: resetFields.
1 2 3 4 5
| const handleCancel = () => { setIsModalOpen(false); modalForm.resetFields(); }
|
用户修改数据
实现编辑回显
编辑数据的时候, 我们需要拿到数据. 这里首先需要对数据进行深拷贝, 不能对原数据产生影响. 最简单的深拷贝方式就是序列化.
另外, 现在是反向操作, 所以日期需要转换为 dayjs 的形式. 综上可以实现该功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const handleClick = (type, rowData) => { if (type === 'add') { setModalType(0) } else { setModalType(1) const cloneData = JSON.parse(JSON.stringify(rowData)); console.log(cloneData) cloneData.birth = dayjs(cloneData.birth) modalForm.setFieldsValue(cloneData); }
setIsModalOpen(!isModalOpen); }
|
至此, 数据成功进行了回填.

实现编辑内容
我们希望点击确认后, 能够更新数据. 回到 handleOK 函数, 调用对应的接口. 这里需要注意, 编辑的逻辑不太一样, 修改是需要找到数据再修改的.
我们可以根据数据的 id 来进行修改, 这就是修改的基本原理. 这里的 id 需要在弹窗的里面设置一下 ID. 这里我们还是通过一个表单来获取 ID, 不过我们的这个表单是不可见的.
1 2
| {} {modalType === 1 && <Form.Item hidden name='id'><Input/></Form.Item>}
|
回到 handleOK, 就可以实现这个更新的逻辑了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const handleOK = () => { setIsModalOpen(false); modalForm.validateFields().then((value) => { value.birth = dayjs(value.birth).format("YYYY-MM-DD"); if (modalType) { editUser(value).then(() => { handleCancel(); getTableData(); }) } else { ... } }) }
|
另外, 我们可以在校验的时候接收一下错误, 阻止页面中的报错. 比较简单的, 写一个弹窗效果会好看很多! 引入 message, 然后调用对应的函数即可.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const [messageApi, contextHolder] = message.useMessage();
...
const handleOK = () => { modalForm.validateFields().then((value) => { ...; }).catch((err) => { messageApi.info('输入存在问题!'); }) }
{contextHolder}
|
现在功能已经实现, 且显示好看很多!

用户删除数据
定义 API
删除数据也是有对应的 API 的. 还是一样, 首先在 mook 中拦截对应的请求:
1 2
| Mock.mock(/user\/delUser/, 'post', UserAPI.deleteUser)
|
在 API 中进行定义:
1 2 3 4 5 6 7
| export const delUser = (data) => { return http.request({ url: '/user/delUser', method: 'post', data }) }
|
调用 API
来到 User 组件中, 引入 API, 并且在删除按钮对应的回调函数中进行调用.
1 2 3 4 5 6 7 8 9 10 11
| const handldDelete = (rowData) => { const {id} = rowData; delUser({id}).then(()=>{ getTableData(); }) }
|
现在已经可以实现删除的效果了.

用户搜索数据
这里调用的接口和列表的接口实际上是一个接口, 不过搜索传入了一个参数而已.
1 2 3 4 5 6
| const handleFinish = (e) => { setListData({ name: e.keyword, }) }
|
不过特殊的是, 我们需要进行监听修改, 回到 useEffect 钩子中, 监听一下我们的 ListData 就好:
1 2 3 4
| useEffect(() => { getTableData(listData); }, [listData]);
|
到这里, 可以实现正常的搜索功能了.

实现 Tag 功能
什么是 Tag 功能
简单说, 就是面包屑导航的改版, 打开一个页面, 上面就会出现一个页面的 Tag, 用户关闭 Tag 则关闭页面, 点击别的 Tag 则切换到对应的 Tag 页面.
实现显示效果
组件化
还是一样, 查看组件库, 可以找到我们想要的一个 Tag 组件.

这里的 Tag 是一行内显示, 可以直接使用 antd 的 Space 组件实现. 考虑到 Tag 实际上是一个功能比较单一的东西, 所以直接新建一个组件出来实现.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import React from 'react'; import {Tag, Space} from "antd";
import './CommonTag.css'
const CommonTag = () => { const handleClose = () => {
}
return ( <Space size={[0, 8]} wrap className="common-tag"> <Tag>首页</Tag> <Tag closeIcon onClose={() => { handleClose() }}>用户列表</Tag> </Space> ); };
export default CommonTag;
|
在 Main 主页面页面中进行引入, 查看对应的效果.
1 2 3 4 5 6 7 8 9
| {} <CommonHeader collapsed={collapse}/> {} <CommonTag/> <Content>
...
</Content>
|
顺便使用 css 添加一下边距, 不然不好看:
1 2 3 4
| .common-tag { padding-top: 24px; padding-left: 24px; }
|

高亮显示
Tag 的高亮直接使用一个颜色属性就好, 比如下面这样子, 给一个 Tag 添加一个蓝色背景:
1 2 3
| <Tag color="#55acee" closeIcon onClose={() => { handleClose() }}>用户列表</Tag>
|

效果还是十分好看的.
实现 Tag 的数据设置
首先, 我们考虑添加时机. 点击菜单的时候添加 Tag 的数据. 在点击菜单的时候, 会传入一个数据, 我们可以根据数据找到 url, 然后根据 url 进行匹配, 找到对应的数据.
我们来到侧边栏的点击事件中, 对数据进行遍历.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const selectMenu = (event) => { let data; MenuConfig.forEach((item) => { if (item.path === event.keyPath[event.keyPath.length - 1]) { data = item; if (event.keyPath.length > 1) { data = item.children.find((child) => { return child.path === event.key; }) } } }) navigate(event.key); }
|
现在数据找到了, 但是需要传递给 Side 组件. 这是兄弟组件的传值, 使用 redux 就可以实现. 来到 redux 的边栏的 reducer 中, 可以添加一个状态 tabList: [], 以及对应的 reducer.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const tabSlice = createSlice({ name: 'tab', initialState: { ... tabList: [ { path: '/', name: 'home', label: '首页' } ] }, reducers: { ... selectMenuList: (state, {payload: val}) => { } } })
export const {collapseMenu, selectMenuList} = tabSlice.actions
|
随后, 在边栏 Aside 中调用这个 reducer. 首先引入 useDispatch, 然后调用钩子函数.
1 2 3 4 5 6 7 8
| import {useDispatch} from "react-redux"; import {selectMenuList} from '../../store/reducers/tab_reducer.js'
...
const dispatch = useDispatch();
|
同时, 我们需要创建一个方法, 将数据添加到 store 中.
1 2 3 4
| const setTabsList = (val) => { dispatch(selectMenuList(val)) }
|
在后面, 获取数据结束, 就可以调用这个函数了.
1 2 3 4 5
| setTabsList({ path: data.path, name: data.name, label: data.label, })
|
另外, 添加的时候, 我们需要做一些逻辑判断, 是否重复, 是否是主页. 在 reducer 中, 可以写出如下代码:
1 2 3 4 5 6 7 8 9 10 11
| selectMenuList: (state, {payload: val}) => { if (val.name !== 'home') { const res = state.tabList.findIndex(item => item.name === val.name); if (res === -1) { state.tabList.push(val) } } }
|
现在我们已经拿到数据了. 可以添加一些点击事件来校验.

Tag 选中效果
这里其实还是一个组件之间的通信. 我们需要使用 redux 来记录当前选中的菜单是什么. 在 reducer 中, 可以增加一个状态, 以及对应的逻辑部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const tabSlice = createSlice({ name: 'tab', initialState: { ... currentMenu: {} }, reducers: { ... selectMenuList: (state, {payload: val}) => { if (val.name !== 'home') { state.currentMenu = val; ... } else { state.currentMenu = {}; } } } })
|
随后回到 Tag 组件, 处理一下渲染部分的逻辑.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const setTag = (flag, item, index) => { return ( flag ? <Tag color="#55acee" closeIcon onClose={() => handleClose(item, index)}>{item.label}</Tag> : <Tag key={item.name} onClick={() => handleChange(item)}>{item.label}</Tag> ) }
return ( <Space size={[0, 8]} wrap className="common-tag"> { /*首先需要进行判断 当前选中的数据(name)是否存在*/ currentMenu.name && tabsList.map((item, index) => setTag(item.path === currentMenu.path, item, index)) } </Space> );
|
至此, 显示效果已经正确.

实现关闭 Tag
这里一样的, 在 reducer 中实现对应的方法.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
closeTab: (state, {payload: val}) => { let res = state.tabList.findIndex(item => item.name === val.name) state.tabList.splice(res, 1); },
setCurrentMenu: (state, {payload: val}) => { if (val.name === 'home') { state.currentMenu = {}; } else { state.currentMenu = val; } }
export const {collapseMenu, selectMenuList, closeTab} = tabSlice.actions
|
随后在 Tab 组件中进行调用.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const handleClose = (tag, index) => { dispatch(closeTab(tag)) const length = tabsList.length - 1; if (tag.path !== action.pathname) { return } if (index === length) { const currentData = tabsList[index - 1]; dispatch(setCurrentMenu(currentData)); navigate(currentData.path); } else { if (tabsList.length > 1) { const nextData = tabsList[index + 1]; dispatch(setCurrentMenu(nextData)); navigate(nextData.path); } } }
const handleChange = (tag) => { dispatch(setCurrentMenu(tag)) navigate(tag.path); }
|
这个时候有一个东西需要注意, 刚才的 key 我们没有给全, 会造成报错! 这里添加以下 key:
1 2 3 4 5 6 7 8 9
| const setTag = (flag, item, index) => { return ( flag ? <Tag key={item.name} color="#55acee" closeIcon onClose={() => handleClose(item, index)}>{item.label}</Tag> : <Tag key={item.name} onClick={() => handleChange(item)}>{item.label}</Tag> ) }
|
现在基本的使用已经实现了!

用户鉴权和登陆页面
实现登陆页面
一般来说, 如果没有登陆, 应该是会直接返回到登陆页面的. 其次, 只有输入了账号密码, 才可以进行登陆.
首先, 创建路由信息, 在 router 中进行添加. 这里的登陆页面是一个独立的页面, 所以不需要和刚才的一致, 这个页面应当和 / 路由并列.

另外, 这里也需要创建一个 Login 页面, 样式可以直接使用现成的样式. (见项目压缩包). Login 的页面布局如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| import React from 'react'; import {Button, Form, Input} from "antd"; import './login.css'
const Login = () => { const handleSubmit = () => {
}
return ( <Form className={"login-container"} onFinish={handleSubmit}> {/*顶部标题*/} <div className={"login_title"}>系统登陆</div> {/*表单元素*/} <Form.Item label="账号" name="username" > <Input placeholder="请输入账号"/> </Form.Item> <Form.Item label="密码" name="password" > <Input.Password placeholder="请输入账号"/> </Form.Item> <Form.Item className={"login-button"}> <Button type="primary" htmlType="submit">登陆</Button> </Form.Item> </Form> ); };
export default Login;
|
整体布局还是比较简洁美观的.

实现用户鉴权
实现登陆效果
一般来说, 是通过后端来判断的, 提供一个 token 凭证. 这里还是直接使用 mock 来实现.
mock 中, 定义鉴权的拦截器.
1 2
| Mock.mock(/permission\/getMenu/, 'post', PermissionAPI.getMenu)
|
API 中, 定义一个接口:
1 2 3 4 5 6 7
| export const getMenu = (data) => { return http.request({ url: '/permission/getMenu/', method: 'post', data }) }
|
随后回到登陆页面, 首先我们需要判断用户是否输入了东西:
1 2 3 4 5 6 7 8 9 10 11 12 13
| const [messageApi, contextHolder] = message.useMessage(); const handleSubmit = (val) => { if (!val.password || !val.username) { return messageApi.open({ type: 'error', content: "请输入用户名和密码" }) } }
|
然后, 开始进行鉴权, 调用登陆接口即可.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const handleSubmit = (val) => { if (!val.password || !val.username) { return messageApi.open({ type: 'error', content: "请输入用户名和密码" }) } getMenu(val).then(({data}) => { console.log(data) }) }
|
通过输出, 可以看到已经拿到信息了.

可以做一些判断的操作, 比如密码错误之类的提示. 随后, 储存 token 到当前的缓存当中.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const handleSubmit = (val) => { if (!val.password || !val.username) { return messageApi.open({ type: 'info', content: "请输入用户名和密码" }) } getMenu(val).then(({data}) => { if (data.code === -999) { return messageApi.open({ type: 'error', content: "密码错误" }) } localStorage.setItem('token', data.data.token) navigate('/home') }) }
|
实现权限管理
这里还需要进行一定的判断. 首先, 如果登陆了, 我再次访问当前页面就可以直接重定向了.
1 2 3 4 5
| if (localStorage.getItem('token')) { return <Navigate to={'/home'} replace={true}/> }
|
实现退出登陆
回到 Header 组件中, 实现退出登陆的效果. 实际上就是清空浏览器的缓存.
1 2 3 4 5 6 7 8 9 10
| const navigate = useNavigate();
const logout = () => { localStorage.removeItem('token'); navigate('/login'); }
|
这样就实现了退出登陆了.
判断是否登陆
这里我们需要写一个高阶组件来实现. 在 router 目录下面创建一个新的组件: routerAuth.jsx

我们使用这个组件来进行权限的判断. 定义过程和页面的组件类似. 不过一上来就是判断语句.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import {Navigate} from "react-router-dom";
import React from 'react';
const RouterAuth = ({children}) => { const token = localStorage.getItem('token'); if (!token) { return <Navigate to={'/login'} replace={true}/> } return ( children ); };
export default RouterAuth;
|
随后, 我们在 Main 中, 将刚才的所有组件部分塞在中间就可以啦
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| return ( <RouterAuth> <Layout className={'main-container'}> {/*使用自定义侧边栏*/} <CommonAside collapsed={collapse}/> <Layout> {/*使用自定义顶栏*/} <CommonHeader collapsed={collapse}/> {/*Tag的位置应该是在Header和内容的中间*/} <CommonTag/> <Content style={{ margin: '24px 16px', padding: 24, minHeight: 280, background: colorBgContainer, borderRadius: borderRadiusLG, }} > {/*添加子路由的入口*/} <Outlet/> </Content> </Layout> </Layout> </RouterAuth> );
|
至此, 鉴权实现, 项目结束.
项目总结
项目源码
源代码可以直接点击如下链接进行下载.

也可以前往 Gitee 查看.
1 2 3 4 5
| url: https://gitee.com/kaede221/react-management-system title: "kaede221/react-management-system" description: "React 制作的后台管理系统" host: gitee.com image: https://foruda.gitee.com/avatar/1730619043434033814/15040222_kaede221_1730619043.png
|
项目注意点 & 反思
- 代码编写的时候, 最好统一命名规范, 否则容易出现拼写错误的问题
- 组件封装需要注意逻辑
- redux 的使用仍然不够熟练, 需要多加练习
- axios 的封装需要再次学习
- mock 的使用仍不明确