react 高效高質量搭建後臺系統 系列 —— 系統佈局

2023-01-31 18:01:10

其他章節請看:

react 高效高質量搭建後臺系統 系列

系統佈局

前面我們用腳手架搭建了專案,並實現了登入模組,登入模組所依賴的請求資料antd(ui框架和樣式)也已完成。

本篇將完成系統佈局。比如導航區、頭部區域、主體區域、頁尾。

最終效果如下:

spug 中系統佈局的分析

spug 登入成功後進入系統,頁面分為三大塊:左側導航、頭部和主體區域。如下圖所示:

Tip:spug 將版權部分也放在主體區域內。

切換左側導航,主體內容會跟著變化,頭部區域不變。例如從工作臺切換到 Dashboard,就像這樣:

入口

登入成功後,進入系統。也就是進入 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 中的程式碼和效果幾乎相同:

Tip

  1. 這裡的 Sider 和 Header 都不是 antd 中的原始元件,已被封裝,挪出成一個單獨的元件。
  2. <Footer/> 總是在視口底部,受父元素 flex 的影響。請看下圖:

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

完整程式碼:

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

主體區域

主體區域更簡單,就是一個元件(根據自己需求自行完成)。如果需要麵包屑,自行加上即可。有無麵包屑導航的效果如下圖所示:

主頁(/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}>

  • 登入成功預設進入主頁
  • 點選報警歷史,url 切換為 /alarm/alarm,選單選中項更新,同時主體區域顯示對應資訊
  • 滑鼠移至管理員,點選個人中心,url切換,選單選中項不變,同時主體區域顯示對應資訊
  • 對於不存在的 url ,內容區域會顯示 404 的效果,同時選單選中項會清空

其他章節請看:

react 高效高質量搭建後臺系統 系列