博客管理系统开发 -- 顶部导航栏开发
这一节我将带领大家,来开发博客管理系统的顶部导航功能,其效果最终如下:
一、路由优化
上一节我们介绍了routes的目录结构,其中我们创建了一web.js文件,用来保存路由配置。
/**
* web路由配置项
* @author zy
* @date 2020/4/5
*/
export default {
path: '/',
name: 'home',
component: Home,
exact: false,
childRoutes: [
{path: 'about', component: About},
{path: '*', component: PageNotFound}
]
}
我们每添加一个路由,都需要在这里配置一次,如果路由较多,且存在多层父子关系的情况下,该配置会越来越复杂且结构不够清晰,此外多个人同时开发,每个人都可能去配置路由信息,那样我们就需要解决代码冲突问题。
那么有没有一种简单的方式,我们可以根据views文件夹结构动态生成路由配置信息,当然可以了,我们现在就来介绍。
1.1 views目录创建
我们的博客管理系统设计主要参考了郭大大这位博主的文章:
首先来看一下我们博客系统的主页,我们可以看到导航栏主要包含首页、归档、分类、关于这几个菜单,每个菜单项都对应着一个路由。
因此,我们在views文件夹下创建个web文件夹,并在该文件夹下创建与这几个对应的文件夹,用于保存每个页面对应的视图组件。最终我们views目录结构如下:
其中menu_config.js用于导出菜单配置,我们后面会根据该配置动态生成路由配置信息以及菜单配置信息。
我们首先来看一些about文件夹:
about.jsx:
import React from 'react';
function About(props) {
console.log('About=>', props);
return <h2>About</h2>;
}
export default About;
menu_config.js:
import {UserOutlined} from '@ant-design/icons';
import About from './about';
export default {
title: '关于',
icon: UserOutlined,
path: 'about',
component: About
}
path:指定About组件的路由,由于每个路由实际上都对应这一个菜单项,因此我们通过icon指定菜单图标,title指定菜单名称。
如果一个菜单还有子菜单,比如归档菜单下面还有子菜单github:
archives文件夹下menu_config.js:
import {FolderOutlined} from '@ant-design/icons';
import github from './github/menu_config';
export default {
title: '归档',
icon: FolderOutlined,
path: 'archives',
subMenus: [github]
}
这里我们配置了归档的子菜单github;
github.jsx:
import React from 'react';
function Github(props) {
console.log('Github=>', props);
return <h2>github</h2>;
}
export default Github;
menu_config.js:
import {GithubOutlined} from '@ant-design/icons';
import Github from './github';
export default {
title: 'github',
icon: GithubOutlined,
path: 'github',
component: Github
}
home和categories文件夹同上,就不一一介绍,最后我们通过web文件夹下的menu_config.js导出该菜单结构:
import home from './home/menu_config';
import archives from './archives/menu_config';
import categories from './categories/menu_config';
import about from './about/menu_config';
export default [home, archives, categories, about];
1.2 获取菜单配置信息
由于我们之前配置的path都是相对路径,因此我们需要将其转换为绝对路径,此外,我们还在菜单配置中加入了404菜单配置项;
@/components/404/menu_config.js:
import PageNotFound from './index';
export default {
title: '404',
icon: '',
path: '*',
component: PageNotFound,
invisible: true
}
这里配置了invisible指明*路由不需要出现在菜单项中。
utils/get_menus.js:
import _ from 'lodash';
import pageNotFoundMenu from '@/components/404/menu_config';
/**
* 解析menu_config 将配置路径由相对路径转为绝对路径
* @author zy
* @date 2020/4/8
* @param menus:menu_config配置
* @return contextPath:设置根路径
*/
const getMenus = (menus, contextPath) => {
const menusCopy = _.cloneDeep(menus);
const decodeMenus = (menusCopy, menuContextPath) => {
_.forEach(menusCopy, item => {
//获取当前菜单路径
let path = item.path ? `${menuContextPath}/${item.path}` : menuContextPath;
item.path = path.replace(/\/+/g, '/');
if (item.subMenus) {
decodeMenus(item.subMenus, path);
}
})
//给每个同阶菜单追加一个404 如/* /archives/* /archives/layout/*
if (menusCopy) {
const menu = _.cloneDeep(pageNotFoundMenu);
menu.path = (menuContextPath + '/*').replace(/\/+/g, '/');
menusCopy.push(menu);
}
}
decodeMenus(menusCopy, contextPath);
return menusCopy;
}
export default getMenus;
utils/index.js:
/**
* @author zy
* @date 2020/4/6
* @Description: 统用函数
*/
import getMenusFunctions from './get_menus';
export const getMenus = getMenusFunctions;
1.3 routes目录
我们修改routes/web.js:
/**
* @author zy
* @date 2020/4/5
* @Description: web路由
* 不懂的可以参考:https://segmentfault.com/a/1190000020812860
* https://reacttraining.com/react-router/web/api/Route
*/
import Layout from '@/layout/web';
import menus from '@/views/web/menu_config';
import {getMenus} from '@/utils';
import {WEB_ROOT_PATH} from '@/config';
/**
* web路由配置项
* @author zy
* @date 2020/4/5
*/
//web 菜单配置
export const webMenuConfig = getMenus(menus, WEB_ROOT_PATH);
//web route配置
export const webRouteConfig = {
title: 'home',
path: WEB_ROOT_PATH,
component: Layout, //根路径下配置web统一布局样式
subMenus: webMenuConfig
}
这里WEB_ROOT_PATH配置为'/'路径:
/**
* @author zy
* @date 2020/4/6
* @Description: 项目配置文件
*/
//web 根路径
export const WEB_ROOT_PATH = '/';
//导航栏博客名称
export const HEADER_BLOG_NAME = '我的博客';
其对应的组件为Layout,该组件是我们的布局组件,其主要包括顶部导航和侧边导航部分。
修改routes/index.js:
/**
* @author zy
* @date 2020/4/5
* @Description: 路由组件
*/
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { webRouteConfig } from './web';
import _ from 'lodash';
//保存所有路由配置的数组
const routeConfig = [webRouteConfig]
/**
* 路由配置
* @author zy
* @date 2020/4/5
*/
export default function () {
/**
* 生成路由嵌套结构
* @author: zy
* @date: 2020-03-05
* @param routeConfig: 路由配置数组
*/
const renderRouters = (routeConfig) => {
const routes = [];
//遍历每一个路由项
_.forEach(routeConfig, item => {
//这里使用了嵌套路由
routes.push(
<Route
key={item.path}
path={item.path}
component={() =>
<div className={item.title}>
{item.component && <item.component />}
{item.subMenus && renderRouters(item.subMenus)}
</div>
}
exact={item.subMenus ? false : true}
/>
);
});
return <Switch>{routes}</Switch>;
};
return renderRouters(routeConfig);
}
我们可以输出webRouteConfig:
二、Layout组件开发
上面我们已经说过WEB_ROOT_PATH路由,对应的组件为Layout,其中主要包括顶部导航和侧边导航部分,这里我们将尝试开发顶部导航功能:
我们将顶部导航拆分为两个组件Left,Right;Right组件拆分为Serch、NavBar、UserInfo三个组件;
我们在layout下创建web文件夹,其目录如下:
2..1 web/index.js
/**
* @author zy
* @date 2020/4/6
* @Description: web页面布局
*/
import React from 'react';
import {Layout, Row, Col} from 'antd';
import Header from './header';
// 响应式
const siderLayout = {xxl: 4, xl: 5, lg: 5, sm: 0, xs: 0}
const contentLayout = {xxl: 20, xl: 19, lg: 19, sm: 24, xs: 24}
/**
* Web布局组件
* @author zy
* @date 2020/4/6
*/
const WebLayout = props => {
return (
<Layout >
<Header/>
<Row>
<Col {...siderLayout}>
</Col>
<Col {...contentLayout}>
</Col>
</Row> </Layout>
)
}
export default WebLayout;
2.2 web/header/index.js
/**
* @author zy
* @date 2020/4/6
* @Description: web 头部布局
*/
import React from 'react';
import {Layout, Row, Col} from 'antd';
import Left from './left';
import Right from './right';
import styles from './styles.scss';
const Header = Layout.Header;
/**
* 头部布局组件
* @author zy
* @date 2020/4/6
*/
const WebHeader = () => {
// 响应式 xxl:超大屏 一行显示24/4列 xl:大屏一行显示24/5 ...
const responsiveLeft = {xxl: 4, xl: 5, lg: 5, sm: 4, xs: 24};
const responsiveRight = {xxl: 20, xl: 19, lg: 19, sm: 20, xs: 0};
return (
<Header id='app-header' className={styles.appHeader}>
<Row>
<Col {...responsiveLeft}>
<Left/>
</Col>
<Col {...responsiveRight}>
<Right/>
</Col>
</Row>
</Header>
)
}
export default WebHeader;
2.3 web/header/styles.scss
@import '@/styles/other.scss';
.appHeader {
padding: 0;
background: #fff;
box-shadow: 0 2px 8px $headerBoxShadowColor;
}
这里我们引入了@/styles/other.scss文件:
/**
* @author zy
* @date 2020/4/7
* @Description: 所有颜色定义
*/
//头部颜色
$headerColor: rgba(0, 0, 0, .85);
$headerBoxShadowColor: #f0f1f2;
//分割线颜色
$dividerColor: rgb(235, 237, 240);
//图标颜色
$searchIconColor: #ced4d9;
//占位符颜色
$placeholderColor: #a3b1bf;
//主页颜色
$homeBasicColor: #0cb7d5;
三、Left组件
3.1 index.js
/**
* @author zy
* @date 2020/4/6
* @Description: 头部左侧布局
*/
import React from 'react';
import {DingdingOutlined} from '@ant-design/icons';
import styles from './styles.scss';
import {HEADER_BLOG_NAME} from '@/config'
/**
* 头部左侧布局组件
* @author zy
* @date 2020/4/6
*/
const HeaderLeft = props => {
return (
<div className={styles.headerLeft}>
<a href='/' className={styles.blogIcon}>
<DingdingOutlined/>{HEADER_BLOG_NAME}
</a>
</div>
)
}
export default HeaderLeft;
3.2 styles.scss
@import '@/styles/other.scss';
.headerLeft {
padding-left: 40px;
font-size: 20px;
color: $headerColor;
display: flex;
align-items: center;
line-height: 64px;
height: 64px;
.blogIcon {
overflow: hidden;
color: $headerColor;
font-size: 18px;
white-space: nowrap;
text-decoration: none;
span {
margin-right: 16px;
}
}
}
四、Right组件
4.1 index.jsx
/**
* @author zy
* @date 2020/4/6
* @Description: 头部右侧布局
*/
import React from 'react';
import Search from './right_search';
import Navbar from './right_nav_bar';
import UserInfo from './right_user_info';
import styles from './styles.scss';
/**
* 头部右侧布局组件
* @author zy
* @date 2020/4/6
*/
const HeaderRight = props => {
return (
<div className={styles.headerRight}>
<Search/>
<Navbar/>
<UserInfo/>
</div>
)
}
export default HeaderRight;
4.2 right_search.jsx:
/**
* @author zy
* @date 2020/4/6
* @Description: 文章搜索
*/
import React from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {Input} from 'antd';
import {SearchOutlined} from '@ant-design/icons';
import styles from './styles.scss';
import {setKeyword} from '@/redux/article/actions';
/**
* 搜索组件
* @author zy
* @date 2020/4/6
*/
function SearchButton(props) {
//dispatch
const dispatch = useDispatch()
//将store状态article映射到组件
const article = useSelector(state => state.article);
//获取文章信息
const {keyword} = article;
//搜索关键字发生变化
const handleChange = e => {
dispatch(setKeyword(e.target.value));
}
//确定 开始搜索
const handleSubmit = () => {
if (keyword) {
console.log('开始搜索', keyword);
}
}
return (
<div className={styles.searchBox}>
<SearchOutlined className={styles.searchIcon}/>
<Input
type='text'
value={keyword}
onChange={handleChange}
onPressEnter={handleSubmit}
className={styles.searchInput}
placeholder='搜索文章'
style={{width: 200}}
/>
</div>
)
}
export default SearchButton;
这里使用redux状态管理器保存搜索关键字keyword,当用户在输入框输入搜索内容时,触发onChange事件:
//搜索关键字发生变化
const handleChange = e => {
dispatch(setKeyword(e.target.value));
}
此时调用dispatch设置keyword,当我们点击搜索时,这里将会将keyword搜索关键字输出:
//确定 开始搜索
const handleSubmit = () => {
if (keyword) {
console.log('开始搜索', keyword);
}
}
实际上,我们点搜索的时候,应当使用ajax请求服务器获取文章,然后将其保存到store状态中,然而由于此时没有开发后端接口,所以只好先输出到控制台。
我们再来看看文章reducer的配置,我们在redux下创建article文件夹:
actions.js
/**
* @author zy
* @date 2020/4/12
* @Description: 文章action
*/
import * as TYPES from './types';
//设置搜索关键字
export const setKeyword = (params) => ({
type: TYPES.ARTICLE_SET_KEYWORD,
payload: params
})
reducer.js
/**
* @author zy
* @date 2020/4/12
* @Description: 文章reducer
*/
import * as TYPES from './types';
/**
* @author zy
* @date 2020/4/12
* @Description: 初始化文章信息
*/
const defaultState = {
keyword: ''
}
/**
* articleReducer
* @author zy
* @date 2020/4/12
*/
export default function articleReducer(state = defaultState, action) {
const {type, payload} = action
switch (type) {
case TYPES.ARTICLE_SET_KEYWORD:
return {...state, keyword: payload}
default:
return state
}
}
types.js
// article
export const ARTICLE_SET_KEYWORD = '';
同时redux/root_reducers.js中引入articleReducer:
/**
* @author zy
* @date 2020/4/5
* @Description: 合并reducer
*/
import {combineReducers} from 'redux';
import article from './article/reducer';
export default combineReducers({ article})
4.3 right_nav_bar.jsx
/**
* @author zy
* @date 2020/4/7
* @Description: 导航栏
*/
import React from 'react';
import {Link, useLocation} from 'react-router-dom';
import {Menu} from "antd";
import {webMenuConfig} from '@/routes/web';
import _ from 'lodash';
import styles from './styles.scss';
const {SubMenu} = Menu;
/**
* 导航栏组件
* @author zy
* @date 2020/4/7
*/
function NavBar(props) {
//获取当前location对象
const location = useLocation();
//菜单样式 默认水平
const {mode = 'horizontal'} = props;
/**
* 生成菜单树
* @author zy
* @date 2020/4/7
*/
const genMenuTree = (menus) => {
return _.map(menus, menu => {
const title = <span>{menu.icon && <menu.icon/>} {menu.title}</span>;
return menu.subMenus ?
!menu.invisible && <SubMenu key={menu.title}
title={title}>{genMenuTree(menu.subMenus)}</SubMenu> :
!menu.invisible && <Menu.Item key={menu.path}><Link to={menu.path}>{title}</Link></Menu.Item>;
})
}
const onSelect = ({item, key, keyPath, selectedKeys, domEvent}) => {
console.log('选择项为', selectedKeys);
}
return (
<Menu className={styles.headerNav} mode={mode} selectedKeys={[location.pathname]} onSelect={onSelect}>
{genMenuTree(webMenuConfig)}
</Menu>
)
}
export default NavBar;
4.4 right_user_info.jsx
/**
* @author zy
* @date 2020/4/12
* @Description: 用户信息
*/
import React from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {Button, Dropdown, Menu, Avatar} from 'antd';
import {useBus} from '@/hooks/use_bus';
import {USER_LOGIN, USER_REGISTER} from '@/redux/user/types';
import {loginout} from '@/redux/user/actions';
/**
* 用户细腻些组件
* @author zy
* @date 2020/4/12
*/
function UserInfo(props) {
//dispatch
const dispatch = useDispatch()
//将store状态user映射到组件
const userInfo = useSelector(state => state.user);
//获取用户信息
const {username} = userInfo;
//使用bus
const bus = useBus();
//菜单
const menuOverLay = (
<Menu>
<Menu.Item>
<span>导入文章</span>
</Menu.Item>
<Menu.Item>
<span>后台管理</span>
</Menu.Item>
<Menu.Item>
<span onClick={() => dispatch(loginout())}>退出登录</span>
</Menu.Item>
</Menu>
);
return (
<div>
{/*登录 or not*/}
{username ? (
<Dropdown placement='bottomCenter' overlay={menuOverLay} trigger={['click', 'hover']}>
<Avatar size={32}
src='http://img2.imgtn.bdimg.com/it/u=3906498928,936423956&fm=26&gp=0.jpg'>{username}</Avatar>
</Dropdown>
)
: (
<div>
<Button
ghost
type='primary'
size='small'
style={{marginRight: 20}}
onClick={e => bus.emit('openSignModal', USER_LOGIN)}>
登录
</Button>
<Button ghost
type='danger'
size='small'
onClick={e => bus.emit('openSignModal', USER_REGISTER)}>
注册
</Button>
</div>
)}
</div>
)
}
export default UserInfo;
这里显示通过useSelector将store中的用户信息映射到当前组件中,如果用户信息存在,则会加载下拉菜单:
如果用户信息不存在,则会显示:
这里使用到了事件发生器,当点击了登录时候,会触发openSignModal事件,并传入参数USER_LOGIN:
onClick={e => bus.emit('openSignModal', USER_LOGIN)}>
当点击注册的时候,也会触发openSignModal事件,但是传入的参数是USER_REGISTER,当有函数监听了该事件的发生,那么就会执行该监听函数,这里实际上采用的就是是发布/订阅模式。
我们在components/public下创建public公共组件:
components/public/index.jsx:
/**
* @author zy
* @date 2020/4/12
* @Description: Public 公共组件,挂在在 APP.jsx 中,用于存放初始化的组件/方法 或者公用的 modal 等
*/
import React from 'react';
import useMount from '@/hooks/use_mount'
import SignModal from '@/components/public/sign_modal';
/**
* 公共组件
* @author zy
* @date 2020/4/12
*/
function PublicComponent(props) {
useMount(() => {
})
return (
<div>
<SignModal/>
</div>
)
}
export default PublicComponent;
我们引入了SignModal组件,该组件用于注册/登录,在public下创建sign_modal文件夹,并添加index.jsx文件:
/**
* @author zy
* @date 2020/4/12
* @Description: 注册 or 登录对话框
*/
import React, {useState} from 'react';
import {Form, Input, Button, Modal} from 'antd';
import {UserOutlined, LockOutlined} from '@ant-design/icons';
import {login, register} from '@/redux/user/actions';
import {USER_LOGIN} from '@/redux/user/types'
import {useDispatch} from 'react-redux';
import {busListener} from '@/hooks/use_bus';
//表单样式调整
const FormItemLayout = {
labelCol: {
xs: {span: 0},
sm: {span: 5}
},
wrapperCol: {
xs: {span: 24},
sm: {span: 19}
}
}
/**
* 用户注册 or 登录组件
* @author zy
* @date 2020/4/12
*/
function SignModal(props) {
//获取表单
const [form] = Form.useForm();
//获取dispatch
const dispatch = useDispatch();
//对话框可见?
const [visible, setVisible] = useState(false)
//类型:登录 or 注册
const [type, setType] = useState('login')
//事件监听 如果触发登录或者注册事件,显示该对话框
busListener('openSignModal', type => {
form.resetFields();
setType(type);
setVisible(true);
})
//提交表单且数据验证成功后回调事件
const onFinish = values => {
console.log('Received values of form: ', values);
const action = type === USER_LOGIN ? login : register;
dispatch(action(values)).then(() => {
setVisible(false);
})
};
//确认密码
function compareToFirstPassword(rule, value, callback) {
if (value && value !== form.getFieldValue('password')) {
callback('Two passwords that you enter is inconsistent!')
} else {
callback()
}
}
return (
<Modal
width={420}
title={type === USER_LOGIN ? 'login' : 'register'}
visible={visible}
onCancel={e => setVisible(false)}
footer={null}>
<Form
form={form}
name="normal_login"
layout="horizontal"
onFinish={onFinish}
>
{/*登录或注册*/}
{type === USER_LOGIN ? (
<div>
<Form.Item
name="username"
rules={[{required: true, message: 'Please input your Username!'}]}
>
<Input prefix={<UserOutlined className="site-form-item-icon"/>} placeholder="Username"/>
</Form.Item>
<Form.Item
name="password"
rules={[{required: true, message: 'Please input your Password!'}]}
>
<Input
prefix={<LockOutlined/>}
type="password"
placeholder="Password"
/>
</Form.Item>
</div>
)
: (
<div>
<Form.Item
{...FormItemLayout}
label="用户名"
name="username"
rules={[{required: true, message: 'Please input your Username!'}]}
>
<Input placeholder="Username"/>
</Form.Item>
<Form.Item
{...FormItemLayout}
label="密码"
name="password"
rules={[{required: true, message: 'Please input your Password!'}]}
>
<Input
type="password"
placeholder="Password"
/>
</Form.Item>
<Form.Item
{...FormItemLayout}
label="确认密码"
name='confirm'
rules={[
{required: true, message: 'Password is required'},
{validator: compareToFirstPassword}
]}>
<Input placeholder='Confirm Password' type='password'/>
</Form.Item>
<Form.Item
{...FormItemLayout}
label="邮箱"
name='email'
rules={[
{type: 'email', message: 'The input is not valid E-mail!'},
{required: true, message: 'Please input your E-mail!'}
]}>
<Input placeholder='Email'/>
</Form.Item>
</div>
)}
<Form.Item>
<Button type="primary" htmlType="submit" style={{width: '100%'}}>
Login
</Button>
</Form.Item>
</Form>
</Modal>
)
}
export default SignModal;
这里采用antd表单来实现登录/注册,我们通过busListener监听登录和注册事件:
//事件监听 如果触发登录或者注册事件,显示该对话框
busListener('openSignModal', type => {
form.resetFields();
setType(type);
setVisible(true);
})
如果是登录,对话框内容如下:
如果是注册,对话框内容如下:
以登录为例,当我们输入了登录信息后,如果校验通过,则会执行onFinish函数:
//提交表单且数据验证成功后回调事件
const onFinish = values => {
console.log('Received values of form: ', values);
const action = type === USER_LOGIN ? login : register;
dispatch(action(values)).then(() => {
setVisible(false);
})
};
这里将会调用dispatch用来保存用户信息,然后关闭对话框。
同文章reducer,我们再来看看用户reducer的实现,我们在redux下创建user文件夹:
actions.js:
/**
* @author zy
* @date 2020/4/12
* @Description: 用户action
*/
import * as TYPES from './types'
import {message} from 'antd'
/**
* 执行登录操作
* @author zy
* @date 2020/4/12
*/
export const login = params => {
return dispatch => {
return new Promise((resolve, reject) => {
//设置用户信息
dispatch({
type: TYPES.USER_LOGIN,
payload: params
})
message.success(`登录成功, 欢迎您 ${params.username}`);
resolve('这里调用登录接口');
})
}
}
/**
* 执行注册操作
* @author zy
* @date 2020/4/12
*/
export const register = params => {
return dispatch => {
message.success('注册成功,请重新登录您的账号!')
}
}
/**
* 执行退出登录操作
* @author zy
* @date 2020/4/12
*/
export const loginout = () => ({
type: TYPES.USER_LOGIN_OUT
})
reducer.js:
/**
* @author zy
* @date 2020/4/12
* @Description: 用户reducer
*/
import * as TYPES from './types';
/**
* 初始化用户信息
* @author zy
* @date 2020/4/12
*/
let defaultState = {
username: '',
userId: 0,
github: null
}
/**
* userReducer
* @author zy
* @date 2020/4/12
*/
export default function userReducer(state = defaultState, action) {
const {type, payload} = action
switch (type) {
case TYPES.USER_LOGIN:
const {username, userId, github} = payload;
return {...state, username, userId, github};
case TYPES.USER_LOGIN_OUT:
return {...state, username: '', userId: 0, github: null};
default:
return state;
}
}
type.js
// user
export const USER_LOGIN = 'USER_LOGIN';
export const USER_REGISTER = 'USER_REGISTER';
export const USER_LOGIN_OUT = 'USER_LOGIN_OUT';
同时redux/root_reducers.js中引入userReducer:
/**
* @author zy
* @date 2020/4/5
* @Description: 合并reducer
*/
import {combineReducers} from 'redux';
import user from './user/reducer';
import article from './article/reducer';
export default combineReducers({user, article})
最后我们需要将PublicComponent挂在到App组件上:
/**
* @author zy
* @date 2020/4/5
* @Description: 根组件
*/
import React from 'react';
import Routes from '@/routes';
import {BrowserRouter} from 'react-router-dom';
import PublicComponent from '@/components/public';
export default function App(props) {
return (
<BrowserRouter>
<Routes/>
<PublicComponent/>
</BrowserRouter>
)
}
至此,我们这节的内容介绍完毕了。
五、源码地址
代码放在gitee上:
前端:blog-webapp
后端:blog-server
参考文章:
[1]郭大大博客系统开发