博客管理系统开发 -- 顶部导航栏开发

这一节我将带领大家,来开发博客管理系统的顶部导航功能,其效果最终如下:

一、路由优化

上一节我们介绍了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、views目录创建

我们的博客管理系统设计主要参考了郭大大这位博主的文章:

博客管理系统开发 -- 顶部导航栏开发_第1张图片

 

首先来看一下我们博客系统的主页,我们可以看到导航栏主要包含首页、归档、分类、关于这几个菜单,每个菜单项都对应着一个路由。

因此,我们在views文件夹下创建个web文件夹,并在该文件夹下创建与这几个对应的文件夹,用于保存每个页面对应的视图组件。最终我们views目录结构如下:

博客管理系统开发 -- 顶部导航栏开发_第2张图片

其中menu_config.js用于导出菜单配置,我们后面会根据该配置动态生成路由配置信息以及菜单配置信息。

我们首先来看一些about文件夹:

about.jsx:

import React from 'react';

function About(props) {
    console.log('About=>', props);
    return 

About

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

博客管理系统开发 -- 顶部导航栏开发_第3张图片

 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 

github

; } 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导出该菜单结构:

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

2、获取菜单配置信息

由于我们之前配置的path都是相对路径,因此我们需要将其转换为绝对路径,此外,我们还在菜单配置中加入了404菜单配置项;

@/components/404/menu_config:

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;

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,该组件是我们的布局组件,其主要包括顶部导航和侧边导航部分。

博客管理系统开发 -- 顶部导航栏开发_第4张图片

修改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={() =>
                        
{item.component && } {item.subMenus && renderRouters(item.subMenus)}
} exact={item.subMenus ? false : true} /> ); }); return {routes}; }; return renderRouters(routeConfig); }

我们可以输出webRouteConfig:

博客管理系统开发 -- 顶部导航栏开发_第5张图片

二、Layout组件开发

上面我们已经说过WEB_ROOT_PATH路由,对应的组件为Layout,其中主要包括顶部导航和侧边导航部分,这里我们将尝试开发顶部导航功能:

我们将顶部导航拆分为两个组件Left,Right;Right组件拆分为Serch、NavBar、UserInfo三个组件;

我们在layout下创建web文件夹,其目录如下:

博客管理系统开发 -- 顶部导航栏开发_第6张图片

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 (
        
            
) } export default WebLayout;

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 (
        
) } export default WebHeader;

2、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组件

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

export default HeaderLeft;

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

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 (
        
) } export default HeaderRight;

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 (
        
<Input type='text' value={keyword} onChange={handleChange} onPressEnter={handleSubmit} className={styles.searchInput} placeholder='搜索文章' style={{width: 200}} />
) } 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文件夹:

(1).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
})

(2).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
    }
}

(3).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})

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 = {menu.icon && } {menu.title};
            return menu.subMenus ?
                !menu.invisible && {menu.title}
                                            title={title}>{genMenuTree(menu.subMenus)} :
                !menu.invisible && {title};
        })
    }

    const onSelect = ({item, key, keyPath, selectedKeys, domEvent}) => {
        console.log('选择项为', selectedKeys);
    }

    return (
        
            {genMenuTree(webMenuConfig)}
        
    )
}

export default NavBar;

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 = (
        
            
                导入文章
            
            
                后台管理
            
            
                 dispatch(loginout())}>退出登录
            
        
    );
    return (
        
{/*登录 or not*/} {username ? ( } src='http://img2.imgtn.bdimg.com/it/u=3906498928,936423956&fm=26&gp=0.jpg'>{username} ) : (
<Button ghost type='primary' size='small' style={{marginRight: 20}} onClick={e => bus.emit('openSignModal', USER_LOGIN)}> 登录 <Button ghost type='danger' size='small' onClick={e => bus.emit('openSignModal', USER_REGISTER)}> 注册
)}
) } export default UserInfo;

这里显示通过useSelector将store中的用户信息映射到当前组件中,如果用户信息存在,则会加载下拉菜单:

博客管理系统开发 -- 顶部导航栏开发_第7张图片

如果用户信息不存在,则会显示:

这里使用到了事件发生器,当点击了登录时候,会触发openSignModal事件,并传入参数USER_LOGIN:

  onClick={e => bus.emit('openSignModal', USER_LOGIN)}>

当点击注册的时候,也会触发openSignModal事件,但是传入的参数是USER_REGISTER,当有函数监听了该事件的发生,那么就会执行该监听函数,这里实际上采用的就是是发布/订阅模式。

我们在components/public下创建public公共组件:

博客管理系统开发 -- 顶部导航栏开发_第8张图片

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 (
        
) } 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 ? (
                        
<Form.Item name="username" rules={[{required: true, message: 'Please input your Username!'}]} > } placeholder="Username"/> <Form.Item name="password" rules={[{required: true, message: 'Please input your Password!'}]} > <Input prefix={} type="password" placeholder="Password" />
) : (
<Form.Item {...FormItemLayout} label="用户名" name="username" rules={[{required: true, message: 'Please input your Username!'}]} > <Form.Item {...FormItemLayout} label="密码" name="password" rules={[{required: true, message: 'Please input your Password!'}]} > <Input type="password" placeholder="Password" /> <Form.Item {...FormItemLayout} label="确认密码" name='confirm' rules={[ {required: true, message: 'Password is required'}, {validator: compareToFirstPassword} ]}> <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!'} ]}>
)} ) } export default SignModal;

这里采用antd表单来实现登录/注册,我们通过busListener监听登录和注册事件:

    //事件监听 如果触发登录或者注册事件,显示该对话框
    busListener('openSignModal', type => {
        form.resetFields();
        setType(type);
        setVisible(true);
    })

如果是登录,对话框内容如下:

博客管理系统开发 -- 顶部导航栏开发_第9张图片

如果是注册,对话框内容如下:

博客管理系统开发 -- 顶部导航栏开发_第10张图片

 以登录为例,当我们输入了登录信息后,如果校验通过,则会执行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文件夹:

(1).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
})

(2).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;
    }
}

(3).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 (
        
            
            
        
    )
}

 至此,我们这节的内容介绍完毕了,代码有点多,我整理放在github上:https://github.com/Zhengyang550/react-blog-zy。

参考文章:

[1]郭大大博客系统开发

你可能感兴趣的:(博客管理系统开发 -- 顶部导航栏开发)