鬂已星星也 發表於 2023-1-31 16:47:00

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

<blockquote>
<p>其他章节请看:</p>
<p>react 高效高质量搭建后台系统 系列</p>
</blockquote>
<h2 id="系统布局">系统布局</h2>
<p>前面我们用<code>脚手架搭建</code>了项目,并实现了<code>登录模块</code>,登录模块所依赖的<code>请求数据</code>和<code>antd</code>(ui框架和样式)也已完成。</p>
<p>本篇将完成<code>系统布局</code>。比如导航区、头部区域、主体区域、页脚。</p>
<p>最终效果如下:</p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083524_highqualitybacksystem-systemlayout-08.gif"></p>
<h3 id="spug-中系统布局的分析">spug 中系统布局的分析</h3>
<p>spug 登录成功后进入系统,页面分为三大块:左侧导航、头部和主体区域。如下图所示:</p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083413_highqualitybacksystem-systemlayout-01.png"></p>
<p><em>Tip</em>:spug 将版权部分也放在主体区域内。</p>
<p>切换左侧导航,<code>主体</code>内容会跟着变化,头部区域不变。例如从<code>工作台</code>切换到 <code>Dashboard</code>,就像这样:</p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083421_highqualitybacksystem-systemlayout-02.png"></p>
<h4 id="入口">入口</h4>
<p>登录成功后,进入系统。也就是进入 Layout 组件。</p>
<pre><code class="language-javascript">// App.js
class App extends Component {
   render() {
   return (
       &lt;Switch&gt;
         &lt;Route path="/" exact component={Login} /&gt;
         {/* 系统登录后进入 Layout 组件 */}
         &lt;Route component={Layout} /&gt;
       &lt;/Switch&gt;
   );
   }
}
</code></pre>
<p>Layout下index.js渲染的代码如下:</p>
<pre><code class="language-javascript">return (
    &lt;Layout&gt;
      {/* 左侧区域,对 antd 中 Sider 的封装 */}
      &lt;Sider collapsed={collapsed}/&gt;
      &lt;Layout style={{height: '100vh'}}&gt;
      {/* 顶部区域, 对 antd 中 Layout.Header 的封装*/}
      &lt;Header collapsed={collapsed} toggle={() =&gt; setCollapsed(!collapsed)}/&gt;
      &lt;Layout.Content className={styles.content}&gt;
          &lt;Switch&gt;
            {Routes}
            &lt;Route component={NotFound}/&gt;
          &lt;/Switch&gt;
          &lt;Footer/&gt;
      &lt;/Layout.Content&gt;
      &lt;/Layout&gt;
    &lt;/Layout&gt;
</code></pre>
<p>这里主要用到 <code>antd</code> 的 Layout 布局组件。请看 antd 中 Layout 的示例,和 spug 中的代码和效果几乎相同:</p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083428_highqualitybacksystem-systemlayout-03.png"></p>
<p><em>Tip</em>:</p>
<ol>
<li>这里的 Sider 和 Header 都不是 antd 中的原始组件,已被封装,挪出成一个单独的组件。</li>
<li><code>&lt;Footer/&gt;</code> 总是在视口底部,受父元素 flex 的影响。请看下图:</li>
</ol>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083438_highqualitybacksystem-systemlayout-04.png"></p>
<p>Layout 中 index.js 完整代码如下:</p>
<pre><code class="language-javascript">// 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: &lt;DesktopOutlined /&gt;, title: '工作台', path: '/home', component: HomeIndex },
...
{
    icon: &lt;AlertOutlined /&gt;, 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(&lt;Route exact key={route.path} path={route.path} component={route.component}/&gt;)
      }
    } else if (route.child) {
      initRoutes(Routes, route.child)
    }
}
}

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

// 组件挂载后执行。相当于 componentDidMount()
useEffect(() =&gt; {
   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 布局组件。和官方用法相同:
    /*
    &lt;Layout&gt;
      &lt;Sider&gt;Sider&lt;/Sider&gt;
      &lt;Layout&gt;
      &lt;Header&gt;Header&lt;/Header&gt;
      &lt;Content&gt;Content&lt;/Content&gt;
      &lt;Footer&gt;Footer&lt;/Footer&gt;
      &lt;/Layout&gt;
    &lt;/Layout&gt;
    */
    &lt;Layout&gt;
      
      {/* 左侧区域,对 antd 中 Sider 的封装 */}
      &lt;Sider collapsed={collapsed}/&gt;
      {/* 内容高度不够,版权信息在底部;内容高度太高,则需要滚动才可查看全部内容; */}
      &lt;Layout style={{height: '100vh'}}&gt;
      {/* 顶部区域, 对 antd 中 Layout.Header 的封装*/}
      &lt;Header collapsed={collapsed} toggle={() =&gt; setCollapsed(!collapsed)}/&gt;
      &lt;Layout.Content className={styles.content}&gt;
          {/* 只渲染第一个路径匹配的组件。类似 if...else。参考:https://www.cnblogs.com/pengjiali/p/16045481.html#Switch */}
          &lt;Switch&gt;
            {/* 路由数组。里面每项类似这样:&lt;Route exact key={route.path} path='/home' component={HomeComponent}/&gt; */}
            {Routes}
            {/* 没有匹配则进入 NotFound */}
            &lt;Route component={NotFound}/&gt;
          &lt;/Switch&gt;
          {/* 系统底部展示。例如版权、官网、文档链接、仓库链接*/}
          {/* 父元素采用 flex 布局,当主体内容不多时,版权这部分信息也会置于底部 */}
          &lt;Footer/&gt;
      &lt;/Layout.Content&gt;
      &lt;/Layout&gt;
    &lt;/Layout&gt;
)
}
</code></pre>
<h4 id="左侧导航">左侧导航</h4>
<p>左侧导航封装在 Sider(<code>spug\src\layout\Sider.js</code>) 组件中。</p>
<p>利用的是 antd 中的 Menu 组件。就像这样:</p>
<pre><code class="language-javascript">// &lt;4.20.0 可用,&gt;=4.20.0 时不推荐
&lt;Menu&gt;
    &lt;Menu.Item&gt;菜单项一&lt;/Menu.Item&gt;
    &lt;Menu.Item&gt;菜单项二&lt;/Menu.Item&gt;
    &lt;Menu.SubMenu title="子菜单"&gt;
      &lt;Menu.Item&gt;子菜单项&lt;/Menu.Item&gt;
    &lt;/Menu.SubMenu&gt;
&lt;/Menu&gt;;
</code></pre>
<p>完整代码如下:</p>
<pre><code class="language-javascript">// 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: &lt;DesktopOutlined /&gt;, title: '工作台', path: '/home', component: HomeIndex },
...
{
    icon: &lt;AlertOutlined /&gt;, 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 = item.title
    }
} else if (item.title) {
    // 一级节点的值是 1
    OpenKeysMap = 1
}
}

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

// 根据路由返回菜单项或子菜单。没有权限或没有 title 返回 null
function makeMenu(menu) {
    // 如果没有权限
    if (menu.auth &amp;&amp; !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 (
      &lt;Menu.SubMenu key={menu.title} title={&lt;span&gt;{menu.icon}&lt;span&gt;{menu.title}&lt;/span&gt;&lt;/span&gt;}&gt;
      {menu.child.map(menu =&gt; makeMenu(menu))}
      &lt;/Menu.SubMenu&gt;
    )
}

// 返回菜单项
function _makeItem(menu) {
    return (
      &lt;Menu.Item key={menu.path}&gt;
      {menu.icon}
      &lt;span&gt;{menu.title}&lt;/span&gt;
      &lt;/Menu.Item&gt;
    )
}
// window.location.pathname 返回当前页面的路径或文件名
// 例如 https://demo.spug.cc/host?name=pjl 返回 /host
const tmp = window.location.pathname;
const openKey = OpenKeysMap;
// 如果是不存在的路径(例如 /host9999),菜单则无需选中
if (openKey) {
    // 当前选中的菜单项 key 数组。
    selectedKey = tmp;
    // 更新子菜单。`openKey 不是1` &amp;&amp; `侧边栏展开` &amp;&amp;
    // if (openKey !== 1 &amp;&amp; !props.collapsed &amp;&amp; !openKeys.includes(openKey)) {
    //   setOpenKeys([...openKeys, openKey])
    // }
}
// 下面的className都仅仅让样式好看点,对功能没有影响。
return (
    // Sider:侧边栏,自带默认样式及基本功能,其下可嵌套任何元素,只能放在 Layout 中。
    // collapsed - 当前收起状态。这里设置为默认展开
    &lt;Layout.Sider width={208} collapsed={props.collapsed} className={styles.sider}&gt;
      {/* 图标 */}
      &lt;div className={styles.logo}&gt;
      &lt;img src={logo} alt="Logo" style={{ height: '30px' }} /&gt;
      &lt;/div&gt;
      &lt;div className={styles.menus} style={{ height: `${document.body.clientHeight - 64}px` }}&gt;
      {/* 导航菜单。使用的是`缩起内嵌菜单` */}
      &lt;Menu
          theme="dark"
          mode="inline"
          className={styles.menus}
          // 当前选中的菜单项 key 数组
          selectedKeys={}
          // openKeys        当前展开的 SubMenu 菜单项 key 数组 string[]
          // openKeys={openKeys}
          // onOpenChange - SubMenu 展开/关闭的回调
          // onOpenChange={setOpenKeys}
          // 路由切换。点击哪个导航,url和路由就会切换到该路劲
          onSelect={menu =&gt; history.push(menu.key)}&gt;
          {/* 数组中的 null 会被忽略 */}
          {menus.map(menu =&gt; makeMenu(menu))}
      &lt;/Menu&gt;
      &lt;/div&gt;
    &lt;/Layout.Sider&gt;
)
}
</code></pre>
<p>代码简析:</p>
<ul>
<li>模块返回一个侧边栏 <code>&lt;Layout.Sider&gt;</code>,里面使用菜单组件 Menu,Menu 中的 openKeys 和 onOpenChange 的逻辑有点凌乱,这里将其注释,对于切换菜单没有影响</li>
<li>menus 来自路由(<code>routes.js</code>),菜单中的内容由 makeMenu() 返回</li>
<li>侧边栏默认展开,由父组件传入的 collapsed 决定</li>
<li>OpenKeysMap 其中一个作用是,当你输入的路径不在菜单中,菜单项则无需选中</li>
</ul>
<h4 id="头部">头部</h4>
<p>头部组件比较简单,分为三块:左侧导航伸缩控制区、通知区和用户区。</p>
<p>点击用户区<code>个人中心</code>,主体区域路由会跳转。效果如下图所示:</p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083445_highqualitybacksystem-systemlayout-05.png"></p>
<p>完整代码:</p>
<pre><code class="language-javascript">// 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 = (
    &lt;Menu&gt;
      &lt;Menu.Item&gt;
      {/* 路由跳转。主体区域对应路由是 `{ path: '/welcome/info', component: WelcomeInfo },` */}
      &lt;Link to="/welcome/info"&gt;
          &lt;UserOutlined style={{marginRight: 10}}/&gt;个人中心
      &lt;/Link&gt;
      &lt;/Menu.Item&gt;
      &lt;Menu.Divider/&gt;
      &lt;Menu.Item onClick={handleLogout}&gt;
      &lt;LogoutOutlined style={{marginRight: 10}}/&gt;退出登录
      &lt;/Menu.Item&gt;
    &lt;/Menu&gt;
);

return (
    &lt;Layout.Header className={styles.header}&gt;
      {/* 收缩左侧导航按钮 */}
      &lt;div className={styles.left}&gt;
      {/* 点击触发父组件的 toggle 方法 */}
      &lt;div className={styles.trigger} onClick={props.toggle}&gt;
          {/* 根据父组件的 collapsed 属性显示对应图标*/}
          {props.collapsed ? &lt;MenuUnfoldOutlined/&gt; : &lt;MenuFoldOutlined/&gt;}
      &lt;/div&gt;
      &lt;/div&gt;
      {/* 通知 */}
      &lt;Notification/&gt;
      {/* 用户区域 */}
      &lt;div className={styles.right}&gt;
      &lt;Dropdown overlay={UserMenu} style={{background: '#000'}}&gt;
          &lt;span className={styles.action}&gt;
            &lt;Avatar size="small" src={avatar} style={{marginRight: 8}}/&gt;
            {/* 登录后设置过的昵称 */}
            {localStorage.getItem('nickname')}
          &lt;/span&gt;
      &lt;/Dropdown&gt;
      &lt;/div&gt;
    &lt;/Layout.Header&gt;
)
}
</code></pre>
<h4 id="主体区域">主体区域</h4>
<p>主体区域更简单,就是一个组件(根据自己需求自行完成)。如果需要面包屑,自行加上即可。有无面包屑导航的效果如下图所示:</p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083512_highqualitybacksystem-systemlayout-06.png"></p>
<p>主页(<code>/home</code>) 代码可以浏览下:</p>
<pre><code class="language-javascript">// spug\src\pages\home\index.js

function HomeIndex() {
return (
    &lt;div&gt;
      {/* 面包屑 */}
      &lt;Breadcrumb&gt;
      &lt;Breadcrumb.Item&gt;首页&lt;/Breadcrumb.Item&gt;
      &lt;Breadcrumb.Item&gt;工作台&lt;/Breadcrumb.Item&gt;
      &lt;/Breadcrumb&gt;

      &lt;Row gutter={12}&gt;
      &lt;Col span={16}&gt;
          &lt;NavIndex /&gt;
      &lt;/Col&gt;
      &lt;Col span={8}&gt;
          &lt;Row gutter={}&gt;
            &lt;Col span={24}&gt;
            &lt;TodoIndex /&gt;
            &lt;/Col&gt;
            &lt;Col span={24}&gt;
            &lt;NoticeIndex /&gt;
            &lt;/Col&gt;
          &lt;/Row&gt;
      &lt;/Col&gt;
      &lt;/Row&gt;
    &lt;/div&gt;
)
}

export default HomeIndex
</code></pre>
<h3 id="myspug-系统布局的实现">myspug 系统布局的实现</h3>
<h4 id="入口-1">入口</h4>
<p>在 App.js 中引入 <code>Layout 组件</code>,之前我们是一个占位组件:</p>
<pre><code class="language-javascript">// myspug\src\App.js
-import HelloWorld from './HelloWord'
+import Layout from './layout'
import { Switch, Route } from 'react-router-dom';

// 定义一个类组件
class App extends Component {
       &lt;Switch&gt;
         &lt;Route path="/" exact component={Login} /&gt;
         {/* 没有匹配则进入 Layout */}
-      &lt;Route component={HelloWorld} /&gt;
+      &lt;Route component={Layout} /&gt;
       &lt;/Switch&gt;
   );
}
</code></pre>
<p>Layout 中 index.js 代码如下:</p>
<pre><code class="language-javascript">// 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: &lt;DesktopOutlined /&gt;, title: '工作台', path: '/home', component: HomeIndex },
...
{
    icon: &lt;AlertOutlined /&gt;, 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(&lt;Route exact key={route.path} path={route.path} component={route.component} /&gt;)
      }
    } else if (route.child) {
      initRoutes(Routes, route.child)
    }
}
}

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

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

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

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

      {/* 左侧区域,对 antd 中 Sider 的封装 */}
      &lt;Sider collapsed={collapsed} /&gt;
      {/* 内容高度不够,版权信息在底部;内容高度太高,则需要滚动才可查看全部内容; */}
      &lt;Layout style={{ height: '100vh' }}&gt;
      {/* 顶部区域, 对 antd 中 Layout.Header 的封装*/}
      &lt;Header collapsed={collapsed} toggle={() =&gt; setCollapsed(!collapsed)} /&gt;
      &lt;Layout.Content className={styles.content}&gt;
          {/* 只渲染第一个路径匹配的组件*/}
          &lt;Switch&gt;
            {/* 路由数组。里面每项类似这样:&lt;Route exact key={route.path} path='/home' component={HomeComponent}/&gt; */}
            {Routes}
            {/* 没有匹配则进入 NotFound */}
            &lt;Route component={NotFound} /&gt;
          &lt;/Switch&gt;
          {/* 系统底部展示。例如版权、官网、文档链接、仓库链接*/}
          &lt;Footer /&gt;
      &lt;/Layout.Content&gt;
      &lt;/Layout&gt;
    &lt;/Layout&gt;
)
}
</code></pre>
<p>在 routes.js 中定义3个路由,其中报警中心里面有三个子菜单,用同一个组件做占位:</p>
<pre><code class="language-javascript">// myspug\src\routes.js

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

export default [
    { icon: &lt;DesktopOutlined /&gt;, title: '工作台', path: '/home', component: HomeIndex },
    {
      icon: &lt;AlertOutlined /&gt;, 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 },
]
</code></pre>
<p><em>Tip</em>: <code>&lt;Footer&gt;</code> 组件直接拷贝 spug 中的</p>
<p><code>NotFound</code> 代码如下:</p>
<pre><code class="language-javascript">// myspug\src\compoments\NotFound.js
import React from 'react';
// 拷贝 spug 中的内容
import styles from './index.module.less';

export default function NotFound() {
    return (
      &lt;div className={styles.notFound}&gt;
            &lt;div className={styles.imgBlock}&gt;
                &lt;div className={styles.img} /&gt;
            &lt;/div&gt;
            &lt;div&gt;
                &lt;h1 className={styles.title}&gt;404&lt;/h1&gt;
                &lt;div className={styles.desc}&gt;抱歉,你访问的页面不存在&lt;/div&gt;
            &lt;/div&gt;
      &lt;/div&gt;
    )
}
</code></pre>
<h4 id="左侧导航-1">左侧导航</h4>
<pre><code class="language-javascript">// 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: &lt;DesktopOutlined /&gt;, title: '工作台', path: '/home', component: HomeIndex },
...
{
    icon: &lt;AlertOutlined /&gt;, 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 = item.title
    }
} else if (item.title) {
    // 一级节点的值是 1
    OpenKeysMap = 1
}
}

export default function Sider(props) {
// 根据路由返回菜单项或子菜单。没有权限或没有 title 返回 null
function makeMenu(menu) {
    // 如果没有权限
    if (menu.auth &amp;&amp; !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 (
      &lt;Menu.SubMenu key={menu.title} title={&lt;span&gt;{menu.icon}&lt;span&gt;{menu.title}&lt;/span&gt;&lt;/span&gt;}&gt;
      {menu.child.map(menu =&gt; makeMenu(menu))}
      &lt;/Menu.SubMenu&gt;
    )
}

// 返回菜单项
function _makeItem(menu) {
    return (
      &lt;Menu.Item key={menu.path}&gt;
      {menu.icon}
      &lt;span&gt;{menu.title}&lt;/span&gt;
      &lt;/Menu.Item&gt;
    )
}
// window.location.pathname 返回当前页面的路径或文件名
// 例如 https://demo.spug.cc/host?name=pjl 返回 /host
const tmp = window.location.pathname;
const openKey = OpenKeysMap;
// 如果是不存在的路径(例如 /host9999),菜单则无需选中
if (openKey) {
    // 当前选中的菜单项 key 数组。
    selectedKey = tmp;
}
// 下面的className都仅仅让样式好看点,对功能没有影响。
return (
    // Sider:侧边栏,自带默认样式及基本功能,其下可嵌套任何元素,只能放在 Layout 中。
    // collapsed - 当前收起状态。这里设置为默认展开
    &lt;Layout.Sider width={208} collapsed={props.collapsed} className={styles.sider}&gt;
      {/* 图标 */}
      &lt;div className={styles.logo}&gt;
      &lt;img src={logo} alt="Logo" style={{ height: '30px' }} /&gt;
      &lt;/div&gt;
      &lt;div className={styles.menus} style={{ height: `${document.body.clientHeight - 64}px` }}&gt;
      {/* 导航菜单。使用的是`缩起内嵌菜单` */}
      &lt;Menu
          theme="dark"
          mode="inline"
          className={styles.menus}
          // 当前选中的菜单项 key 数组
          selectedKeys={}
          // 路由切换。点击哪个导航,url和路由就会切换到该路劲
          onSelect={menu =&gt; history.push(menu.key)}&gt;
          {/* 数组中的 null 会被忽略 */}
          {menus.map(menu =&gt; makeMenu(menu))}
      &lt;/Menu&gt;
      &lt;/div&gt;
    &lt;/Layout.Sider&gt;
)
}
</code></pre>
<h4 id="头部-1">头部</h4>
<p><em>Tip</em>:<code>通知</code>暂不实现</p>
<p>代码如下:</p>
<pre><code class="language-javascript">// 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 = (
    &lt;Menu&gt;
      &lt;Menu.Item&gt;
      {/* 路由跳转。主体区域对应路由是 `{ path: '/welcome/info', component: WelcomeInfo },` */}
      &lt;Link to="/welcome/info"&gt;
          &lt;UserOutlined style={{ marginRight: 10 }} /&gt;个人中心
      &lt;/Link&gt;
      &lt;/Menu.Item&gt;
      &lt;Menu.Divider /&gt;
      &lt;Menu.Item onClick={handleLogout}&gt;
      &lt;LogoutOutlined style={{ marginRight: 10 }} /&gt;退出登录
      &lt;/Menu.Item&gt;
    &lt;/Menu&gt;
);

return (
    &lt;Layout.Header className={styles.header}&gt;
      {/* 收缩左侧导航按钮 */}
      &lt;div className={styles.left}&gt;
      {/* 点击触发父组件的 toggle 方法 */}
      &lt;div className={styles.trigger} onClick={props.toggle}&gt;
          {/* 根据父组件的 collapsed 属性显示对应图标*/}
          {props.collapsed ? &lt;MenuUnfoldOutlined /&gt; : &lt;MenuFoldOutlined /&gt;}
      &lt;/div&gt;
      &lt;/div&gt;
      {/* 通知 */}
      &lt;div&gt;通知 todo&lt;/div&gt;
      {/* &lt;Notification/&gt; */}
      {/* 用户区域 */}
      &lt;div className={styles.right}&gt;
      &lt;Dropdown overlay={UserMenu} style={{ background: '#000' }}&gt;
          &lt;span className={styles.action}&gt;
            &lt;Avatar size="small" src={avatar} style={{ marginRight: 8 }} /&gt;
            {/* 登录后设置过的昵称 */}
            {localStorage.getItem('nickname')}
          &lt;/span&gt;
      &lt;/Dropdown&gt;
      &lt;/div&gt;
    &lt;/Layout.Header&gt;
)
}
</code></pre>
<h4 id="less-模块化样式的配置">less 模块化样式的配置</h4>
<p><em>Tip</em>: 样式模块化的更多介绍请看 这里</p>
<p>目前 myspug 支持 index.module.css:</p>
<pre><code class="language-javascript">// 支持
import helloWorld from './index.module.css'

export default function HelloWorld() {
    return &lt;div className={helloWorld.title}&gt;hello world!&lt;/div&gt;
}
</code></pre>
<p>却不支持 <code>.module.less</code> 这种模块化的写法:</p>
<pre><code class="language-javascript">// 不支持
import helloWorld from './index.module.less'

export default function HelloWorld() {
    return &lt;div className={helloWorld.title}&gt;hello world!&lt;/div&gt;
}
</code></pre>
<p>你会发现 div 元素上的 class 是空的。</p>
<p>使其支持费了一些波折:</p>
<ul>
<li>参考 <code>spug\config-overrides.js</code> 添加 <code>addLessLoader()</code> 报错,修改 addLessLoader 新语法也报错,将 less、less-loader更新至与 spug 中相同版本不行,安装 postCss 报新错</li>
<li>使用 antd 中自定义主题的方式成功跑起来,但按钮总是绿色</li>
</ul>
<p>最终解决方法如下:</p>
<pre><code class="language-javascript"> // 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: '--'
+      }
+    }),
+    // 网友`阖湖丶`的介绍,解决:ValidationError: Invalid options object. PostCSS Loader has been initialized...
+    adjustStyleLoaders(({ use: [, , postcss] }) =&gt; {
+      const postcssOptions = postcss.options;
+      postcss.options = { postcssOptions };
+    }),
);
</code></pre>
<h3 id="效果验证">效果验证</h3>
<p>最终效果:</p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083524_highqualitybacksystem-systemlayout-08.gif"></p>
<p><img src="https://images.cnblogs.com/cnblogs_com/blogs/665957/galleries/2127271/o_230131083519_highqualitybacksystem-systemlayout-07.png"></p>
<ul>
<li>登录成功默认进入主页</li>
<li>点击<code>报警历史</code>,url 切换为 <code>/alarm/alarm</code>,菜单选中项更新,同时主体区域显示对应信息</li>
<li>鼠标移至<code>管理员</code>,点击<code>个人中心</code>,url切换,菜单选中项不变,同时主体区域显示对应信息</li>
<li>对于不存在的 url ,内容区域会显示 404 的效果,同时菜单选中项会清空</li>
</ul>
<blockquote>
<p>其他章节请看:</p>
<p>react 高效高质量搭建后台系统 系列</p>
</blockquote>


</div>
<div id="MySignature" role="contentinfo">
    <section class="m-reprintStatement" style="white-space:normal;/* 防止break-all与nowrap矛盾 */word-break:break-all;">
    作者:彭加李<br>
    出处:https://www.cnblogs.com/pengjiali/p/17079747.html<br>
    本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。
</section><br><br>
来源:https://www.cnblogs.com/pengjiali/p/17079747.html
頁: [1]
查看完整版本: react 高效高质量搭建后台系统 系列 —— 系统布局