[React 從零實(shí)踐02-后臺(tái)] 權(quán)限控制

導(dǎo)航

[react] Hooks

[React 從零實(shí)踐01-后臺(tái)] 代碼分割
[React 從零實(shí)踐02-后臺(tái)] 權(quán)限控制
[React 從零實(shí)踐03-后臺(tái)] 自定義hooks
[React 從零實(shí)踐04-后臺(tái)] docker-compose 部署react+egg+nginx+mysql
[React 從零實(shí)踐05-后臺(tái)] Gitlab-CI使用Docker自動(dòng)化部署

[源碼-webpack01-前置知識(shí)] AST抽象語法樹
[源碼-webpack02-前置知識(shí)] Tapable
[源碼-webpack03] 手寫webpack - compiler簡(jiǎn)單編譯流程
[源碼] Redux React-Redux01
[源碼] axios
[源碼] vuex
[源碼-vue01] data響應(yīng)式 和 初始化渲染
[源碼-vue02] computed 響應(yīng)式 - 初始化往扔,訪問,更新過程
[源碼-vue03] watch 偵聽屬性 - 初始化和更新
[源碼-vue04] Vue.set 和 vm.$set
[源碼-vue05] Vue.extend

[源碼-vue06] Vue.nextTick 和 vm.$nextTick

[部署01] Nginx
[部署02] Docker 部署vue項(xiàng)目
[部署03] gitlab-CI

[深入01] 執(zhí)行上下文
[深入02] 原型鏈
[深入03] 繼承
[深入04] 事件循環(huán)
[深入05] 柯里化 偏函數(shù) 函數(shù)記憶
[深入06] 隱式轉(zhuǎn)換 和 運(yùn)算符
[深入07] 瀏覽器緩存機(jī)制(http緩存機(jī)制)
[深入08] 前端安全
[深入09] 深淺拷貝
[深入10] Debounce Throttle
[深入11] 前端路由
[深入12] 前端模塊化
[深入13] 觀察者模式 發(fā)布訂閱模式 雙向數(shù)據(jù)綁定
[深入14] canvas
[深入15] webSocket
[深入16] webpack
[深入17] http 和 https
[深入18] CSS-interview
[深入19] 手寫Promise
[深入20] 手寫函數(shù)
[深入21] 算法 - 查找和排序

前置知識(shí)

(1) 一些單詞

graph:圖熊户,圖表
intelligence:智能的
contrast:對(duì)比

persistence:持久化
( data persistence:數(shù)據(jù)持久化 )

(2) 權(quán)限控制的類型

  • 登陸權(quán)限控制
    • 是否登陸
      • 登陸才能訪問的頁面/路由
      • 不登陸就可以訪問的頁面/路由萍膛,比如 login 頁面
  • 頁面權(quán)限控制
    • 菜單
      • 菜單中的頁面/路由是否顯示
      • 如果只是控制菜單,還不夠嚷堡,因?yàn)槿绻?cè)了所有路由蝗罗,即使菜單隱藏,還是可以通過地址欄訪問到
    • 頁面
      • 頁面的路由是否注冊(cè)
      • 退一步蝌戒,如果不根據(jù)權(quán)限就行路由注冊(cè)串塑,即使注冊(cè)了所有路由,沒權(quán)限就從定向到404北苟,這樣雖然不是最好桩匪,但也能用
    • 按鈕
      • 頁面中的按鈕(增、刪友鼻、改傻昙、查)是否顯示
  • 接口權(quán)限控制
    • 兜底
      • 路由可能配置失誤,按鈕可能忘了加權(quán)限桃移,這種時(shí)候請(qǐng)求控制可以用來兜底屋匕,越權(quán)請(qǐng)求將在前端被攔截
      • 通過axios請(qǐng)求響應(yīng)攔截來實(shí)現(xiàn)

(3) react-router-dom 中的 Redirect 組件

  • Redirect => to => state
  • 當(dāng)to屬性是一個(gè)對(duì)象時(shí) state 屬性可以傳遞一個(gè)對(duì)象,在to頁面中可以通過 this.props.state 獲取借杰,應(yīng)用場(chǎng)景:比如重定向到login頁面过吻,登陸成功后要返回之前所在的頁面,就可以把當(dāng)前的location信息通過state帶入到login頁面

(4) react-router-dom 實(shí)現(xiàn)在 Form 未保存時(shí)跳轉(zhuǎn)別的路由提示

  • ( Prompt ) 組件 和 ( router.getUserConfirmation ) 配合
  • Prompt
    • message 屬性:字符串 或者 函數(shù)
      • 函數(shù)
        • 返回true蔗衡,允許跳轉(zhuǎn)
        • 返回false纤虽,不允許跳轉(zhuǎn),沒有任何提示
        • 返回字符串绞惦,會(huì)彈出是否可以跳轉(zhuǎn)的彈窗逼纸,提示就是字符串內(nèi)的內(nèi)容,確定和取消
      • 字符串
        • 將上面的返回字符串
    • when:boolean
      • true:彈窗
      • false:順利跳轉(zhuǎn)
  • router.getUserConfirmation(message, callback)
    • 問題:為什么需要getUserConfirmation济蝉?
    • 因?yàn)椋篜rompt默認(rèn)使用window.confirm杰刽,丑,可以通過getUserConfirmation自定義樣式DOM王滤,阻止默認(rèn)彈窗
    • 參數(shù):
      • messag:就是Prompt的message指定的字符串
      • callback:true允許跳轉(zhuǎn)贺嫂,false不允許跳轉(zhuǎn)
在表單組件中使用 Prompt
<Prompt message={() => isSave ? true : '表單還未保存,真的需要跳轉(zhuǎn)嗎雁乡?'} ></Prompt>



ReactDOM.render(
  <Provider store={store}>
    <Router getUserConfirmation={getUserConfirmation}> // ----------- getUserConfirmation
      <App />
    </Router>
  </Provider>,
  document.getElementById('root')
);
function getUserConfirmation(message: string, callback: any) {
  Modal.confirm({ // ----------------------------------------------- antd Modal
    content: message, // ------------------------------------------- message就是Pormpt組件的message返回的字符串
    cancelText: '取消',
    okText: '確定',
    onCancel: () => {
      callback(false) // ------------------------------------------- callback(false) 不跳轉(zhuǎn)
    },
    onOk: () => {
      callback(true) // -------------------------------------------- callback(true) 跳轉(zhuǎn)
    }
  })
}

(5) react-router-config 源碼分析

  • react-router-config 官網(wǎng)

  • 為啥要分析 react-router-config

  • 因?yàn)樽雎酚蓹?quán)限時(shí)第喳,需要向route配置對(duì)象中添加一些權(quán)限相關(guān)的自定義屬性,但我們又想用集中式路由來管理

  • react-router-config => renderRoutes 源碼分析

renderRoutes 一個(gè)最重要的api
----

import React from "react";
import { Switch, Route } from "react-router";
function renderRoutes(routes, extraProps = {}, switchProps = {}) {
  return routes ? (
    <Switch {...switchProps}>
      {routes.map((route, i) => (
        <Route
          key={route.key || i}
          path={route.path}
          exact={route.exact}
          strict={route.strict}
          render={props =>
            route.render ? (
              route.render({ ...props, ...extraProps, route: route })
            ) : (
              <route.component {...props} {...extraProps} route={route} />
            )
          }
        />
      ))}
    </Switch>
  ) : null;
} 
export default renderRoutes;


解析:
1. renderRoutes()只遍歷一層routes踱稍,不管你嵌套多少層routes數(shù)組曲饱,你都需要在對(duì)應(yīng)的組件中再次調(diào)用renderRoutes()傳入該層該routes
2. 所以:在每層的render和componet兩個(gè)屬性中悠抹,都需要傳入該層的route配置對(duì)象,在組件中通過props.route.routes獲取該層的routes (重要)
3. exact和strict都是boolean類型的數(shù)據(jù)扩淀,所以當(dāng)配置對(duì)象中不存在這兩個(gè)屬性時(shí)楔敌,boolen相當(dāng)于傳入false即不生效
4. render屬性是一個(gè)函數(shù),(routesProps) => {...} 引矩,routeProps包含 match, location and history

(6) antd4版本以上 自定義圖標(biāo)組件

import { createFromIconfontCN } from '@ant-design/icons';

const MyIcon = createFromIconfontCN({
  scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js', // 在 iconfont.cn 上生成 => Symbol方式A呵稹!M隆7彰铡!
});

ReactDOM.render(<MyIcon type="icon-example" />, mountedNode);

(7) 添加別名 @ 映射 src 在TS的項(xiàng)目中

    1. create-react-app構(gòu)建的項(xiàng)目区端,eject后值漫,找到 config/webpack.config.js => resolve.alias
    1. tsconfig.json 中刪除 baseUrlpaths,添加 "extends": "./paths.json"
    1. 在根目錄新建 paths.json 文件织盼,寫入 baseUrlpaths 配置
  • 教程地址
1. webpack.config.js => resolve => alias
module.export = {
   resolve: {
     alias: {
      "@": path.resolve(__dirname, '../src')
     }
   }
 }
2. 根目錄新建 paths.json 寫入以下配置
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@/*": ["*"]
    }
  }
}
3. 在 tsconfig.json 中做如下修改杨何,添加( extends ), 刪除(  baseUrl,paths )
{
  // "baseUrl": "src",
  // "paths": {
  //   "@/*": ["src/*"]
  // },
  "extends": "./paths.json"
}

(8) create-react-app 配置全局的 scss ,而不需要每次 @import

  • 安裝 sass-resources-loader
  • 修改 config/webpack.config.js 如下
  • 注意:很多教程修改use:getStyleLoaders().concat()這樣修改不行
const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [......].filter(Boolean);
  if (preProcessor) {
    loaders.push(......);
  }
  if (preProcessor === 'sass-loader') { // ------------ 如果第二個(gè)參數(shù)是 sass-loader沥邻,就 push sass-resources-loader
    loaders.push({
      loader: 'sass-resources-loader',
      options: {
          resources: [
            // 這里按照你的文件路徑填寫../../../ 定位到根目錄下, 可以引入多個(gè)文件
            path.resolve(__dirname, '../src/style/index.scss'),
          ]
      }
    })
  }
  return loaders;
};

(9) eslint 檢查 react-hooks 語法

  • eslint-plugin-react-hooks
  • 比如:可以檢查 hooks 不能在循環(huán)危虱,條件等地方使用,不能在回調(diào)中使用等等
  • 安裝:yarn add eslint-plugin-react-hooks --dev
  • 使用:在 .eslintrc.js 中 添加 pluginrules 配置
/* eslint-disable */
module.exports = {
  "env": {
    "es6": true, // 在開發(fā)環(huán)境唐全,啟用es6語法埃跷,包括全局變量
    "node": true,
    "browser": true
  },
  "parser": "babel-eslint", // 解析器
  "parserOptions": { // 解析器選項(xiàng)
    "ecmaVersion": 6, // 啟用es6語法,不包括全局變量
    "sourceType": "module",
    "ecmaFeatures": { //額外的語言特性
      "jsx": true // 啟用jsx語法
    }
  },
  "plugins": [
    // ...
    "react-hooks" 
  ],
  rules: {
    'no-console': 'off', // 可以console
    'no-debugger': 'off',
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  },
}
/* eslint-disable */


(一) react中實(shí)現(xiàn)權(quán)限控制

(1) ( 嵌套路由注冊(cè) ) 和 ( menu ) 和 ( breadcrumb面包屑 ) 共用同一份 ( routes )

  • 好處
    • 路由注冊(cè)的path和menu的path共用一個(gè)弥雹,而不用分開維護(hù)
    • 集中式路由,統(tǒng)一維護(hù)延届,雖然router4是分布式路由思想
  • 注意點(diǎn)
    • ( menu根據(jù)不同權(quán)限顯示隱藏 ) 和 ( 路由根據(jù)權(quán)限注冊(cè)和不注冊(cè) ) 是兩個(gè)概念剪勿,如果只是控制menu的顯示隱藏,而所有的路由都注冊(cè)的話方庭,即使頁面上沒有出現(xiàn)別的權(quán)限的菜單厕吉,但是通過地址欄輸入地址等方式還是可以導(dǎo)航到路由注冊(cè)的頁面,這就需要不在權(quán)限的路由不注冊(cè)或者跳轉(zhuǎn)到404頁面或者做提示沒權(quán)限等處理
    • ( 子菜單 ) 用 ( subs ) 數(shù)組屬性表示械念,( 嵌套路由 ) 用 ( routes ) 數(shù)組屬性表示
    • menu是樹形菜單头朱,所以注冊(cè)路由時(shí)要遞歸遍歷注冊(cè)每一層,menu中有子菜單我們用 subs 表示
    • 如果menu的item存在subs订讼,則該item層級(jí)不應(yīng)該有 pathcomponent 屬性
    • ( 即只有menu.item有上面這兩個(gè)屬性,submenu沒有扇苞,因?yàn)椴恍枰@示和跳轉(zhuǎn) )
    • 全局下renderRoutes遍歷一次routes欺殿,即只注冊(cè)第一層的routes寄纵,嵌套路由存在routes屬性,在相應(yīng)的路由頁面中再次調(diào)用renderRoutes注冊(cè)路由脖苏,但是再遞歸遍歷所有的menu相關(guān)的subs進(jìn)行路由注冊(cè)
  • 代碼
  • routes
routes是這樣一個(gè)數(shù)組
----


const totalRoutes: IRouteModule[] = [
  {
    path: '/login',
    component: Login,
  },
  {
    path: '/404',
    component: NotFound,
  },
  {
    path: '/',
    component: Layout,
    routes: [ 
      // routes:用于嵌套路由程拭,注意不是嵌套菜單
      // subs:主要還遍歷注冊(cè)menu樹形菜單,和渲染menu樹形菜單棍潘,在不同系統(tǒng)的路由中定義了subs
      // ----------------------------------------------------------- 嵌套路由通過 renderRoutes 做處理
      ...adminRoutes, // ------------------------------------ ( 后臺(tái)系統(tǒng)路由 )恃鞋,單獨(dú)維護(hù),同時(shí)用于menu
      ...bigScreenRoutes, // -------------------------------- ( 大屏系統(tǒng)路由 )亦歉,單獨(dú)維護(hù)恤浪,同時(shí)用于menu
    ]
  }
]

---- 分割線 ---- 

const adminRoutes: IRouteModule[] = [{ 
  // ---------------- adminRoutes 用于menu的樹形菜單的 ( 渲染 )和 ( 路由注冊(cè),注冊(cè)可以在同一層級(jí),因?yàn)閙une視口一樣 )
  title: '首頁',
  icon: 'anticon-home--line',
  key: '/admin-home',
  path: '/admin-home',
  component: AdminHome,
}, {
  title: 'UI',
  icon: 'anticon-uikit',
  key: '/admin-ui',
  subs: [{ 
    // -------------------------------------------------------- subs用于注冊(cè)路由肴楷,并且用于menu樹形菜單的渲染 
    // -------------------------------------------------------- ( 路由注冊(cè):其實(shí)就是在不同的地方渲染 <Route /> 組件 )
    // -------------------------------------------------------- ( 菜單渲染:其實(shí)就是menu菜單在頁面上顯示 )
    title: 'Antd',
    icon: 'anticon-ant-design',
    key: '/admin-ui/antd',
    subs: [{
      title: '首頁',
      icon: 'anticon-codev1',
      key: '/admin-ui/antd/index',
      path: '/admin-ui/antd/index',
      component: UiAntd,
    }]
  }, {
    title: 'Vant',
    icon: 'anticon-relevant-outlined',
    key: '/admin-ui/vant',
    path: '/admin-ui/vant',
    component: UiAntd,
  }]
}]
  • renderRoutes - 重點(diǎn)
import React from 'react'
import { IRouteModule } from '../../global/interface'
import { Switch, Route } from 'react-router-dom'

/**
 * @function normolize
 * @description 遞歸的對(duì)route.subs做normalize水由,即把所有嵌套展平到一層,主要對(duì)menu樹就行路由注冊(cè)
 * @description 因?yàn)閙enu樹都在同一個(gè)路由視口赛蔫,所以可以在同一層級(jí)就行路由注冊(cè)
 * @description 注意:path 和 component 在存在subs的那層menu-route對(duì)象中同時(shí)存在和同時(shí)不存在
 */
function normolize(routes?: IRouteModule[]) {
    let result: IRouteModule[] = []
    routes?.forEach(route => {
        !route.subs
            ? result.push(route)
            : result = result.concat(normolize(route.subs)) // ---------------- 拼接
    })
    return result
}


/**
 * @function renderRoutes
 * @description 注冊(cè)所有路由砂客,并向嵌套子路由組件傳遞 route 對(duì)象屬性,子組件就可以獲取嵌套路由屬性 routes
 */
const renderRoutes = (routes?: IRouteModule[], extraProps = {}, switchProps = {}) => {
    return routes
        ? <Switch {...switchProps}>
            {normolize(routes).map((route, index) => { // --------------------- 先對(duì)subs做處理呵恢,再map
                return route.path && route.component &&
                // path 并且 component 同時(shí)存在才進(jìn)行路由注冊(cè)
                // path 和 componet 總是同時(shí)存在鞠值,同時(shí)不存在
                    <Route
                        key={route.key || `${index + +new Date()}`}
                        path={route.path}
                        exact={route.exact}
                        strict={route.strict}
                        render={props => {
                            return route.render
                                ? route.render({ ...props, ...extraProps, route: route })
                                : <route.component {...props} {...extraProps} route={route} /> 
                                // 向嵌套組件中傳遞 route屬性,通過route.routes在嵌套路由組件中可以再注冊(cè)嵌套路由
                        }} />
            })}
        </Switch>
        : null
}

export {
    renderRoutes
}
  • menu
    /**
     * @function renderMenu
     * @description 遞歸渲染菜單
     */
    const renderMenu = (adminRoutes: IRouteModule[]) => {
        return adminRoutes.map(({ subs, key, title, icon }) => {
            return subs
                ?
                <SubMenu key={key} title={title} icon={<IconFont type={icon || 'anticon-shouye'} />}>
                    {renderMenu(subs)}
                </SubMenu>
                :
                <Menu.Item key={key} icon={<IconFont type={icon || 'anticon-shouye'} />} >{title}</Menu.Item>
        })
    }
  • 嵌套路由
    <Layout className={styles.layoutAdmin}> 
    // -------------------------------------------------------------------------------- Layout 是 '/' 路由對(duì)應(yīng)的組件
    // ---------------- {renderRoutes(props.route.routes)} 就是在 '/' 路由中渲染的 <Route path="" compoent="" />組件
            <Sider>
                <Menu
                    mode="inline"
                    theme="dark"
                    onClick={goPage}
                >
                    {renderMenu(adminRoutes)}
                </Menu>
            </Sider>
            <Layout>
                <Header className={styles.header}>
                    <ul className={styles.topMenu}>
                        <li>退出</li>
                    </ul>
                </Header>
                <Content className={styles.content}>
                    {renderRoutes(props.route.routes)} // --------------- 再次執(zhí)行渗钉,注冊(cè)嵌套的路由彤恶,成為父組件的子組件
                </Content>
            </Layout>
        </Layout>

(2) 在(1)的基礎(chǔ)上加入權(quán)限 ( 登陸,頁面晌姚,菜單 )

  • 要達(dá)到的效果 ( 菜單和路由兩個(gè)方面考慮 )
    • menu根據(jù)權(quán)限顯示和隱藏 注意menu中由于存在樹形粤剧,為了控制粒度更細(xì),在 submenu 和 menu.item 上都加入權(quán)限的判斷比較好
    • router根據(jù)權(quán)限注冊(cè)和不注冊(cè)
  • 需要添加的字段
    • needLoginAuth:boolen
      • 表示路由/菜單是否需要登陸權(quán)限
      • ( 只要登陸挥唠,后端就會(huì)返回角色抵恋,不同角色的權(quán)限可以用rolesAuth數(shù)組表示,如果返回的角色在rolesAuth數(shù)組中宝磨,就注冊(cè)路由 或 顯示菜單)
      • 如果 needLoginAuth是false弧关,則就不需要有 rolesAuth 字段了,即任何角色都會(huì)有的路由或菜單
    • rolesAuth:array
      • 該路由注冊(cè)/菜單顯示 需要的角色數(shù)組
    • meta: object
      • 可以把 needLoginAuthrolesAuth 放入 meta 對(duì)象中唤锉,便于管理
    • visiable
      • visiable主要用于 list 和 detail 這兩種類型的頁面世囊,詳情頁在menu中是不展示的,但是需要注冊(cè)Route窿祥,需要用字段來判斷隱藏掉詳情頁
  • 模擬需求
    • 角色有兩種:user 和 admin
    • 菜單權(quán)限
      • 首頁:登陸后株憾,兩種角色都可以訪問
      • UI:
        • ui 這個(gè)菜單兩種角色都顯示
        • ui/antd 這個(gè)菜單只有 admin 可以訪問和顯示
        • ui/vant 這個(gè)菜單兩種角色都可以顯示
      • JS:
        • 只有admin可以顯示
  • 代碼
  • 改造后的routes
const totalRoutes: IRouteModule[] = [
  {
    path: '/login',
    component: Login,
    meta: {
      needLoginAuth: false
    }
  },
  {
    path: '/404',
    component: NotFound,
    meta: {
      needLoginAuth: false
    }
  },
  {
    path: '/',
    component: Layout,
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user', 'admin']
    },
    routes: [ 
      // routes:用于嵌套路由,注意不是嵌套菜單
      // subs:主要還遍歷注冊(cè)menu樹形菜單,和渲染menu樹形菜單嗤瞎,在不同系統(tǒng)的路由中定義了subs
      // 嵌套路由通過 renderRoutes函數(shù) 做處理
      ...adminRoutes, // --------------------------- 后臺(tái)系統(tǒng)路由表
      ...bigScreenRoutes, // ----------------------- 大屏系統(tǒng)路由表
    ]
  }
]


---- 分割線 ----


const adminRoutes: IRouteModule[] = [{
  title: '首頁',
  icon: 'anticon-home--line',
  key: '/admin-home',
  path: '/admin-home',
  component: AdminHome,
  meta: {
    needLoginAuth: true,
    rolesAuth: ['user', 'admin']
  },
}, {
  title: 'UI',
  icon: 'anticon-uikit',
  key: '/admin-ui',
  meta: {
    needLoginAuth: true,
    rolesAuth: ['user', 'admin']
  },
  subs: [{ // subs用于注冊(cè)路由墙歪,并且用于menu樹形菜單
    title: 'Antd',
    icon: 'anticon-ant-design',
    key: '/admin-ui/antd',
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user','admin']
    },
    subs: [{
      title: '首頁',
      icon: 'anticon-codev1',
      key: '/admin-ui/antd/index',
      path: '/admin-ui/antd/index',
      component: UiAntd,
      meta: {
        needLoginAuth: true,
        rolesAuth: ['user', 'admin']
      },
    }, {
      title: 'Form表單',
      icon: 'anticon-yewubiaodan',
      key: '/admin-ui/antd/form',
      path: '/admin-ui/antd/form',
      component: UiAntdForm,
      meta: {
        needLoginAuth: true,
        rolesAuth: ['admin']
      },
    }]
  }, {
    title: 'Vant',
    icon: 'anticon-relevant-outlined',
    key: '/admin-ui/vant',
    path: '/admin-ui/vant',
    component: UiVant,
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user', 'admin']
    },
  }]
}, {
  title: 'JS',
  icon: 'anticon-js',
  key: '/admin-js',
  meta: {
    needLoginAuth: true,
    rolesAuth: ['user', 'admin']
  },
  subs: [{
    title: 'ES6',
    icon: 'anticon-6',
    key: '/admin-js/es6',
    path: '/admin-js/es6',
    component: JsEs6,
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user', 'admin']
    },
  }, {
    title: 'ES5',
    icon: 'anticon-js',
    key: '/admin-js/es5',
    path: '/admin-js/es5',
    component: UiAntd,
    meta: {
      needLoginAuth: true,
      rolesAuth: ['user', 'admin']
    },
  }]
}]
  • 對(duì)routes和menu過濾的函數(shù)
/**
 * @function routesFilter routes的權(quán)限過濾
 */
export function routesFilter(routes: IRouteModule[], roles: string) {
    return routes.filter(({ meta: { needLoginAuth, rolesAuth }, routes: nestRoutes, subs }) => {
        if (nestRoutes) { // 存在routes,對(duì)routes數(shù)組過濾贝奇,并重新賦值過濾后的routes
            nestRoutes = routesFilter(nestRoutes, roles) // 遞歸
        } 
        if (subs) { // 存在subs虹菲,對(duì)subs數(shù)組過濾,并重新賦值過濾后的subs
            subs = routesFilter(subs, roles) // 遞歸
        }
        return !needLoginAuth
            ? true
            : rolesAuth?.includes(roles)
                ? true
                : false
    })
}
  • renderRoutes 登陸權(quán)限的驗(yàn)證掉瞳,路由注冊(cè)過濾即路由注冊(cè)權(quán)限毕源,menu的過濾顯示隱藏不在這里進(jìn)行


/**
 * @function renderRoutes
 * @description 注冊(cè)所有路由,并向嵌套子路由組件傳遞 route 對(duì)象屬性陕习,子組件就可以獲取嵌套路由屬性 routes
 */
const renderRoutes = (routes: IRouteModule[], extraProps = {}, switchProps = {}) => {
    const history = useHistory()
    const token = useSelector((state: {app: {loginMessage: {token: string}}}) => state.app.loginMessage.token)
    const roles = useSelector((state: {app: {loginMessage: {roles: string}}}) => state.app.loginMessage.roles)
    if (!token) {
        history.push('/login') // token未登錄去登陸頁面霎褐,即登陸權(quán)限的驗(yàn)證!:獠椤4衿邸!0枭>愣觥!K觥E牟骸!M辆印T婀骸!2烈C奕Α!>祢选7竹!吁系!
    }
    routes = routesFilter(routes, roles) // 權(quán)限過濾德召,這里只用于路由注冊(cè),menu過濾還需在menu頁面調(diào)用routesFilter
    routes = normalize(routes) // 展平 subs

    return routes
        ? <Switch {...switchProps}>
            {
                routes.map((route, index) => { // 先對(duì)subs做處理
                    return route.path && route.component &&
                        // path 并且 component 同時(shí)存在才進(jìn)行路由注冊(cè)
                        // path 和 componet 總是同時(shí)存在汽纤,同時(shí)不存在
                        <Route
                            key={route.key || `${index + +new Date()}`}
                            path={route.path}
                            exact={route.exact}
                            strict={route.strict}
                            render={props => {
                                return route.render
                                    ? route.render({ ...props, ...extraProps, route: route })
                                    : <route.component {...props} {...extraProps} route={route} />
                                // 向嵌套組件中傳遞 route屬性上岗,通過route.routes在嵌套路由組件中可以再注冊(cè)嵌套路由
                            }} />
                })}
        </Switch>
        : null
}
  • menu的過濾
    /**
     * @function renderMenu
     * @description 遞歸渲染菜單
     */
    const renderMenu = (adminRoutes: IRouteModule[]) => {
        const roles =
            useSelector((state: { app: { loginMessage: { roles: string } } }) => state.app.loginMessage.roles) ||
            getLocalStorage('loginMessage').roles;
        // 這里用 eslint-plugin-react-hooks 會(huì)報(bào)錯(cuò),因?yàn)?hooks 必須放在最頂層
        // useSelector

        adminRoutes = routesFilter(adminRoutes, roles) // adminRoutes權(quán)限過濾T唐骸K貉帧!!!P衣啤!!2ú摹!MφC鞴!7岵础J矶ā!M骸;爸丁!Q年堆!

        return adminRoutes.map(({ subs, key, title, icon }) => {
            return subs
                ?
                <SubMenu key={key} title={title} icon={<IconFont type={icon || 'anticon-shouye'} />}>
                    {renderMenu(subs)}
                </SubMenu>
                :
                <Menu.Item key={key} icon={<IconFont type={icon || 'anticon-shouye'} />} >{title}</Menu.Item>
        })
    }

(3) breadcrumb 面包屑

  • 面包屑要解決的基本問題
    • 對(duì)于導(dǎo)航到詳情頁的動(dòng)態(tài)路由,要顯示到面包屑
    • 對(duì)于有menu.item即routes中有component的route對(duì)象盏浇,要能夠點(diǎn)擊并導(dǎo)航
    • 對(duì)于submenu的item不能點(diǎn)擊变丧,并置灰
    • 如何判斷是否可以點(diǎn)擊? 如果routes具有subs數(shù)組绢掰,就不可以點(diǎn)擊痒蓬;只有menu.item的route可以點(diǎn)擊
  • 因?yàn)槊姘际歉鶕?jù)當(dāng)前的url的pathname來進(jìn)行判斷的,所以無需做持久化滴劲,只要刷新地址欄不變就不會(huì)變
  • 但是有點(diǎn)需要注意:就是退出登陸時(shí)攻晒,應(yīng)該清除掉 localStorage 中的用于緩存menu等所有數(shù)據(jù),而刷新時(shí)候不需要班挖,如果退出時(shí)不清除localStorage鲁捏,登陸重定向到首頁,就會(huì)加載首頁的面包屑和緩存的menu聪姿,造成不匹配
import { Breadcrumb } from 'antd'
import React from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import styles from './breadcrumb.module.scss'
import { routesFilter } from '@/utils/render-routes/index'
import adminRoutes from '@/router/admin-routes'
import { useSelector } from 'react-redux'
import { IRouteModule } from '@/global/interface'
import { getLocalStorage } from '@/utils'
import _ from 'lodash'


const CustomBreadcrumb = () => {
  const roles = useSelector((state: any) => state.app.loginMessage.roles) || getLocalStorage('loginMessage').roles
  const pathname = useLocation().pathname // 獲取url的path
  const history = useHistory()

  // routeParams => 獲取useParams的params對(duì)象碴萧,對(duì)象中包含動(dòng)態(tài)路由的id屬性
  const routeParams = getLocalStorage('routeParams')

  // 深拷貝 權(quán)限過濾后的adminRoutes
  const routesAmin = _.cloneDeep([...routesFilter(adminRoutes, roles)]) // 權(quán)限過濾,為了和menu同步

  // generateRouteMap => 生成面包屑的 path,title映射
  const generateRouteMap = (routesAmin: IRouteModule[]) => {
    const routeMap = {}
    function step(routesAmin: IRouteModule[]) {
      routesAmin.forEach((item, index) => {
        if (item.path.includes(Object.keys(routeParams)[0])) { // 動(dòng)態(tài)路由存在:符號(hào)末购,緩存該 route破喻,用于替換面包屑的最后一級(jí)名字
          item.path = item.path.replace(`:${Object.keys(routeParams)[0]}`, routeParams[Object.keys(routeParams)[0]])
          // 把動(dòng)態(tài)路由參數(shù)(:id) 替換成真實(shí)的(params)
        }
        routeMap[item.path] = item.title
        item.subs && step(item.subs)
      })
    }
    step(routesAmin) // 用于遞歸
    return routeMap
  }
  const routeMap = generateRouteMap(routesAmin)

  // generateBreadcrumbData => 生成面包屑的data
  const generateBreadcrumbData = (pathname: string) => {
    const arr = pathname.split('/')
    return arr.map((item, index) => {
      return arr.slice(0, index + 1).join('/')
    }).filter(v => !!v)
  }
  const data = generateBreadcrumbData(pathname)

  // pathFilter 
    // 面包屑是否可以點(diǎn)擊導(dǎo)航
    // 同時(shí)用來做可點(diǎn)擊,不可點(diǎn)擊的 UI
  const pathFilter = (path: string) => {
    // normalizeFilterdAdminRoutes => 展平所有subs
    function normalizeFilterdAdminRoutes(routesAmin: IRouteModule[]) {
      let normalizeArr: IRouteModule[] = []
      routesAmin.forEach((item, index: number) => {
        item.subs
          ?
          normalizeArr = normalizeArr.concat(normalizeFilterdAdminRoutes(item.subs))
          :
          normalizeArr.push(item)
      })
      return normalizeArr
    }
    const routes = normalizeFilterdAdminRoutes(_.cloneDeep(routesAmin))

    // LinkToWhere => 是否可以點(diǎn)擊面包屑
    function LinkToWhere(routes: IRouteModule[]) {
      let isCanGo = false
      routes.forEach(item => {
        if (item.path === path && item.component) {
          isCanGo = true
        }
      })
      return isCanGo
    }
    return LinkToWhere(routes)
  }

  // 點(diǎn)擊時(shí)的導(dǎo)航操作
  const goPage = (item: string) => {
    pathFilter(item) && history.push(item)
    // 函數(shù)組合盟榴,可以點(diǎn)擊就就跳轉(zhuǎn)
  }

  // 渲染 breadcrumb
  const renderData = (item: string, index: number) => {
    return (
      <Breadcrumb.Item key={index} onClick={() => goPage(item)}>
        <span
          style={{
            cursor: pathFilter(item) ? 'pointer' : 'not-allowed',
            color: pathFilter(item) ? '#4DB2FF' : 'silver'
          }}
        >
          {routeMap[item]}
        </span>
      </Breadcrumb.Item>
    )
  }

  return (
    <Breadcrumb className={styles.breadcrumb} separator="/">
      {data.map(renderData)}
    </Breadcrumb>
  )
}

export default CustomBreadcrumb
  • 上面的面包屑存在的問題
    • 需求:面包屑在點(diǎn)擊到詳情時(shí)曹质,更新全局面包屑
    • 不足:使用localstore,在子組件set,在父組件get羽德,但是父組件先執(zhí)行几莽,子組件后執(zhí)行,并且localstore不會(huì)更新組件宅静,所以導(dǎo)致面包屑不更新
    • 代替:在子組件 es6detail 中 dispatch 了一個(gè)action章蚣,但不是在onClick的事件中,觸發(fā)了警告

// 需求:面包屑在點(diǎn)擊到詳情時(shí)姨夹,更新全局面包屑
// 不足:使用localstore纤垂,在子組件set,在父組件get磷账,但是父組件先執(zhí)行峭沦,子組件后執(zhí)行,并且localstore不會(huì)更新組件逃糟,所以導(dǎo)致面包屑不更新
// 代替:在子組件 es6detail 中 dispatch 了一個(gè)action吼鱼,但不是在onClick的事件中,觸發(fā)了警告
  // 之所以還這樣做绰咽,是要在子組件es6detail更新后菇肃,b更新CustomBreadcrumb
  // 因?yàn)樽咏M件es6detail更新了store,而父組件 CustomBreadcrumb 有引用store中的state取募,所以會(huì)更新
  // 不足:觸發(fā)了警告
const CustomBreadcrumb = () => {
  const roles = useSelector((state: any) => state.app.loginMessage.roles) || getLocalStorage('loginMessage').roles
  const pathname = useLocation().pathname // 獲取url的path
  const history = useHistory()

  // routeParams => 獲取useParams的params對(duì)象巷送,對(duì)象中包含動(dòng)態(tài)路由的id屬性
  const routeParams = getLocalStorage('routeParams')
  // debugger

  // 深拷貝 權(quán)限過濾后的adminRoutes
  const routesAmin = _.cloneDeep([...routesFilter(adminRoutes, roles)]) // 權(quán)限過濾,為了和menu同步

  // generateRouteMap => 生成面包屑的 path,title映射
  const generateRouteMap = (routesAmin: IRouteModule[]) => {
    const routeMap = {}
    function step(routesAmin: IRouteModule[]) {
      routesAmin.forEach((item, index) => {
        if (item.path.includes(routeParams && Object.keys(routeParams)[0])) { // 動(dòng)態(tài)路由存在:符號(hào)矛辕,緩存該 route笑跛,用于替換面包屑的最后一級(jí)名字
          item.path = item.path.replace(`:${Object.keys(routeParams)[0]}`, routeParams[Object.keys(routeParams)[0]])
          // 把動(dòng)態(tài)路由參數(shù)(:id) 替換成真實(shí)的(params)
        }
        routeMap[item.path] = item.title
        item.subs && step(item.subs)
      })
    }
    step(routesAmin) // 用于遞歸
    return routeMap
  }
  const routeMap = generateRouteMap(routesAmin)

  // generateBreadcrumbData => 生成面包屑的data
  const generateBreadcrumbData = (pathname: string) => {
    const arr = pathname.split('/')
    return arr.map((item, index) => {
      return arr.slice(0, index + 1).join('/')
    }).filter(v => !!v)
  }
  const data = generateBreadcrumbData(pathname)

  // pathFilter 
  // 面包屑是否可以點(diǎn)擊導(dǎo)航
  // 同時(shí)用來做可點(diǎn)擊,不可點(diǎn)擊的 UI
  const pathFilter = (path: string) => {
    // normalizeFilterdAdminRoutes => 展平所有subs
    function normalizeFilterdAdminRoutes(routesAmin: IRouteModule[]) {
      let normalizeArr: IRouteModule[] = []
      routesAmin.forEach((item, index: number) => {
        item.subs
          ?
          normalizeArr = normalizeArr.concat(normalizeFilterdAdminRoutes(item.subs))
          :
          normalizeArr.push(item)
      })
      return normalizeArr
    }
    const routes = normalizeFilterdAdminRoutes(_.cloneDeep(routesAmin))

    // LinkToWhere => 是否可以點(diǎn)擊面包屑
    function LinkToWhere(routes: IRouteModule[]) {
      let isCanGo = false
      routes.forEach(item => {
        if (item.path === path && item.component) {
          isCanGo = true
        }
      })
      return isCanGo
    }
    return LinkToWhere(routes)
  }

  // 點(diǎn)擊時(shí)的導(dǎo)航操作
  const goPage = (item: string) => {
    pathFilter(item) && history.push(item)
    // 函數(shù)組合聊品,可以點(diǎn)擊就就跳轉(zhuǎn)
  }

  // 渲染 breadcrumb
  const renderData = (item: string, index: number) => {
    return (
      <Breadcrumb.Item key={index} onClick={() => goPage(item)}>
        <span
          style={{
            cursor: pathFilter(item) ? 'pointer' : 'not-allowed',
            color: pathFilter(item) ? '#4DB2FF' : 'silver'
          }}
        >
          {routeMap[item]}
        </span>
      </Breadcrumb.Item>
    )
  }

  return (
    <Breadcrumb className={styles.breadcrumb} separator="/">
      {data.map(renderData)}
    </Breadcrumb>
  )
}

export default CustomBreadcrumb

(4) menu數(shù)據(jù)持久化

  • 相關(guān)屬性
    • openKeys
    • onOpenChange()
    • selectedKeys
    • onClick()
  • 存入localStorage飞蹂,在effect中初始化
import React, { useEffect, useState } from 'react'
import { renderRoutes, routesFilter } from '@/utils/render-routes/index'
import styles from './index.module.scss'
import { Button, Layout, Menu } from 'antd';
import adminRoutes from '@/router/admin-routes'
import { IRouteModule } from '@/global/interface'
import IconFont from '@/components/Icon-font'
import { useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { getLocalStorage, setLocalStorage } from '@/utils';
import CustomBreadcrumb from '@/components/custorm-breadcrumb';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';

const { SubMenu } = Menu;
const { Header, Sider, Content } = Layout;

const Admin = (props: any) => {
    const [collapsed, setcollapsed] = useState(false)
    const [selectedKeys, setSelectedKeys] = useState(['/admin-home'])
    const [openKeys, setOpenKeys]: any = useState(['/admin-home'])
    const history = useHistory()

    useEffect(() => {
        // 初始化,加載持久化的 selectedKeys 和 openKeys
        const selectedKeys = getLocalStorage('selectedKeys')
        const openKeys = getLocalStorage('openKeys')
        setSelectedKeys(v => v = selectedKeys)
        setOpenKeys((v: any) => v = openKeys)
    }, [])

    /**
     * @function renderMenu
     * @description 遞歸渲染菜單
     */
    const renderMenu = (adminRoutes: IRouteModule[]) => {
        const roles =
            useSelector((state: { app: { loginMessage: { roles: string } } }) => state.app.loginMessage.roles) ||
            getLocalStorage('loginMessage').roles;

        const adminRoutesDeepClone = routesFilter([...adminRoutes], roles) // adminRoutes權(quán)限過濾

        return adminRoutesDeepClone.map(({ subs, key, title, icon, path }) => {
            return subs
                ?
                <SubMenu key={key} title={title} icon={<IconFont type={icon || 'anticon-shouye'} />}>
                    {renderMenu(subs)}
                </SubMenu>
                :
                !path.includes(':') && <Menu.Item key={key} icon={<IconFont type={icon || 'anticon-shouye'} />} >{title}</Menu.Item>
            // 動(dòng)態(tài)路由不進(jìn)行顯示翻屈,因?yàn)橐话銊?dòng)態(tài)路由是詳情頁
            // 雖然不顯示陈哑,但是需要注冊(cè)路由,只是menu不顯示
        })
    }

    // 點(diǎn)擊 menuItem 觸發(fā)的事件
    const goPage = ({ keyPath, key }: { keyPath: any[], key: any }) => {
        history.push(keyPath[0])
        setSelectedKeys(v => v = [key])
        setLocalStorage('selectedKeys', [key]) // 記住當(dāng)前點(diǎn)擊的item伸眶,刷新持久化
    }

    // 展開/關(guān)閉的回調(diào)
    const onOpenChange = (openKeys: any) => {
        setOpenKeys((v: any) => v = openKeys)
        setLocalStorage('openKeys', openKeys) // 記住展開關(guān)閉的組惊窖,刷新持久化
    }

    const toggleCollapsed = () => {
        setcollapsed(v => v = !v)
    };

    return (
        <Layout className={styles.layoutAdmin}>
            <Sider collapsed={collapsed}>
                <Menu
                    mode="inline"
                    theme="dark"
                    onClick={goPage}
                    // inlineCollapsed={} 在有 Sider 包裹的情況下,需要在Sider中設(shè)置展開隱藏
                    inlineIndent={24}
                    selectedKeys={selectedKeys}
                    openKeys={openKeys}
                    onOpenChange={onOpenChange}
                >
                    {renderMenu([...adminRoutes])}
                </Menu>
            </Sider>
            <Layout>
                <Header className={styles.header}>
                    <aside>
                        <span onClick={toggleCollapsed}>
                            {collapsed
                                ? <MenuUnfoldOutlined className={styles.toggleCollapsedIcon} />
                                : <MenuFoldOutlined className={styles.toggleCollapsedIcon} />
                            }
                        </span>
                    </aside>
                    <ul className={styles.topMenu}>
                        <li onClick={() => history.push('/login')}>退出</li>
                    </ul>
                </Header>
                <Content className={styles.content}>
                    <CustomBreadcrumb />
                    {renderRoutes(props.route.routes)}
                    {/* renderRoutes(props.route.routes) 再次執(zhí)行厘贼,注冊(cè)嵌套的路由界酒,成為父組件的子組件 */}
                </Content>
            </Layout>
        </Layout>
    )
}

export default Admin

項(xiàng)目源碼

資料

react路由鑒權(quán)(完善) https://juejin.im/post/6844903924441284615
快速打造react管理系統(tǒng)(項(xiàng)目) https://juejin.im/post/6844903981945208839
權(quán)限控制的類型 https://juejin.im/post/6844903882338861063
React-Router實(shí)現(xiàn)前端路由鑒權(quán):https://juejin.im/post/6857055615739985933
react-router-config路由鑒權(quán):https://github.com/leishihong/react-router-config

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市嘴秸,隨后出現(xiàn)的幾起案子毁欣,更是在濱河造成了極大的恐慌庇谆,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,548評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件凭疮,死亡現(xiàn)場(chǎng)離奇詭異饭耳,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)执解,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,497評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門寞肖,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人衰腌,你說我怎么就攤上這事逝淹。” “怎么了桶唐?”我有些...
    開封第一講書人閱讀 167,990評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)茉兰。 經(jīng)常有香客問我尤泽,道長(zhǎng),這世上最難降的妖魔是什么规脸? 我笑而不...
    開封第一講書人閱讀 59,618評(píng)論 1 296
  • 正文 為了忘掉前任坯约,我火速辦了婚禮,結(jié)果婚禮上莫鸭,老公的妹妹穿的比我還像新娘闹丐。我一直安慰自己,他們只是感情好被因,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,618評(píng)論 6 397
  • 文/花漫 我一把揭開白布卿拴。 她就那樣靜靜地躺著,像睡著了一般梨与。 火紅的嫁衣襯著肌膚如雪堕花。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,246評(píng)論 1 308
  • 那天粥鞋,我揣著相機(jī)與錄音缘挽,去河邊找鬼。 笑死呻粹,一個(gè)胖子當(dāng)著我的面吹牛壕曼,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播等浊,決...
    沈念sama閱讀 40,819評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼腮郊,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了筹燕?” 一聲冷哼從身側(cè)響起伴榔,我...
    開封第一講書人閱讀 39,725評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤纹蝴,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后踪少,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體塘安,經(jīng)...
    沈念sama閱讀 46,268評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,356評(píng)論 3 340
  • 正文 我和宋清朗相戀三年援奢,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了兼犯。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,488評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡集漾,死狀恐怖切黔,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情具篇,我是刑警寧澤纬霞,帶...
    沈念sama閱讀 36,181評(píng)論 5 350
  • 正文 年R本政府宣布,位于F島的核電站驱显,受9級(jí)特大地震影響诗芜,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜埃疫,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,862評(píng)論 3 333
  • 文/蒙蒙 一伏恐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧栓霜,春花似錦翠桦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,331評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至仅炊,卻和暖如春闻鉴,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背茂洒。 一陣腳步聲響...
    開封第一講書人閱讀 33,445評(píng)論 1 272
  • 我被黑心中介騙來泰國打工孟岛, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人督勺。 一個(gè)月前我還...
    沈念sama閱讀 48,897評(píng)論 3 376
  • 正文 我出身青樓渠羞,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國和親智哀。 傳聞我的和親對(duì)象是個(gè)殘疾皇子次询,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,500評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容