5

react 高效高质量搭建后台系统 系列 —— 系统布局 - 彭加李

 2 years ago
source link: https://www.cnblogs.com/pengjiali/p/17079747.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

其他章节请看:

react 高效高质量搭建后台系统 系列

前面我们用脚手架搭建了项目,并实现了登录模块,登录模块所依赖的请求数据antd(ui框架和样式)也已完成。

本篇将完成系统布局。比如导航区、头部区域、主体区域、页脚。

最终效果如下:

o_230131083524_highqualitybacksystem-systemlayout-08.gif

spug 中系统布局的分析

spug 登录成功后进入系统,页面分为三大块:左侧导航、头部和主体区域。如下图所示:

o_230131083413_highqualitybacksystem-systemlayout-01.png

Tip:spug 将版权部分也放在主体区域内。

切换左侧导航,主体内容会跟着变化,头部区域不变。例如从工作台切换到 Dashboard,就像这样:

o_230131083421_highqualitybacksystem-systemlayout-02.png

登录成功后,进入系统。也就是进入 Layout 组件。

// App.js
class App extends Component {
   render() {
     return (
       <Switch>
         <Route path="/" exact component={Login} />
         {/* 系统登录后进入 Layout 组件 */}
         <Route component={Layout} />
       </Switch>
     );
   }
}

Layout下index.js渲染的代码如下:

  return (
    <Layout>
      {/* 左侧区域,对 antd 中 Sider 的封装 */}
      <Sider collapsed={collapsed}/>
      <Layout style={{height: '100vh'}}>
        {/* 顶部区域, 对 antd 中 Layout.Header 的封装*/}
        <Header collapsed={collapsed} toggle={() => setCollapsed(!collapsed)}/>
        <Layout.Content className={styles.content}>
          <Switch>
            {Routes}
            <Route component={NotFound}/>
          </Switch>
          <Footer/>
        </Layout.Content>
      </Layout>
    </Layout>

这里主要用到 antd 的 Layout 布局组件。请看 antd 中 Layout 的示例,和 spug 中的代码和效果几乎相同:

o_230131083428_highqualitybacksystem-systemlayout-03.png

Tip

  1. 这里的 Sider 和 Header 都不是 antd 中的原始组件,已被封装,挪出成一个单独的组件。
  2. <Footer/> 总是在视口底部,受父元素 flex 的影响。请看下图:
o_230131083438_highqualitybacksystem-systemlayout-04.png

Layout 中 index.js 完整代码如下:

// spug\src\layout\index.js

import React, { useState, useEffect } from 'react';
import { Switch, Route } from 'react-router-dom';
import { Layout, message } from 'antd';
import { NotFound } from 'components';
import Sider from './Sider';
import Header from './Header';
import Footer from './Footer'
/*
对象数组。就像这样:

[
  { icon: <DesktopOutlined />, title: '工作台', path: '/home', component: HomeIndex },
  ...
  {
    icon: <AlertOutlined />, title: '报警中心', auth: 'alarm.alarm.view|alarm.contact.view|alarm.group.view', child: [
      { title: '报警历史', auth: 'alarm.alarm.view', path: '/alarm/alarm', component: AlarmIndex },
      { title: '报警联系人', auth: 'alarm.contact.view', path: '/alarm/contact', component: AlarmContact },
      { title: '报警联系组', auth: 'alarm.group.view', path: '/alarm/group', component: AlarmGroup },
    ]
  },
  ...
]
*/
import routes from '../routes';
import { hasPermission, isMobile } from 'libs';
import styles from './layout.module.less';

// 将 routes 中有权限的路由提取到 Routes 中
function initRoutes(Routes, routes) {
  for (let route of routes) {
    // 叶子节点才有 component。如果没有child则属于叶子节点
    if (route.component) {
      // 如果不需要权限,或有权限则放入 Routes
      if (!route.auth || hasPermission(route.auth)) {
        Routes.push(<Route exact key={route.path} path={route.path} component={route.component}/>)
      }
    } else if (route.child) {
      initRoutes(Routes, route.child)
    }
  }
}

export default function () {
  // 侧边栏收起状态。这里设置为展开
  const [collapsed, setCollapsed] = useState(false)
  // 路由,默认是空数组
  const [Routes, setRoutes] = useState([]);

  // 组件挂载后执行。相当于 componentDidMount()
  useEffect(() => {
     if (isMobile) {
      setCollapsed(true);
      message.warn('检测到您在移动设备上访问,请使用横屏模式。', 5)
    }
    // 注:重新声明一个变量 Routes,比上文的 Routes 作用域更小范围
    const Routes = [];
    initRoutes(Routes, routes);
    // console.log('Routes', Routes)
    // console.log('Routes', JSON.stringify(Routes))
    setRoutes(Routes)
  }, [])


  return (
    // 此处 Layout 是 antd 布局组件。和官方用法相同:
    /*
    <Layout>
      <Sider>Sider</Sider>
      <Layout>
        <Header>Header</Header>
        <Content>Content</Content>
        <Footer>Footer</Footer>
      </Layout>
    </Layout>
    */
    <Layout>
      
      {/* 左侧区域,对 antd 中 Sider 的封装 */}
      <Sider collapsed={collapsed}/>
      {/* 内容高度不够,版权信息在底部;内容高度太高,则需要滚动才可查看全部内容; */}
      <Layout style={{height: '100vh'}}>
        {/* 顶部区域, 对 antd 中 Layout.Header 的封装*/}
        <Header collapsed={collapsed} toggle={() => setCollapsed(!collapsed)}/>
        <Layout.Content className={styles.content}>
          {/* 只渲染第一个路径匹配的组件。类似 if...else。参考:https://www.cnblogs.com/pengjiali/p/16045481.html#Switch */}
          <Switch>
            {/* 路由数组。里面每项类似这样:<Route exact key={route.path} path='/home' component={HomeComponent}/> */}
            {Routes}
            {/* 没有匹配则进入 NotFound */}
            <Route component={NotFound}/>
          </Switch>
          {/* 系统底部展示。例如版权、官网、文档链接、仓库链接*/}
          {/* 父元素采用 flex 布局,当主体内容不多时,版权这部分信息也会置于底部 */}
          <Footer/>
        </Layout.Content>
      </Layout>
    </Layout>
  )
}

左侧导航封装在 Sider(spug\src\layout\Sider.js) 组件中。

利用的是 antd 中的 Menu 组件。就像这样:

// <4.20.0 可用,>=4.20.0 时不推荐
<Menu>
    <Menu.Item>菜单项一</Menu.Item>
    <Menu.Item>菜单项二</Menu.Item>
    <Menu.SubMenu title="子菜单">
        <Menu.Item>子菜单项</Menu.Item>
    </Menu.SubMenu>
</Menu>;

完整代码如下:

// spug\src\layout\Sider.js

import React, { useState } from 'react';
import { Layout, Menu } from 'antd';
import { hasPermission, history } from 'libs';
import styles from './layout.module.less';
/*
对象数组。就像这样:

[
  { icon: <DesktopOutlined />, title: '工作台', path: '/home', component: HomeIndex },
  ...
  {
    icon: <AlertOutlined />, title: '报警中心', auth: 'alarm.alarm.view|alarm.contact.view|alarm.group.view', child: [
      { title: '报警历史', auth: 'alarm.alarm.view', path: '/alarm/alarm', component: AlarmIndex },
      { title: '报警联系人', auth: 'alarm.contact.view', path: '/alarm/contact', component: AlarmContact },
      { title: '报警联系组', auth: 'alarm.group.view', path: '/alarm/group', component: AlarmGroup },
    ]
  },
  ...
]
*/
import menus from '../routes';
import logo from './spug.png'
// 当前选中的菜单项 key 数组
let selectedKey = window.location.pathname;
/*
初始化菜单映射。如果输入不存在的路径,那么菜单则无需选中

{
/home: 1,                   // 一级菜单
/dashboard: 1,              // 一级菜单
...
/alarm/alarm: "报警中心",   // 二级菜单
/alarm/contact: "报警中心", // 二级菜单
/alarm/group: "报警中心",   // 二级菜单
...
}
*/
const OpenKeysMap = {};

for (let item of menus) {
  if (item.child) {
    for (let sub of item.child) {
      // child 中的节点值为 item.title
      if (sub.title) OpenKeysMap[sub.path] = item.title
    }
  } else if (item.title) {
    // 一级节点的值是 1
    OpenKeysMap[item.path] = 1
  }
}

export default function Sider(props) {
  // openKeys	当前展开的 SubMenu 菜单项 key 数组 string[]
  // const [openKeys, setOpenKeys] = useState([]);

  // 根据路由返回菜单项或子菜单。没有权限或没有 title 返回 null
  function makeMenu(menu) {
    // 如果没有权限
    if (menu.auth && !hasPermission(menu.auth)) return null;
    // 没有 title 返回 null
    if (!menu.title) return null;
    // 如果有 child 则调用 _makeSubMenu;没有 child 则调用 _makeItem
    return menu.child ? _makeSubMenu(menu) : _makeItem(menu)
  }

  // 返回子菜单
  function _makeSubMenu(menu) {
    return (
      <Menu.SubMenu key={menu.title} title={<span>{menu.icon}<span>{menu.title}</span></span>}>
        {menu.child.map(menu => makeMenu(menu))}
      </Menu.SubMenu>
    )
  }

  // 返回菜单项
  function _makeItem(menu) {
    return (
      <Menu.Item key={menu.path}>
        {menu.icon}
        <span>{menu.title}</span>
      </Menu.Item>
    )
  }
  // window.location.pathname 返回当前页面的路径或文件名
  // 例如 https://demo.spug.cc/host?name=pjl 返回 /host
  const tmp = window.location.pathname;
  const openKey = OpenKeysMap[tmp];
  // 如果是不存在的路径(例如 /host9999),菜单则无需选中
  if (openKey) {
    // 当前选中的菜单项 key 数组。
    selectedKey = tmp;
    // 更新子菜单。`openKey 不是1` && `侧边栏展开` && 
    // if (openKey !== 1 && !props.collapsed && !openKeys.includes(openKey)) {
    //   setOpenKeys([...openKeys, openKey])
    // }
  }
  // 下面的className都仅仅让样式好看点,对功能没有影响。
  return (
    // Sider:侧边栏,自带默认样式及基本功能,其下可嵌套任何元素,只能放在 Layout 中。
    // collapsed - 当前收起状态。这里设置为默认展开
    <Layout.Sider width={208} collapsed={props.collapsed} className={styles.sider}>
      {/* 图标 */}
      <div className={styles.logo}>
        <img src={logo} alt="Logo" style={{ height: '30px' }} />
      </div>
      <div className={styles.menus} style={{ height: `${document.body.clientHeight - 64}px` }}>
        {/* 导航菜单。使用的是`缩起内嵌菜单` */}
        <Menu
          theme="dark"
          mode="inline"
          className={styles.menus}
          // 当前选中的菜单项 key 数组
          selectedKeys={[selectedKey]}
          // openKeys	当前展开的 SubMenu 菜单项 key 数组 string[]
          // openKeys={openKeys}
          // onOpenChange - SubMenu 展开/关闭的回调
          // onOpenChange={setOpenKeys}
          // 路由切换。点击哪个导航,url和路由就会切换到该路劲
          onSelect={menu => history.push(menu.key)}>
          {/* 数组中的 null 会被忽略 */}
          {menus.map(menu => makeMenu(menu))}
        </Menu>
      </div>
    </Layout.Sider>
  )
}

代码简析:

  • 模块返回一个侧边栏 <Layout.Sider>,里面使用菜单组件 Menu,Menu 中的 openKeys 和 onOpenChange 的逻辑有点凌乱,这里将其注释,对于切换菜单没有影响
  • menus 来自路由(routes.js),菜单中的内容由 makeMenu() 返回
  • 侧边栏默认展开,由父组件传入的 collapsed 决定
  • OpenKeysMap 其中一个作用是,当你输入的路径不在菜单中,菜单项则无需选中

头部组件比较简单,分为三块:左侧导航伸缩控制区、通知区和用户区。

点击用户区个人中心,主体区域路由会跳转。效果如下图所示:

o_230131083445_highqualitybacksystem-systemlayout-05.png

完整代码:

// spug\src\layout\Header.js

import React from 'react';
import { Link } from 'react-router-dom';
import { Layout, Dropdown, Menu, Avatar } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined, LogoutOutlined } from '@ant-design/icons';
import Notification from './Notification';
import styles from './layout.module.less';
import http from '../libs/http';
import history from '../libs/history';
import avatar from './avatar.png';

export default function (props) {
  // 退出
  function handleLogout() {
    // 跳转到登录页
    history.push('/');
    // 告诉后端退出登录
    http.get('/api/account/logout/')
  }


  const UserMenu = (
    <Menu>
      <Menu.Item>
        {/* 路由跳转。主体区域对应路由是 `{ path: '/welcome/info', component: WelcomeInfo },` */}
        <Link to="/welcome/info">
          <UserOutlined style={{marginRight: 10}}/>个人中心
        </Link>
      </Menu.Item>
      <Menu.Divider/>
      <Menu.Item onClick={handleLogout}>
        <LogoutOutlined style={{marginRight: 10}}/>退出登录
      </Menu.Item>
    </Menu>
  );

  return (
    <Layout.Header className={styles.header}>
      {/* 收缩左侧导航按钮 */}
      <div className={styles.left}>
        {/* 点击触发父组件的 toggle 方法 */}
        <div className={styles.trigger} onClick={props.toggle}>
          {/* 根据父组件的 collapsed 属性显示对应图标*/}
          {props.collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}
        </div>
      </div>
      {/* 通知 */}
      <Notification/>
      {/* 用户区域 */}
      <div className={styles.right}>
        <Dropdown overlay={UserMenu} style={{background: '#000'}}>
          <span className={styles.action}>
            <Avatar size="small" src={avatar} style={{marginRight: 8}}/>
            {/* 登录后设置过的昵称 */}
            {localStorage.getItem('nickname')}
          </span>
        </Dropdown>
      </div>
    </Layout.Header>
  )
}

主体区域更简单,就是一个组件(根据自己需求自行完成)。如果需要面包屑,自行加上即可。有无面包屑导航的效果如下图所示:

o_230131083512_highqualitybacksystem-systemlayout-06.png

主页(/home) 代码可以浏览下:

// spug\src\pages\home\index.js

function HomeIndex() {
  return (
    <div>
      {/* 面包屑 */}
      <Breadcrumb>
        <Breadcrumb.Item>首页</Breadcrumb.Item>
        <Breadcrumb.Item>工作台</Breadcrumb.Item>
      </Breadcrumb>

      <Row gutter={12}>
        <Col span={16}>
          <NavIndex />
        </Col>
        <Col span={8}>
          <Row gutter={[12, 12]}>
            <Col span={24}>
              <TodoIndex />
            </Col>
            <Col span={24}>
              <NoticeIndex />
            </Col>
          </Row>
        </Col>
      </Row>
    </div>
  )
}

export default HomeIndex

myspug 系统布局的实现

在 App.js 中引入 Layout 组件,之前我们是一个占位组件:

// myspug\src\App.js
-import HelloWorld from './HelloWord'
+import Layout from './layout'
 import { Switch, Route } from 'react-router-dom';

 // 定义一个类组件
class App extends Component {
       <Switch>
         <Route path="/" exact component={Login} />
         {/* 没有匹配则进入 Layout */}
-        <Route component={HelloWorld} />
+        <Route component={Layout} />
       </Switch>
     );
}

Layout 中 index.js 代码如下:

// myspug\src\layout\index.js

import React, { useState, useEffect } from 'react';
import { Switch, Route } from 'react-router-dom';
import { Layout, message } from 'antd';
// 404 对应的组件
/*

//  myspug\src\compoments\index.js
import NotFound from './NotFound';

export {
    NotFound,
}

*/
import { NotFound } from '@/components';
// 侧边栏
import Sider from './Sider';
// 头部
import Header from './Header';
// 页脚。例如版权
import Footer from './Footer'

/*
引入路由。对象数组,就像这样:

[
  { icon: <DesktopOutlined />, title: '工作台', path: '/home', component: HomeIndex },
  ...
  {
    icon: <AlertOutlined />, title: '报警中心', auth: 'alarm.alarm.view|alarm.contact.view|alarm.group.view', child: [
      { title: '报警历史', auth: 'alarm.alarm.view', path: '/alarm/alarm', component: AlarmIndex },
      { title: '报警联系人', auth: 'alarm.contact.view', path: '/alarm/contact', component: AlarmContact },
      { title: '报警联系组', auth: 'alarm.group.view', path: '/alarm/group', component: AlarmGroup },
    ]
  },
  ...
]
*/
import routes from '../routes';
// hasPermission - 权限判断。本篇忽略,这里直接返回 true; isMobile - 是否是手机
/*
export function hasPermission(strCode) {
    return true
}
// 基于检测用户代理字符串的浏览器标识是不可靠的,不推荐使用,因为用户代理字符串是用户可配置的
export const isMobile = /Android|iPhone/i.test(navigator.userAgent)

*/
import { hasPermission, isMobile } from '@/libs';

// 布局样式,直接拷贝 spug 中的样式即可
import styles from './layout.module.less';

// 将 routes 中有权限的路由提取到 Routes 中
function initRoutes(Routes, routes) {
  for (let route of routes) {
    // 叶子节点才有 component。没有 child 则属于叶子节点
    if (route.component) {
      // 如果不需要权限,或有权限则放入 Routes
      if (!route.auth || hasPermission(route.auth)) {
        Routes.push(<Route exact key={route.path} path={route.path} component={route.component} />)
      }
    } else if (route.child) {
      initRoutes(Routes, route.child)
    }
  }
}

export default function () {
  // 侧边栏收缩状态。默认展开
  const [collapsed, setCollapsed] = useState(false)
  // 路由,默认是空数组
  const [Routes, setRoutes] = useState([]);

  // 组件挂载后执行。相当于 componentDidMount()
  useEffect(() => {
    if (isMobile) {
      // 手机查看时导航栏收起
      setCollapsed(true);
      message.warn('检测到您在移动设备上访问,请使用横屏模式。', 5)
    }

    // 注:重新声明一个变量 Routes,比上文(useState 中的 Routes)的 Routes 作用域更小范围
    const Routes = [];
    initRoutes(Routes, routes);
    setRoutes(Routes)
  }, [])

  return (
    // 此处 Layout 是 antd 布局组件。和官方用法相同:
    /*
    <Layout>
      <Sider>Sider</Sider>
      <Layout>
        <Header>Header</Header>
        <Content>Content</Content>
        <Footer>Footer</Footer>
      </Layout>
    </Layout>
    */
    <Layout>

      {/* 左侧区域,对 antd 中 Sider 的封装 */}
      <Sider collapsed={collapsed} />
      {/* 内容高度不够,版权信息在底部;内容高度太高,则需要滚动才可查看全部内容; */}
      <Layout style={{ height: '100vh' }}>
        {/* 顶部区域, 对 antd 中 Layout.Header 的封装*/}
        <Header collapsed={collapsed} toggle={() => setCollapsed(!collapsed)} />
        <Layout.Content className={styles.content}>
          {/* 只渲染第一个路径匹配的组件*/}
          <Switch>
            {/* 路由数组。里面每项类似这样:<Route exact key={route.path} path='/home' component={HomeComponent}/> */}
            {Routes}
            {/* 没有匹配则进入 NotFound */}
            <Route component={NotFound} />
          </Switch>
          {/* 系统底部展示。例如版权、官网、文档链接、仓库链接*/}
          <Footer />
        </Layout.Content>
      </Layout>
    </Layout>
  )
}

在 routes.js 中定义3个路由,其中报警中心里面有三个子菜单,用同一个组件做占位:

// myspug\src\routes.js

import React from 'react';
import {
    DesktopOutlined,
    AlertOutlined,
} from '@ant-design/icons';
/*
export default function HomeIndex() {
    return <div>我是主页</div>
}
*/
import HomeIndex from './pages/home';
// 占位效果
/*
export default function AlarmCenter() {
    return <div>报警中心占位符 - {window.location.pathname}</div>
}
*/
import AlarmCenter from './pages/alarm/alarm';
// 个人中心
/*
export default function HomeIndex() {
    return <div>我是个人中心</div>
}
*/
import WelcomeInfo from './pages/welcome/info';

export default [
    { icon: <DesktopOutlined />, title: '工作台', path: '/home', component: HomeIndex },
    {
        icon: <AlertOutlined />, title: '报警中心', auth: 'alarm.alarm.view|alarm.contact.view|alarm.group.view', child: [
          { title: '报警历史', auth: 'alarm.alarm.view', path: '/alarm/alarm', component: AlarmCenter },
          { title: '报警联系人', auth: 'alarm.contact.view', path: '/alarm/contact', component: AlarmCenter },
          { title: '报警联系组', auth: 'alarm.group.view', path: '/alarm/group', component: AlarmCenter },
        ]
      },
    { path: '/welcome/info', component: WelcomeInfo },
]

Tip: <Footer> 组件直接拷贝 spug 中的

NotFound 代码如下:

// myspug\src\compoments\NotFound.js
import React from 'react';
// 拷贝 spug 中的内容
import styles from './index.module.less';

export default function NotFound() {
    return (
        <div className={styles.notFound}>
            <div className={styles.imgBlock}>
                <div className={styles.img} />
            </div>
            <div>
                <h1 className={styles.title}>404</h1>
                <div className={styles.desc}>抱歉,你访问的页面不存在</div>
            </div>
        </div>
    )
}
// myspug\src\layout\Sider.js

import React, { useState } from 'react';
import { Layout, Menu } from 'antd';
import { hasPermission, history } from '@/libs';
import styles from './layout.module.less';
/*
对象数组。就像这样:

[
  { icon: <DesktopOutlined />, title: '工作台', path: '/home', component: HomeIndex },
  ...
  {
    icon: <AlertOutlined />, title: '报警中心', auth: 'alarm.alarm.view|alarm.contact.view|alarm.group.view', child: [
      { title: '报警历史', auth: 'alarm.alarm.view', path: '/alarm/alarm', component: AlarmIndex },
      { title: '报警联系人', auth: 'alarm.contact.view', path: '/alarm/contact', component: AlarmContact },
      { title: '报警联系组', auth: 'alarm.group.view', path: '/alarm/group', component: AlarmGroup },
    ]
  },
  ...
]
*/
import menus from '../routes';

import logo from './spug.png'

let selectedKey = window.location.pathname;
/*
菜单映射。如果输入不存在的路径,那么菜单就不需要选中

{
/home: 1,                   // 一级菜单
/dashboard: 1,              // 一级菜单
...
/alarm/alarm: "报警中心",   // 二级菜单
/alarm/contact: "报警中心", // 二级菜单
/alarm/group: "报警中心",   // 二级菜单
...
}
*/
const OpenKeysMap = {};

for (let item of menus) {
  if (item.child) {
    for (let sub of item.child) {
      // child 中的节点值为 item.title
      if (sub.title) OpenKeysMap[sub.path] = item.title
    }
  } else if (item.title) {
    // 一级节点的值是 1
    OpenKeysMap[item.path] = 1
  }
}

export default function Sider(props) {
  // 根据路由返回菜单项或子菜单。没有权限或没有 title 返回 null
  function makeMenu(menu) {
    // 如果没有权限
    if (menu.auth && !hasPermission(menu.auth)) return null;
    // 没有 title 返回 null
    if (!menu.title) return null;
    // 如果有 child 则调用 _makeSubMenu;没有 child 则调用 _makeItem
    return menu.child ? _makeSubMenu(menu) : _makeItem(menu)
  }

  // 返回子菜单
  function _makeSubMenu(menu) {
    return (
      <Menu.SubMenu key={menu.title} title={<span>{menu.icon}<span>{menu.title}</span></span>}>
        {menu.child.map(menu => makeMenu(menu))}
      </Menu.SubMenu>
    )
  }

  // 返回菜单项
  function _makeItem(menu) {
    return (
      <Menu.Item key={menu.path}>
        {menu.icon}
        <span>{menu.title}</span>
      </Menu.Item>
    )
  }
  // window.location.pathname 返回当前页面的路径或文件名
  // 例如 https://demo.spug.cc/host?name=pjl 返回 /host
  const tmp = window.location.pathname;
  const openKey = OpenKeysMap[tmp];
  // 如果是不存在的路径(例如 /host9999),菜单则无需选中
  if (openKey) {
    // 当前选中的菜单项 key 数组。
    selectedKey = tmp;
  }
  // 下面的className都仅仅让样式好看点,对功能没有影响。
  return (
    // Sider:侧边栏,自带默认样式及基本功能,其下可嵌套任何元素,只能放在 Layout 中。
    // collapsed - 当前收起状态。这里设置为默认展开
    <Layout.Sider width={208} collapsed={props.collapsed} className={styles.sider}>
      {/* 图标 */}
      <div className={styles.logo}>
        <img src={logo} alt="Logo" style={{ height: '30px' }} />
      </div>
      <div className={styles.menus} style={{ height: `${document.body.clientHeight - 64}px` }}>
        {/* 导航菜单。使用的是`缩起内嵌菜单` */}
        <Menu
          theme="dark"
          mode="inline"
          className={styles.menus}
          // 当前选中的菜单项 key 数组
          selectedKeys={[selectedKey]}
          // 路由切换。点击哪个导航,url和路由就会切换到该路劲
          onSelect={menu => history.push(menu.key)}>
          {/* 数组中的 null 会被忽略 */}
          {menus.map(menu => makeMenu(menu))}
        </Menu>
      </div>
    </Layout.Sider>
  )
}

Tip通知暂不实现

代码如下:

// myspug\src\layout\Header.js

import React from 'react';
import { Link } from 'react-router-dom';
import { Layout, Dropdown, Menu, Avatar } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined, LogoutOutlined } from '@ant-design/icons';
//  `通知`暂不实现
//  import Notification from './Notification';
import styles from './layout.module.less';
import http from '../libs/http';
import history from '../libs/history';
import avatar from './avatar.png';

export default function (props) {
  // 退出
  function handleLogout() {
    // 跳转到登录页
    history.push('/');
    // 告诉后端退出登录
    http.get('/api/account/logout/')
  }

  const UserMenu = (
    <Menu>
      <Menu.Item>
        {/* 路由跳转。主体区域对应路由是 `{ path: '/welcome/info', component: WelcomeInfo },` */}
        <Link to="/welcome/info">
          <UserOutlined style={{ marginRight: 10 }} />个人中心
        </Link>
      </Menu.Item>
      <Menu.Divider />
      <Menu.Item onClick={handleLogout}>
        <LogoutOutlined style={{ marginRight: 10 }} />退出登录
      </Menu.Item>
    </Menu>
  );

  return (
    <Layout.Header className={styles.header}>
      {/* 收缩左侧导航按钮 */}
      <div className={styles.left}>
        {/* 点击触发父组件的 toggle 方法 */}
        <div className={styles.trigger} onClick={props.toggle}>
          {/* 根据父组件的 collapsed 属性显示对应图标*/}
          {props.collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
        </div>
      </div>
      {/* 通知 */}
      <div>通知 todo</div>
      {/* <Notification/> */}
      {/* 用户区域 */}
      <div className={styles.right}>
        <Dropdown overlay={UserMenu} style={{ background: '#000' }}>
          <span className={styles.action}>
            <Avatar size="small" src={avatar} style={{ marginRight: 8 }} />
            {/* 登录后设置过的昵称 */}
            {localStorage.getItem('nickname')}
          </span>
        </Dropdown>
      </div>
    </Layout.Header>
  )
}

less 模块化样式的配置

Tip: 样式模块化的更多介绍请看 这里

目前 myspug 支持 index.module.css:

// 支持
import helloWorld from './index.module.css'

export default function HelloWorld() {
    return <div className={helloWorld.title}>hello world!</div>
}

却不支持 .module.less 这种模块化的写法:

// 不支持
import helloWorld from './index.module.less'

export default function HelloWorld() {
    return <div className={helloWorld.title}>hello world!</div>
}

你会发现 div 元素上的 class 是空的。

使其支持费了一些波折:

  • 参考 spug\config-overrides.js 添加 addLessLoader() 报错,修改 addLessLoader 新语法也报错,将 less、less-loader更新至与 spug 中相同版本不行,安装 postCss 报新错
  • 使用 antd 中自定义主题的方式成功跑起来,但按钮总是绿色

最终解决方法如下:

 // config-overrides.js
-const { override, fixBabelImports, addWebpackAlias } = require('customize-cra');
+const { override, fixBabelImports, addWebpackAlias, addLessLoader, adjustStyleLoaders } = require('customize-cra');
 const path = require('path')
 module.exports = override(
     fixBabelImports('import', {
     module.exports = override(
     // 增加别名。避免 ../../ 相对路劲引入 libs/http
     addWebpackAlias({
         '@': path.resolve(__dirname, './src')
-    })
+    }),
+    // 解决
+    addLessLoader({
+        lessOptions: {
+            javascriptEnabled: true,
+            localIdentName: '[local]--[hash:base64:5]'
+        }
+    }),
+    // 网友`阖湖丶`的介绍,解决:ValidationError: Invalid options object. PostCSS Loader has been initialized...
+    adjustStyleLoaders(({ use: [, , postcss] }) => {
+        const postcssOptions = postcss.options;
+        postcss.options = { postcssOptions };
+    }),
 );

最终效果:

o_230131083524_highqualitybacksystem-systemlayout-08.gif
o_230131083519_highqualitybacksystem-systemlayout-07.png
  • 登录成功默认进入主页
  • 点击报警历史,url 切换为 /alarm/alarm,菜单选中项更新,同时主体区域显示对应信息
  • 鼠标移至管理员,点击个人中心,url切换,菜单选中项不变,同时主体区域显示对应信息
  • 对于不存在的 url ,内容区域会显示 404 的效果,同时菜单选中项会清空

其他章节请看:

react 高效高质量搭建后台系统 系列


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK