diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7e3649a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9a071ec --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +/lambda/ +/scripts +/config +.history +public +dist +.umi +mock diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..efa949a --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,124 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "settings": { + "react": { + "version": "detect" + } + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "react", + "react-hooks", + "@typescript-eslint" + ], + "rules": { + //原生eslint配置 + //规则文档: https://eslint.org/docs/latest/ + "indent": [ + "error", + 2, + { + "SwitchCase": 1, + "ignoredNodes": [ "ConditionalExpression" ] + } + ], + "linebreak-style": [ "error", "windows" ], + "quotes": [ "error", "single" ], + "semi": [ "error", "always" ], + "arrow-parens": [ "error", "always" ], + "array-bracket-spacing": [ + "error", + "always", + { + "singleValue": true, + "objectsInArrays": true, + "arraysInArrays": true + } + ], + "object-curly-spacing": [ + "error", + "always", + { + "arraysInObjects": true, + "objectsInObjects": true + } + ], + "no-lonely-if": "error", + "comma-dangle": [ + "error", + { + "arrays": "always-multiline", + "objects": "always-multiline", + "imports": "always-multiline", + "exports": "always-multiline", + "functions": "always-multiline" + } + ], + "eol-last": [ "error", "always" ], + "no-multiple-empty-lines": [ + "error", + { + "max": 1, + "maxEOF": 0 + } + ], + "space-before-function-paren": [ + "error", + { + "named": "never", + "anonymous": "always", + "asyncArrow": "always" + } + ], + "keyword-spacing": [ + "error", + { + "before": true, + "after": true + } + ], + "space-before-blocks": "error", + //eslint-plugin-react配置 + //规则文档: https://github.com/jsx-eslint/eslint-plugin-react/tree/master/docs/rules + "react/sort-comp": [ 2 ], + "react/jsx-curly-newline": [ + "error", + { + "multiline": "consistent", + "singleline": "consistent" + } + ], + "react/jsx-max-props-per-line": [ + 2, + { + "maximum": 1, + "when": "always" + } + ], + "react/jsx-first-prop-new-line": [ + "error", + "multiline-multiprop" + ], + "react/self-closing-comp": [ + "error", + { + "component": true, + "html": true + } + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e81349 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +/node_modules +/.env.local +/.umirc.local.ts +/config/config.local.ts +/src/.umi +/src/.umi-production +/src/.umi-test +/dist +.swc +*.md +!README.md +!README-en_US.md +*.log +.env diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..d50cdcf --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,7 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# Export Git hook params +export GIT_PARAMS=$* + +npx --no-install fabric verify-commit diff --git a/README-en_US.md b/README-en_US.md new file mode 100644 index 0000000..4916386 --- /dev/null +++ b/README-en_US.md @@ -0,0 +1,201 @@ +[简体中文](./README.md) | English + +The recommended `node` version is `18.14.2`, and the recommended `yarn` version is `1.22.x` + +> October 8, 2024: Today, all dependencies were updated to the latest versions. The Node.js version used is the most recent one, 20.18.0. If you encounter issues with installing dependencies or running the program using Node.js 18.14.2, you can try using 20.18.0, or attempt using other versions. + +# Overview + +A lightweight development framework built with [umi4](https://umijs.org/), integrated conventional routing, custom layout(including custom routing rendering), retrieve menu data from server side, and global state management and permission verification, newest pkg and the best development experience, clone to use + +# Git Work Flow +``` +Legal format of commit log were shown as bellow(emoji and module are optional): + +[] [revert: ?][(scope)?]: + +💥 feat(module): added a great feature +🐛 fix(module): fixed some bugs +📝 docs(module): updated the documentation +🌷 UI(module): modified the style +🏰 chore(module): made some changes to the scaffold +🌐 locale(module): made some small contributions to i18n + +Other commit types: refactor, perf, workflow, build, CI, typos, tests, types, wip, release, dep +``` + +# Mock +Built in `mock service`, start development even backend is not ready~~they always do that~~ + +## Interface Response Format +``` +/** + * response body + * @description data + * @description code 0 success, other failure + * @description message + */ +type ResponstBody = { + data: T; + code: number; + message: string; +}; +``` +This is a common response format, it's suit for most developer, and i choose it as a conventional response format + +# Proxy +Nothing much to say, `proxy` is nessary for morden web app development + +# Environment Variable +Custom environment variable should start with `UMI_APP_`, and write to `.env` file, so we can visit it via `process.env.UMI_APP_xxx` + +Should i commmit `.env` file to my `git` repository? good question, refer to these 2 articles: + +[Should I commit my .env file?](https://github.com/motdotla/dotenv#should-i-commit-my-env-file) + +[Should I have multiple .env files?](https://github.com/motdotla/dotenv#should-i-have-multiple-env-files) + +I agree not to commit the `.env` file to the repo and to have only one `.env` file, cuz the config in it is not safe for commit to repo, additionally, each environment has different config, we can share `.env` file separately with ohter collaborators via email or IM sorftware if we need to collaborate on development + +In other words: one `.env` file on the developer's computer for development, and one `.env` file on the test server and one on the online server for testing and production packaging respectively. + +# Route +Conventional routing. And which specific route is valid is depends on the data returned from menu interface, the data returned from menu interface will be displayed in the left menu bar. While a route(menu data) has been returned by an interface, it's considered valid since it's provided by the interface. However, due to the use of conventional routing, the route will be rendered correctlly only when it has been created in the project directory + +Additionally, the route of login page doesn't need to be returned by menu interface(otherwise it will displayed in the left menu bar), it doesn't use route-based logic to determine it's location (cuz it's not returned by the menu interface). The specific routing and redirection logic can be viewed in `/src/utils/handleRedirect.ts` + +# Menu +The menu is returned by server side, and stored in global state, the format of return data should like this: [ItemType](https://ant.design/components/menu#itemtype) which can be consumed by[Menu](https://ant.design/components/menu), and don't include `access` field any more. The menu data of current user is what can be accessed by current user, but not all operations within the page may be accessible to the current user. Page authentication mainly prevents the current user from opening other users' routes (such as opening bookmarks saved by other users). The permission content will be described later, and the `ts` definition for the menu is as bellow: +``` +/** + * @description id Data's id + + * @description pid Data's parent id + + * @description key The unique identifier of the menu item, use `string` type instead `React.Key`: + https://ant.design/components/menu-cn#itemtype, + otherwise there may be a problem where the menu item can't be selected due to an incorrect key type + + * @description lable Menu's title + + * @description hideInMenu Should hidden in menu bar or not + + * @description path Route path, each menu will have this field whether it has `children` field or not, + for menus without `children` field, it will redirect to this value, while for menus with `children` field, + it will redirect to `redirect` field. Cuz having `children` field which means this menu is expandable, + at this time, the `path` with `children` only represents it's position, rather than a truly valid route + + * @description redirect Redirect route path, only the menu which has `children` field has this field, + when selectable menu whithin the `children` field of this menu, this value will be the + 1st selectable menu's `path`, when there are no selectable menu in `children` field of this menu, + but still have `children` field, the value is the path of the first selectable menu item in its + children's children, that's say no matter how, this value is always the 1st valid route, + check menu data in the mock data out for specifics. Also, this field should be an optional field + theoretically, however, in order to make it easier for the backend to process, it's written as a + fixed field over here, in data where this field is not necessary, the backend can return an + empty string + + * @description children Sub menu + */ +type MenuItem = { + id: number; + pid?: number; + key: string; + path: string; + redirect: string; + hideInMenu?: boolean; + label: React.ReactElement | string; + children?: MenuItem[]; +} +``` + +# Layout +Abandoned the default `/src/app.tsx` layout and switched to using a custom layout: `/src/layouts/index.tsx` + +## Implement Custom Rendering In Custom Layout +`/src/layouts/index.tsx` won't disturb the login page, beside: when user is login already, if user access login page again at this time(e.g. user manually enter the login page or access it through a bookmark) it will be redirected to a non-login page(if there is a `redirect` field then redirect to `redirect`, or redirect to index page), check this file out for specific codes: `/src/components/LayoutWrapper.tsx` + +# Page Title +Using `umi4`'s global `title` config, each page's `location` info and menu data returned from interface implemented dynamic page title for each page, check this out for details: `/src/components/LayoutWrapper.tsx` + +# Data Flow(State Management) +Using [dva](https://dvajs.com/) instead of `initial-state` of `umi4` ~~i won't tell u that it's because i abandoed the default `/src/app.tsx` layout, which resulted in the inability to use `initial-state` and `dva` is the only `react` state management solution i know how to use ;)~~ and we can consider combining [dva_umi4](https://umijs.org/docs/max/dva) and [@umijs/plugin-dva](https://v3.umijs.org/plugins/plugin-dva) together + +# Request +The request lib is [axios](https://github.com/axios/axios) of course, and the structure of request codes are as bellow: +``` +//... +. +├── src +│ ├── layouts +│ │ ├── index.tsx +│ │ ├── index.less +│ ├── services +│ │ └── user.ts +│ ├── pages +│ │ ├── index.tsx +│ │ ├── index.less +│ │ ├── pageA +│ │ │ └── index.tsx +│ │ │ └── index.less +│ │ │ ├── services.ts +//... +``` +The request codes for `pageA` are all in `serveces.ts` + +# Permission +``` +/** + * Page permission type + * @description key is route's path, value is permisson array + */ +type PageAuthority = { + [path: string]: string[]; +} +``` + +``` +/** + * permission type for elements within a page + * @description key is permission name, value is a specific permission string + */ +type Authority = { + [key: string]: string; +} +``` + +The details of permission logic: + + 1. page permission(for entire page): `/src/components/PageAccess.tsx` + 1. have permission: render page normally(`children`) + 2. don't have permission: return[result_antd](https://ant.design/components/result-cn)component's[403](https://ant.design/components/result-cn#components-result-demo-403)result + +The processing of page authorization has been placed in `/src/layouts/index.tsx`, cuz this component is the parent of all pages that require authorization processing, making it the most suitable location for handing this + + 2. page internal(for certain elements in the page): `/src/components/Access.tsx` + 1. have permission: render elements normally(`children`) + 2. don't have permission: + 1. dont't have `fallback`: render nothing + 2. have `fallback`: render `fallback` + +The processing of certain elements's permission in the page requires using the `` component to be separately processed in each individual page + +Both the user info and user permission returned by backend after login are stored in global state, which is `dva` + +# Lint +This part refer to [12 essential ESLint rules for React](https://blog.logrocket.com/12-essential-eslint-rules-react/), and i have made some additional configurations as well: +``` +"editor.codeActionsOnSave": { + "source.fixAll.eslint": true +} +``` +And the shortcut config of course: +``` +{ + "key": "alt+f", + "command": "eslint.executeAutofix" +} +``` +For me, this can imporve experience of programming greatly, i think it should be the same for you all, so i recommend everyone to config it this way + +***Finally, happy hacking my friend ;)*** diff --git a/config/config.ts b/config/config.ts new file mode 100644 index 0000000..5dd89ca --- /dev/null +++ b/config/config.ts @@ -0,0 +1,15 @@ +// import { defineConfig } from "umi"; +// import proxy from './proxy'; + +//不使用defineConfig则能在其他地方获取这些配置 +export default { + plugins: [ + '@umijs/plugins/dist/dva' + ], + // proxy, + dva: {}, + title: 'UMI4 Admin', + favicons: [ + '/favicon.svg' + ] +}; diff --git a/config/proxy.ts b/config/proxy.ts new file mode 100644 index 0000000..91fdb9d --- /dev/null +++ b/config/proxy.ts @@ -0,0 +1,8 @@ +const proxy = { + '/api': { + target: 'https://preview.pro.ant.design', + changeOrigin: true + } +} + +export default proxy; diff --git a/mock/user.ts b/mock/user.ts new file mode 100644 index 0000000..c9de08c --- /dev/null +++ b/mock/user.ts @@ -0,0 +1,259 @@ +import type { Request, Response } from 'express'; + +const waitTime = (time: number = 100) => ( + new Promise((resolve) => { + setTimeout( + () => { + resolve(true); + }, + time + ) + }) +); + +const handleCommonRes = (data: Record | Record[], code = 0) => ({ + data, + code, + message: !code ? '成功' : '失败' +}); + +const userApi = { + //登录 + 'POST /api/user/login': async (req: Request, res: Response) => { + console.log('req', req); + console.log('res', res); + + return + + const { password, username, mobile, captcha } = req.body; + await waitTime(2000); + + switch (true) { + case username === 'admin' && password === 'ant.design': + case username === 'user' && password === 'ant.design': + case /^1\d{10}$/.test(mobile) && Boolean(captcha): + res.send( + handleCommonRes({ + token: 'Bearer xxx' + }) + ); + return; + } + + res.send( + handleCommonRes( + { + token: 'Wrong Bearer' + }, + 10001 + ) + ); + }, + //用户信息 + 'GET /api/user/info': async (req: Request, res: Response) => { + await waitTime(2000); + + if (!req.headers.authorization) { + res.status(401).send(); + return; + } + + res.send( + handleCommonRes({ + name: 'Serati Ma', + avatar: 'https://gw.alipayobjects.com/zos/antfincdn/XAosXuNZyF/BiazfanxmamNRoxxVxka.png', + userid: '00000001', + email: 'antdesign@alipay.com', + signature: '海纳百川,有容乃大', + title: '交互专家', + group: '蚂蚁金服-某某某事业群-某某平台部-某某技术部-UED', + tags: [ + { + key: '0', + label: '很有想法的', + }, + { + key: '1', + label: '专注设计', + }, + { + key: '2', + label: '辣~', + }, + { + key: '3', + label: '大长腿', + }, + { + key: '4', + label: '川妹子', + }, + { + key: '5', + label: '海纳百川', + }, + ], + notifyCount: 12, + unreadCount: 11, + country: 'China', + geographic: { + province: { + label: '浙江省', + key: '330000', + }, + city: { + label: '杭州市', + key: '330100', + }, + }, + address: '西湖区工专路 77 号', + phone: '0752-268888888', + }) + ); + }, + //用户权限 + 'GET /api/user/authority': async (req: Request, res: Response) => { + await waitTime(1500); + + res.send( + handleCommonRes({ + authority: [ + '/', + '/about/u/1', + '/about/u/2', + '/about/m', + '/about/um', + '/teacher/u', + '/teacher/m', + '/teacher/um', + '/student', + '/about/m/update' + ] + }) + ); + }, + //菜单 + 'GET /api/user/menu': async (req: Request, res: Response) => { + await waitTime(1000); + + res.send( + handleCommonRes([ + { + id: 1, + key: '1', + path: '/', + redirect: '', + name: '首页' + }, + { + id: 2, + key: '2', + name: '关于', + path: '/about', + redirect: '/about/m', + children: [ + { + id: 21, + key: '2-1', + name: '关于你', + path: '/about/u', + redirect: '/about/u/1', + pid: 2, + children: [ + { + id: 211, + key: '2-1-1', + name: '关于你1', + path: '/about/u/1', + redirect: '', + pid: 21 + }, + { + id: 212, + key: '2-1-2', + name: '关于你2', + path: '/about/u/2', + redirect: '', + pid: 21 + } + ] + }, + { + id: 22, + key: '2-2', + path: '/about/m', + redirect: '', + name: '(页面元素权限)关于我', + pid: 2 + }, + { + id: 23, + key: '2-3', + path: '/about/um', + redirect: '', + name: '关于你和我', + pid: 2 + } + ] + }, + { + id: 3, + key: '3', + name: '教师', + path: '/teacher', + redirect: '/teacher/u', + children: [ + { + id: 31, + key: '3-1', + path: '/teacher/u', + redirect: '', + name: '(403)关于你教师', + pid: 3 + }, + { + id: 32, + key: '3-2', + path: '/teacher/m', + redirect: '', + name: '关于我教师', + pid: 3 + }, + { + id: 33, + key: '3-3', + path: '/teacher/um', + redirect: '', + name: '关于你和我教师', + pid: 3 + } + ] + }, + { + id: 4, + key: '4', + name: '(404)学生', + path: '/student', + redirect: '', + } + ]) + ); + }, + //登出 + 'POST /api/user/logout': (req: Request, res: Response) => { + res.send( + handleCommonRes({}) + ); + }, + //验证码 + 'GET /api/user/captcha': async (req: Request, res: Response) => { + await waitTime(2000); + return res.send( + handleCommonRes({ + captcha: 'captcha-xxx' + }) + ); + } +}; + +export default userApi; diff --git a/package.json b/package.json new file mode 100644 index 0000000..c3e95eb --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "private": true, + "author": "小镇靓仔 ", + "scripts": { + "dev": "umi dev", + "build": "umi build", + "setup": "umi setup", + "postinstall": "umi setup", + "prepare": "husky" + }, + "dependencies": { + "@ant-design/icons": "^5.5.1", + "@ant-design/pro-components": "^2.8.6", + "antd": "^5.21.2", + "axios": "^1.7.7", + "dva": "^3.0.0-alpha.1", + "umi": "^4.3.24" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.8.1", + "@typescript-eslint/parser": "^8.8.1", + "@umijs/fabric": "^4.0.1", + "@umijs/plugins": "^4.3.24", + "eslint": "^9.21.0", + "eslint-plugin-react": "^7.37.1", + "eslint-plugin-react-hooks": "^4.6.2", + "husky": "^9.1.6", + "typescript": "^5.6.2" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..e7f6c1b --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..29e29c6 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ +Group 28 Copy 5Created with Sketch. diff --git a/src/assets/.gitkeep b/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/assets/login-bg.png b/src/assets/login-bg.png new file mode 100644 index 0000000..9e9e395 Binary files /dev/null and b/src/assets/login-bg.png differ diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 0000000..f529c57 --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1,37 @@ + + + + diff --git a/src/components/Access.tsx b/src/components/Access.tsx new file mode 100644 index 0000000..2f76ac8 --- /dev/null +++ b/src/components/Access.tsx @@ -0,0 +1,32 @@ +import type { FC, ReactElement } from 'react'; +import { connect } from 'umi'; +import type { UserConnectedProps } from '@/models/user'; + +type Props = { + authority: string; + children: ReactElement; + fallback?: ReactElement; +} & UserConnectedProps; + +//处理页面元素权限的组件 +const Access: FC = (props): ReactElement | null => { + const { user, authority, fallback, children } = props; + const { authority: userAuthority } = user; + const accessible = userAuthority.some((item: string) => item === authority); + + let res = null; + + if (accessible) { + res = children; + } else if (fallback) { + res = fallback; + } + + return res; +}; + +export default connect( + ({ user }: { user: UserConnectedProps['user'] }) => ({ + user, + }), +)(Access); diff --git a/src/components/Avatar/index.less b/src/components/Avatar/index.less new file mode 100644 index 0000000..5a511bb --- /dev/null +++ b/src/components/Avatar/index.less @@ -0,0 +1,18 @@ +@import '@/global.less'; + +.avatar-container{ + height: 100%; + padding: 0 10px; + cursor: pointer; + .avatar{ + display: block; + width: 30.9px; + height: 30.9px; + } + .username{ + color: #fff; + } + &:hover { + // background: #252a3d; + } +} diff --git a/src/components/Avatar/index.tsx b/src/components/Avatar/index.tsx new file mode 100644 index 0000000..800ec83 --- /dev/null +++ b/src/components/Avatar/index.tsx @@ -0,0 +1,59 @@ +import type { FC } from 'react'; +import { connect } from 'umi'; +import { Dropdown, Space } from 'antd'; +import { LogoutOutlined } from '@ant-design/icons'; +import type { UserConnectedProps } from '@/models/user'; +import './index.less'; + +const Avatar: FC = (props) => { + const { + user: { data }, dispatch, + } = props; + + const handleLogout = () => { + dispatch?.({ + type: 'user/logout', + }); + }; + + const items = [ + { + key: 'logout', + label: ( + + + 退出登录 + + ), + }, + ]; + + return ( + + + avatar + {data.name} + + + ); +}; + +export default connect( + ({ user }: { user: UserConnectedProps['user'] }) => ({ + user, + }), +)(Avatar); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..64a98f1 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,47 @@ +/* + * @Author: cclu 1106109051@qq.com + * @Date: 2025-02-25 11:39:58 + * @LastEditors: cclu 1106109051@qq.com + * @LastEditTime: 2025-02-26 11:25:46 + * @FilePath: \umi4-admin-main\src\components\Footer.tsx + * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE + */ +import type { FC } from 'react'; +import { DefaultFooter } from '@ant-design/pro-layout'; + +const Footer: FC = () => { + return ( + + // , + // href: 'https://github.com/ant-design/ant-design-pro', + // blankTarget: true, + // }, + // { + // key: 'Ant Design', + // title: 'Ant Design', + // href: 'https://ant.design', + // blankTarget: true, + // }, + // ]} + // /> + ); +}; + +export default Footer; diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx new file mode 100644 index 0000000..187c966 --- /dev/null +++ b/src/components/LayoutWrapper.tsx @@ -0,0 +1,96 @@ +import { Fragment, useEffect } from 'react'; +import type { FC, ReactElement } from 'react'; +import { Spin } from 'antd'; +import { Helmet, useLocation, connect } from 'umi'; +import type { UserConnectedProps } from '@/models/user'; +import LoginPage from '@/pages/user/login'; +import cfg from '../../config/config'; + +type Props = { + children: ReactElement; +} & UserConnectedProps; + +//登录页不走/src/layouts, 登录完之后的页面才走/src/layouts +const LayoutWrapper: FC = (props) => { + const { pathname } = useLocation(); + const { + dispatch, children, + user: { isLogin, layoutWrapperLoading, indexValidMenuItemByPath }, + } = props; + const tabTitle = indexValidMenuItemByPath[pathname]?.label; + const projectTitle = cfg.title; + const title = tabTitle ? `${tabTitle} - ${projectTitle}` : projectTitle; + + useEffect( + () => { + //调用户信息相关接口查询登录状态(401表示未登录) + dispatch?.({ + type: 'user/getUserInfoAuthorityMenu', + payload: { + type: 'relay', + }, + }); + }, + [ dispatch ], + ); + + //进任何页面都需要先loading + let pageContent = ( + + ); + + //页面不loading + /** + 一开始我是这么写的: + if(!layoutWrapperLoading) { + if(isLogin){ + if(pathname === '/user/login') { + 登录过了, 此时页面url还是登录页的时候我希望不再进入登录页而是直接跳转离开 + handleRedirect(); + 这里如果使用了history.push则会报如下的错误: + Warning: Cannot update a component (`BrowserRoutes`) while rendering a different component (`LayoutWrapper`). + To locate the bad setState() call inside `LayoutWrapper`, follow the stack trace as described in + https://reactjs.org/link/setstate-in-render + 大意是不能在render的时候更新路由, 也就是调history.push更新BrowserRoutes + 使用window.location.href = 'xxx';的方式用户会再一次看到整个页面的loading, 体验不好 + 后来我将逻辑写到用户登录状态确认之后, 也就是全局状态管理中: /src/models/user.ts就可以了 + 搜索 handleRedirect 即可找到相应的代码 + } + } + } + */ + if (!layoutWrapperLoading) { + if (isLogin) { + if (pathname !== '/user/login') { + pageContent = children; + } + } else { + //只要没登陆过就渲染登录页而不是跳转到登录页, 因为登录页不走/src/layouts + pageContent = ; + } + } + + return ( + + + {title} + + {pageContent} + + ); +}; + +export default connect( + ({ user }: { user: UserConnectedProps['user'] }) => ({ + user, + }), +)(LayoutWrapper); diff --git a/src/components/Nav/index.less b/src/components/Nav/index.less new file mode 100644 index 0000000..005752f --- /dev/null +++ b/src/components/Nav/index.less @@ -0,0 +1,24 @@ +.container { + display: flex; + justify-content: space-between; + height: 50px; + padding: 0 15px; + // background-color: #001529; + .former { + display: flex; + .left { + display: flex; + align-items: center; + img { + width: 28px; + height: 28px; + display: block; + } + } + } + .latter { + display: flex; + align-items: center; + gap: 0 15px; + } +} diff --git a/src/components/Nav/index.tsx b/src/components/Nav/index.tsx new file mode 100644 index 0000000..bced379 --- /dev/null +++ b/src/components/Nav/index.tsx @@ -0,0 +1,43 @@ +import type { FC } from 'react'; +import { Link } from 'umi'; +import Avatar from '../Avatar'; +import './index.less'; + +const Nav: FC = () => { + + return ( +
+
+ {/*
+ + logo +

UMI4 Admin

+ +
*/} +
+
+ +
+
+ ); +}; + +export default Nav; diff --git a/src/components/PageAccess.tsx b/src/components/PageAccess.tsx new file mode 100644 index 0000000..7a7dfc6 --- /dev/null +++ b/src/components/PageAccess.tsx @@ -0,0 +1,48 @@ +import type { FC, ReactElement } from 'react'; +import { connect, history, useLocation } from 'umi'; +import { Button, Result } from 'antd'; +import authority from '@/pages/authority'; +import type { UserConnectedProps } from '@/models/user'; + +type Props = { + children: ReactElement; +} & UserConnectedProps; + +//处理页面权限的组件, 放置在所有需要鉴权的页面的最外层 +const PageAccess: FC = (props): ReactElement | null => { + const { user, children } = props; + const { authority: userAuthority } = user; + const { pathname } = useLocation(); + //可选操作符用来处理子路由跳转的情形, 因为这里的权限都是有效路由的权限, + //当遇到输入的是菜单组件中可展开的节点的path的时候就没有权限了, 此时可能会报错, + //加可选操作符可避免这个报错导致的页面渲染问题 + const accessible = authority[pathname]?.some((item: string) => userAuthority.includes(item)); + + let res = children; + + if (!accessible) { + res = ( + history.push('/') + } + >返回首页 + )} + /> + ); + } + + return res; +}; + +export default connect( + ({ user }: { user: UserConnectedProps['user'] }) => ({ + user, + }), +)(PageAccess); diff --git a/src/global.less b/src/global.less new file mode 100644 index 0000000..7c45d66 --- /dev/null +++ b/src/global.less @@ -0,0 +1,7 @@ +html,body{ + padding: 0; + margin: 0; +} +*{ + box-sizing: border-box; +} diff --git a/src/layouts/index.less b/src/layouts/index.less new file mode 100644 index 0000000..2c1df36 --- /dev/null +++ b/src/layouts/index.less @@ -0,0 +1,56 @@ +.proLayoutBox{ + .ant-layout{ + .ant-layout-sider{ + background-color: #021529; + .ant-layout-sider-children{ + padding: 0; + .ant-pro-sider-logo{ + display: flex; + align-items: center; + a{ + display: block; + h1{ + color: #fff; + font-weight: 600; + font-size: 18px; + line-height: 32px; + vertical-align: none; + margin-left: 12px; + } + } + } + } + + .ant-layout-sider-children{ + .ant-pro-sider-extra{ + margin: 0; + .ant-layout{ + ul{ + li{ + span{ + font-size: 14px; + color: #ffffffA6; + } + i{ + font-size: 14px; + color: #ffffffA6; + } + } + } + } + } + } + } + } + + + + + + .ant-pro-layout-container{ + background-color: #f0f2f5; + main{ + padding: 0; + } + } +} \ No newline at end of file diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx new file mode 100644 index 0000000..f98ed10 --- /dev/null +++ b/src/layouts/index.tsx @@ -0,0 +1,346 @@ +import type { FC } from 'react'; +import { useState, useEffect } from 'react'; +import { Dropdown, Layout, Menu, Tabs, Tooltip } from 'antd'; +import type { MenuProps } from 'antd'; +import { Outlet, Link, useLocation, connect } from 'umi'; +import PageAccess from '@/components/PageAccess'; +import type { UserConnectedProps, UserModelState } from '@/models/user'; +import LayoutWrapper from '@/components/LayoutWrapper'; +import Nav from '@/components/Nav'; +import Page404 from '@/pages/404'; +import handleRecursiveNestedData from '@/utils/handleRecursiveNestedData'; +import handleGetCurrentLocation from '@/utils/handleGetCurrentLocation'; +import { MenuDataItem, ProLayout } from '@ant-design/pro-components'; +import './index.less' +import logo from '../assets/logo.svg'; +import Icon, { DoubleRightOutlined, SmileOutlined } from '@ant-design/icons'; +import { ProfileModelState } from '@/models/global'; +import React from 'react'; + +const { Header, Content } = Layout; +/** + * 获取openKeys的方法 + * @param currentLocation 当前位置, 由handleGetCurrentLocation方法返回 + * @returns openKeys + */ +const handleGetOpenKeys = (currentLocation: API.MenuItem[] | []): string[] => { + //currentLocation为空 + if (!currentLocation.length) return ['']; + + //currentLocation只有一项 + if (currentLocation.length === 1) { + return currentLocation.map((item: API.MenuItem) => `${item.key}`); + } + + const res = []; + + //currentLocation有多项, 只要前n-1项 + for (let i = 0; i < currentLocation.length - 1; i++) { + res.push(`${currentLocation[i].key}`); + } + + return res; +}; + +//自定义的layout页面, 顶部导航通栏+侧边栏(菜单)布局, 可根据需要做调整 +const BasicLayout: FC<{ user: UserModelState, global: ProfileModelState, dispatch: any }> = (props) => { + const [collapsed, setCollapsed] = useState(false); + const [openKeys, setOpenKeys] = useState(['']); + const { pathname } = useLocation(); + const { + dispatch, + user: { + menu, rootSubmenuKeys, indexAllMenuItemById, + indexValidMenuItemByPath + }, + global: { + + } + } = props; + + console.log('props', props); + + + const validMenuItem = indexValidMenuItemByPath[pathname]; + const selectedKeys = validMenuItem?.key; + + useEffect( + () => { + //每次页面重新渲染都要设置openKeys + setOpenKeys( + handleGetOpenKeys( + handleGetCurrentLocation(indexValidMenuItemByPath[pathname], indexAllMenuItemById), + ), + ); + }, + [pathname, indexAllMenuItemById, indexValidMenuItemByPath], + ); + + //Menu中的selectedKeys和openKeys不是一回事: + //openKeys: + //当前展开的SubMenu菜单项key数组, 有子菜单的父菜单, 当selectedKeys为没子菜单的父菜单时该值应该设为[''], + //也就是关闭所有有子菜单的父菜单; + //selectedKeys: + //当前选中的菜单项key数组, 有子菜单则是子菜单(叶子节点), 没有子菜单则是父菜单(一级菜单), 始终是可选中的 + + //点击有子菜单的父菜单的回调 + // const onOpenChange: MenuProps['onOpenChange'] = (keys) => { + // setOpenKeys(keys) + // console.log('keys', keys); + // dispatch({ + // namespace: "global/setProfileData", + // payload: { + // keys + // } + // }) + // }; + + //所有MenuItem: + //有children的: 一定都有path, lable不动, children下的label修改为 + //无children的: 有path的label修改为, 没path的label不动 + const consumableMenu = handleRecursiveNestedData( + menu, + (item: API.MenuItem) => ({ + ...item, + name: item.path + ? ( + {item.name} + ) + : item.name, + }), + ); + // const consumableMenu: MenuDataItem[] = [ + // { + // path: '/', + // name: 'Dashboard', + // icon: , + // }, + // { + // path: '/about', + // name: 'Users', + // icon: , + // children: [ + // { + // path: '/about/u', + // name: 'User List', + // }, + // { + // path: '/about/m', + // name: 'User Profile', + // }, + // ], + // }, + // ]; + console.log('consumableMenu', consumableMenu); + const location = useLocation(); + + // 改变panes + const handleTabsPanes = (payload: any): void => { + dispatch({ + type: "global/saveTabsRoutes", + payload: { + data: payload, + action: 'add' + } + }) + }; + + return ( + + + consumableMenu + // menu + } + subMenuItemRender={(menuItemProps) => { + if (menuItemProps.icon && typeof menuItemProps.icon === 'string') { + const ele = React.createElement(Icon[menuItemProps.icon]) + return
+ {ele} + {menuItemProps.name} +
; + } + return
+ {menuItemProps.name} +
; + }} + menuItemRender={(menuItemProps, defaultDom) => { + if ( + menuItemProps.isUrl || + !menuItemProps.path || + location.pathname === menuItemProps.path + ) { + return defaultDom; + } + return { + }}> + {defaultDom} + {/* {menuItemProps.name} */} + ; + }} + breadcrumbRender={false} + itemRender={(route, params, routes, paths) => { + const first = routes.indexOf(route) === 0; + return first ? ( + {route.breadcrumbName} + ) : ( + {route.breadcrumbName} + ); + }} + actionsRender={() =>