This commit is contained in:
cclu 2025-02-26 14:09:23 +08:00
parent 5fb3d5cbaf
commit 8f84f22e95
56 changed files with 14407 additions and 0 deletions

16
.editorconfig Normal file
View File

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

8
.eslintignore Normal file
View File

@ -0,0 +1,8 @@
/lambda/
/scripts
/config
.history
public
dist
.umi
mock

124
.eslintrc.json Normal file
View File

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

14
.gitignore vendored Normal file
View File

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

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

7
.husky/commit-msg Normal file
View File

@ -0,0 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Export Git hook params
export GIT_PARAMS=$*
npx --no-install fabric verify-commit

201
README-en_US.md Normal file
View File

@ -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):
[<emoji>] [revert: ?]<type>[(scope)?]: <message>
💥 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<T> = {
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 `<Access />` 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 ;)***

15
config/config.ts Normal file
View File

@ -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'
]
};

8
config/proxy.ts Normal file
View File

@ -0,0 +1,8 @@
const proxy = {
'/api': {
target: 'https://preview.pro.ant.design',
changeOrigin: true
}
}
export default proxy;

259
mock/user.ts Normal file
View File

@ -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<string, unknown> | Record<string, unknown>[], 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;

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"private": true,
"author": "小镇靓仔 <misunderstandjerry@qq.com>",
"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"
}
}

1
public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg width="28" height="27" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M26.594 12.968c0 .912-.74 1.651-1.652 1.651H3.058a1.651 1.651 0 01-.852-3.065 1.65 1.65 0 01.543-2.547 1.651 1.651 0 011.778-2.213A1.651 1.651 0 016.79 4.532a1.651 1.651 0 012.472-1.654 1.652 1.652 0 012.977-.786 1.652 1.652 0 013.11 0 1.652 1.652 0 012.977.785 1.651 1.651 0 012.472 1.654 1.651 1.651 0 012.263 2.262 1.651 1.651 0 011.778 2.213 1.65 1.65 0 01.65 2.404 1.652 1.652 0 011.105 1.558z" stroke="#000" stroke-width=".8" fill="#FFF" stroke-linejoin="round"/><path fill="#000" d="M9.531 24.937h8.938V27H9.531z"/><path d="M1 13.794h26c-.42 6.909-6.08 12.38-13 12.38S1.42 20.704 1 13.795h0z" stroke="#000" stroke-width=".8" fill="#1890FF"/><path d="M8.313 8.016a.41.41 0 01-.407-.413.41.41 0 01.407-.413.41.41 0 01.406.413.41.41 0 01-.406.413zm-1.626 2.063a.41.41 0 01-.406-.412.41.41 0 01.407-.413.41.41 0 01.406.413.41.41 0 01-.407.412zm1.625 1.651a.41.41 0 01-.406-.413.41.41 0 01.407-.412.41.41 0 01.406.412.41.41 0 01-.406.413zm1.626-3.3a.41.41 0 01-.407-.413.41.41 0 01.406-.413.41.41 0 01.407.413.41.41 0 01-.406.413zm9.75 1.65a.41.41 0 01-.407-.412.41.41 0 01.407-.413.41.41 0 01.406.413.41.41 0 01-.407.412z" fill="#000"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" version="1.1" viewBox="0 0 200 200"><title>Group 28 Copy 5</title><desc>Created with Sketch.</desc><defs><linearGradient id="linearGradient-1" x1="62.102%" x2="108.197%" y1="0%" y2="37.864%"><stop offset="0%" stop-color="#4285EB"/><stop offset="100%" stop-color="#2EC7FF"/></linearGradient><linearGradient id="linearGradient-2" x1="69.644%" x2="54.043%" y1="0%" y2="108.457%"><stop offset="0%" stop-color="#29CDFF"/><stop offset="37.86%" stop-color="#148EFF"/><stop offset="100%" stop-color="#0A60FF"/></linearGradient><linearGradient id="linearGradient-3" x1="69.691%" x2="16.723%" y1="-12.974%" y2="117.391%"><stop offset="0%" stop-color="#FA816E"/><stop offset="41.473%" stop-color="#F74A5C"/><stop offset="100%" stop-color="#F51D2C"/></linearGradient><linearGradient id="linearGradient-4" x1="68.128%" x2="30.44%" y1="-35.691%" y2="114.943%"><stop offset="0%" stop-color="#FA8E7D"/><stop offset="51.264%" stop-color="#F74A5C"/><stop offset="100%" stop-color="#F51D2C"/></linearGradient></defs><g id="Page-1" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="logo" transform="translate(-20.000000, -20.000000)"><g id="Group-28-Copy-5" transform="translate(20.000000, 20.000000)"><g id="Group-27-Copy-3"><g id="Group-25" fill-rule="nonzero"><g id="2"><path id="Shape" fill="url(#linearGradient-1)" d="M91.5880863,4.17652823 L4.17996544,91.5127728 C-0.519240605,96.2081146 -0.519240605,103.791885 4.17996544,108.487227 L91.5880863,195.823472 C96.2872923,200.518814 103.877304,200.518814 108.57651,195.823472 L145.225487,159.204632 C149.433969,154.999611 149.433969,148.181924 145.225487,143.976903 C141.017005,139.771881 134.193707,139.771881 129.985225,143.976903 L102.20193,171.737352 C101.032305,172.906015 99.2571609,172.906015 98.0875359,171.737352 L28.285908,101.993122 C27.1162831,100.824459 27.1162831,99.050775 28.285908,97.8821118 L98.0875359,28.1378823 C99.2571609,26.9692191 101.032305,26.9692191 102.20193,28.1378823 L129.985225,55.8983314 C134.193707,60.1033528 141.017005,60.1033528 145.225487,55.8983314 C149.433969,51.69331 149.433969,44.8756232 145.225487,40.6706018 L108.58055,4.05574592 C103.862049,-0.537986846 96.2692618,-0.500797906 91.5880863,4.17652823 Z"/><path id="Shape" fill="url(#linearGradient-2)" d="M91.5880863,4.17652823 L4.17996544,91.5127728 C-0.519240605,96.2081146 -0.519240605,103.791885 4.17996544,108.487227 L91.5880863,195.823472 C96.2872923,200.518814 103.877304,200.518814 108.57651,195.823472 L145.225487,159.204632 C149.433969,154.999611 149.433969,148.181924 145.225487,143.976903 C141.017005,139.771881 134.193707,139.771881 129.985225,143.976903 L102.20193,171.737352 C101.032305,172.906015 99.2571609,172.906015 98.0875359,171.737352 L28.285908,101.993122 C27.1162831,100.824459 27.1162831,99.050775 28.285908,97.8821118 L98.0875359,28.1378823 C100.999864,25.6271836 105.751642,20.541824 112.729652,19.3524487 C117.915585,18.4685261 123.585219,20.4140239 129.738554,25.1889424 C125.624663,21.0784292 118.571995,14.0340304 108.58055,4.05574592 C103.862049,-0.537986846 96.2692618,-0.500797906 91.5880863,4.17652823 Z"/></g><path id="Shape" fill="url(#linearGradient-3)" d="M153.685633,135.854579 C157.894115,140.0596 164.717412,140.0596 168.925894,135.854579 L195.959977,108.842726 C200.659183,104.147384 200.659183,96.5636133 195.960527,91.8688194 L168.690777,64.7181159 C164.472332,60.5180858 157.646868,60.5241425 153.435895,64.7316526 C149.227413,68.936674 149.227413,75.7543607 153.435895,79.9593821 L171.854035,98.3623765 C173.02366,99.5310396 173.02366,101.304724 171.854035,102.473387 L153.685633,120.626849 C149.47715,124.83187 149.47715,131.649557 153.685633,135.854579 Z"/></g><ellipse id="Combined-Shape" cx="100.519" cy="100.437" fill="url(#linearGradient-4)" rx="23.6" ry="23.581"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

0
src/assets/.gitkeep Normal file
View File

BIN
src/assets/login-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

37
src/assets/logo.svg Normal file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="35px" height="30px" viewBox="0 0 35 30" enable-background="new 0 0 35 30" xml:space="preserve"> <image id="image0" width="35" height="30" x="0" y="0"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAeCAYAAACmPacqAAAABGdBTUEAALGPC/xhBQAAACBjSFJN
AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QA/wD/AP+gvaeTAAAA
B3RJTUUH5QkBEx07b/a9lAAABN5JREFUWMO1l1tsVUUUhr+16ZVyCbESEi/RB1BAtPVBg0ZAgyjB
qBFFuRoRUe4FVIiJGJUEXoxBQaNAJDTcJFK5aJSbRZ7QBzSIYGgowSZiKBcFbaGc9fuw99lnn/aU
tiDzMvvMnj3zzT//zFrHJNFWSU2dUyrxNDAMqRy4GQiQHKhDHAD2gKryPl1S0+aArRS7HMylKXOK
kN4FpgHFpPvGnyjzHL/TTmBh3soP9/xvMJdemX090g7grjYmTwAq/on0OTAtf9Wy+quCuTS5IgB2
gwZ3YPKW76XfgRH5lR8fuHKYl2aNQ6pMDH4CcRBUK3ESaEIqBW5CuhfoGU2eC7ge8UD+2k8OXxnM
izOqEadAX0l8n//Z0lZN2TR+iiHdB8xGjMxsZVhH4x9GlBdsWN7YYZimF6Z3z/9s6V/tkTbru9GT
H5a0FihNAkVKVRRsXLmkwzBXUy6OmjQAaS/QvZl/jgJzkb4rqFqVc6E5Yfou/7uXxDi5PYK4TW69
5OQjzsutTs5PyHbKqTr6WufTLYBGTnwWaX0IoqiKt+8iYiOwqHDL6oOtwvRbdTYftwUSr8qtCAfJ
kANuSCC3UP2wrVFimdzerp1XfC458IUnn69GDG5xHWS2LwVajHir8Os1qSyY/qvPFiK2yW2oBGkQ
PAQI20KwuD0G5YBkD9bOKz4Vwzw+4VakERHQcERJMw+l1VoHjC/6dl0qiCUyLcMYaibMACOuM8+C
AMwUtUV9AwaYkWXOwi2rawu3Vi4t3Fb5DCm/AfePlHKUcuRCKQcXuI8m5W/EygxYe2aonB0ovfLM
tmRtT1KNqC18NuRIsltqXy8+3pq5G4c9Nx9pUdZJC+uLiN5B5Jx3sldOpk6oQMC/mLabsRpjO8bZ
hIpmpscud9KKtq9fjPubuENKpGu5F8h9ct6dG07fbcZABaFPDCGzuCYAc5Cx3tD036Z2i33R+4Pz
RQpsnLnek1k3oKyto1+0a+PChiEj+yLGZDwEoOEBpqeIFMisspknTF9YwJjDL2dAAI7M7NJYM6tk
BcYgAp02o2dbMACkNAf3C7E67uDqE5gxJGnQlubVBTNmHJrUrdXbsaai5GczRmHq1B6W4r2b/sRV
HUGkgboEGH3MFJ2oJFBaHbb9OrH7H21NUFNRssuMqnYpA+B+Ahfy6HS5GvLMuE6EWyIMA2RhfwuE
3H5o9wQB69rbVa7SZlH+SB4W3Y+W6GkhVgTZ7jSyZlZJQweU6Zt9xNkXgE5aBJMxLkn/dG33BB0p
rrqMZwTuywOMQ7lPUgz06LWB8TlIx3A1Is3tvP+bH62s6uR8iUVhIhQGwfimDW9gSTz0y9ge1dcE
KlECjEozmmI1oHk8MjO23rHmzIRrDWOSKP+y/n1EhUQYZ6JbMROdScef/ZKtwNktcSKKW13l1qkd
6UayLSW3c+l3kjUdX1D4Twizub4EsU+iP82B0gM2C5BxoOx4utGyTdYgMTjOZ8o319+I2CFxe1KV
GCQrkuccMJw8oUYSMBs+rVLmO2R743xm/xOldRj3mLESi+69HPlMjnCR9c6CzGmM+0ZtOW735Nj3
58yBy6vq+0nMRDZKokcL/6RXllAnVCDXtrTMh9LjJBVDHLvsv4OyTfWFyAZKDEKUya03opdkpa37
p1nylcs/nnPbx/4HXgSzauZflRIAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjEtMDktMDFUMTE6Mjk6
NTkrMDg6MDCXs8feAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIxLTA5LTAxVDExOjI5OjU5KzA4OjAw
5u5/YgAAACB0RVh0c29mdHdhcmUAaHR0cHM6Ly9pbWFnZW1hZ2ljay5vcme8zx2dAAAAGHRFWHRU
aHVtYjo6RG9jdW1lbnQ6OlBhZ2VzADGn/7svAAAAF3RFWHRUaHVtYjo6SW1hZ2U6OkhlaWdodAAz
MMb6mdgAAAAWdEVYdFRodW1iOjpJbWFnZTo6V2lkdGgAMzVOP63aAAAAGXRFWHRUaHVtYjo6TWlt
ZXR5cGUAaW1hZ2UvcG5nP7JWTgAAABd0RVh0VGh1bWI6Ok1UaW1lADE2MzA0NjY5OTka9aiIAAAA
EnRFWHRUaHVtYjo6U2l6ZQAxMzg5QkJc76D9AAAARnRFWHRUaHVtYjo6VVJJAGZpbGU6Ly8vYXBw
L3RtcC9pbWFnZWxjL2ltZ3ZpZXcyXzlfMTYzMDQ2NDI3MzkwMjUxNjNfMzVfWzBdmaJpYAAAAABJ
RU5ErkJggg==" ></image>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

32
src/components/Access.tsx Normal file
View File

@ -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> = (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);

View File

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

View File

@ -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<UserConnectedProps> = (props) => {
const {
user: { data }, dispatch,
} = props;
const handleLogout = () => {
dispatch?.({
type: 'user/logout',
});
};
const items = [
{
key: 'logout',
label: (
<a
onClick={handleLogout}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0 8px',
}}
>
<LogoutOutlined />
<span>退</span>
</a>
),
},
];
return (
<Dropdown
menu={{ items }}
>
<Space className="avatar-container">
<img
alt="avatar"
src={data.avatar}
className="avatar"
/>
<span className="username">{data.name}</span>
</Space>
</Dropdown>
);
};
export default connect(
({ user }: { user: UserConnectedProps['user'] }) => ({
user,
}),
)(Avatar);

47
src/components/Footer.tsx Normal file
View File

@ -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 (
<DefaultFooter
style={{ background: "#f0f2f5", color: "rgba(0, 0, 0, 0.45)" }}
copyright={`Copyright © 2013-${new Date().getFullYear()} Eshang Cloud. All Rights Reserved. 驿商云 版权所有`}
/>
// <DefaultFooter
// style={{
// backgroundColor: 'inherit',
// }}
// copyright={`${currentYear} ${defaultMessage}`}
// links={[
// {
// key: 'Ant Design Pro',
// title: 'Ant Design Pro',
// href: 'https://pro.ant.design',
// blankTarget: true,
// },
// {
// key: 'github',
// title: <GithubOutlined />,
// 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;

View File

@ -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> = (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 = (
<Spin
size="large"
style={{
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
);
//页面不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 = <LoginPage />;
}
}
return (
<Fragment>
<Helmet>
<title>{title}</title>
</Helmet>
{pageContent}
</Fragment>
);
};
export default connect(
({ user }: { user: UserConnectedProps['user'] }) => ({
user,
}),
)(LayoutWrapper);

View File

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

View File

@ -0,0 +1,43 @@
import type { FC } from 'react';
import { Link } from 'umi';
import Avatar from '../Avatar';
import './index.less';
const Nav: FC = () => {
return (
<div className="container">
<div className="former">
{/* <div className="left">
<Link
to="/"
style={{
display: 'flex',
alignItems: 'center',
gap: '0 15px',
lineHeight: 1,
}}
>
<img
alt="logo"
src="/logo.svg"
className="logo"
/>
<h1
style={{
margin: 0,
color: '#fff',
fontSize: '18px',
}}
>UMI4 Admin</h1>
</Link>
</div> */}
</div>
<div className="latter">
<Avatar />
</div>
</div>
);
};
export default Nav;

View File

@ -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> = (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 = (
<Result
title="403"
status="403"
subTitle="对不起, 您没有访问此页面的权限"
extra={(
<Button
type="primary"
onClick={
() => history.push('/')
}
></Button>
)}
/>
);
}
return res;
};
export default connect(
({ user }: { user: UserConnectedProps['user'] }) => ({
user,
}),
)(PageAccess);

7
src/global.less Normal file
View File

@ -0,0 +1,7 @@
html,body{
padding: 0;
margin: 0;
}
*{
box-sizing: border-box;
}

56
src/layouts/index.less Normal file
View File

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

346
src/layouts/index.tsx Normal file
View File

@ -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修改为<Link to={path} />
//无children的: 有path的label修改为<Link to={path} />, 没path的label不动
const consumableMenu = handleRecursiveNestedData(
menu,
(item: API.MenuItem) => ({
...item,
name: item.path
? (
<Link
to={item.path}
style={{ color: 'inherit' }}
>{item.name}</Link>
)
: item.name,
}),
);
// const consumableMenu: MenuDataItem[] = [
// {
// path: '/',
// name: 'Dashboard',
// icon: <SmileOutlined />,
// },
// {
// path: '/about',
// name: 'Users',
// icon: <SmileOutlined />,
// 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 (
<LayoutWrapper>
<ProLayout
logo={logo}
contentWidth={"Fluid"}
fixedHeader={true}
fixSiderbar={true}
colorWeak={false}
title={"驿商云平台"}
pwa={false}
iconfontUrl={'//at.alicdn.com/t/font_2794551_djdgwbunsvg.js'}
footerRender={false}
location={location}
style={{ minHeight: '100vh' }}
siderWidth={208}
menuDataRender={() =>
consumableMenu
// menu
}
subMenuItemRender={(menuItemProps) => {
if (menuItemProps.icon && typeof menuItemProps.icon === 'string') {
const ele = React.createElement(Icon[menuItemProps.icon])
return <div className="ant-pro-menu-item">
<span style={{ marginRight: 10 }} className="ant-pro-menu-item">{ele}</span>
<span className='ant-pro-menu-item-title'>{menuItemProps.name}</span>
</div>;
}
return <div>
{menuItemProps.name}
</div>;
}}
menuItemRender={(menuItemProps, defaultDom) => {
if (
menuItemProps.isUrl ||
!menuItemProps.path ||
location.pathname === menuItemProps.path
) {
return defaultDom;
}
return <Link to={menuItemProps.path || '/'} onClick={() => {
}}>
{defaultDom}
{/* {menuItemProps.name} */}
</Link>;
}}
breadcrumbRender={false}
itemRender={(route, params, routes, paths) => {
const first = routes.indexOf(route) === 0;
return first ? (
<Link to={paths.join('/')}>{route.breadcrumbName}</Link>
) : (
<span>{route.breadcrumbName}</span>
);
}}
actionsRender={() => <nav />}
onPageChange={(location) => {
console.log('location', location);
if (location?.pathname && location?.pathname !== '/') {
const nextModule = consumableMenu.find(n => location?.pathname && n?.path && location?.pathname.match(n?.path))
console.log('nextModule', nextModule);
}
handleTabsPanes({ path: location?.pathname, key: location?.pathname })
}}
>
{/* menuExtraRender={() =>
<Layout>
<Menu
mode="inline"
openKeys={openKeys}
items={consumableMenu}
onOpenChange={(e: any, dom: any) => {
onOpenChange(e)
}}
onSelect={(item: any, key: any) => {
console.log('item', item);
console.log('key', key);
}}
selectedKeys={[selectedKeys]}
/>
</Layout>
} */}
<Layout>
<Header style={{ background: '#fff', height: '48px', padding: '0 16px' }}>
<Nav />
</Header>
<Content>
<Tabs
hideAdd
type="editable-card"
size="small"
className='main-tab'
tabBarExtraContent={
<Tooltip title="关闭选项卡" placement="topRight">
<Dropdown overlay={
<Menu onClick={(targetKey) => {
}}>
<Menu.Item key="other"></Menu.Item>
<Menu.Item key="now"></Menu.Item>
<Menu.Item key="all"></Menu.Item>
</Menu>
}
trigger={['click']}
placement="bottomRight"
arrow>
<div onClick={(e) => {
e.preventDefault()
}}>
<img style={{ width: '20px', height: '20px', cursor: 'pointer' }} />
</div>
</Dropdown >
</Tooltip>
}
moreIcon={<DoubleRightOutlined style={{ color: "#7b828c" }} />}
>
{/* {tabsPanes && tabsPanes.map((item: any) =>
<TabPane
tab={item.title} key={item?.path}
style={{ padding: 24, paddingTop: 0 }}>
{
//统一对所有有效路由做页面鉴权的处理
validMenuItem
? (
<PageAccess>
<Outlet />
</PageAccess>
)
: <Page404 />
}
</TabPane>)
} */}
</Tabs>
</Content>
</Layout>
</ProLayout>
</LayoutWrapper >
// <LayoutWrapper>
// <Layout style={{ minHeight: '100vh' }}>
// <Header
// style={{
// padding: '0',
// height: 'auto',
// }}
// >
// <Nav />
// </Header>
// <Layout>
// <Sider
// collapsible
// theme="light"
// collapsed={collapsed}
// onCollapse={(value) => setCollapsed(value)}
// >
// <Menu
// mode="inline"
// openKeys={openKeys}
// items={consumableMenu}
// onOpenChange={onOpenChange}
// selectedKeys={[selectedKeys]}
// />
// </Sider>
// <Layout>
// <Content style={{ margin: '0 16px' }}>
// {
// //统一对所有有效路由做页面鉴权的处理
// validMenuItem
// ? (
// <PageAccess>
// <Outlet />
// </PageAccess>
// )
// //这里不再使用<Outlet />是因为当一条路由在菜单接口中没有返回, 但实际项目中创建了,
// //此时访问应该显示404页面; 菜单中没有, 实际项目中有表示这条路由/页面当前登录用户无法访问,
// //而如果继续使用<Outlet />则那条路由/页面依旧会被渲染, 因为对于umi来说这条路由/页面是有效的,
// //是存在的, 而对于本项目来说则不是
// : <Page404 />
// }
// </Content>
// </Layout>
// </Layout>
// </Layout>
// </LayoutWrapper>
);
};
export default connect(
({ user, global }: { user: UserConnectedProps['user'], global: any }) => ({
user,
global
}),
)(BasicLayout);

156
src/layouts/old.tsx Normal file
View File

@ -0,0 +1,156 @@
import type { FC } from 'react';
import { useState, useEffect } from 'react';
import { Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import { Outlet, Link, useLocation, connect } from 'umi';
import PageAccess from '@/components/PageAccess';
import type { UserConnectedProps } 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';
const { Header, Content, Sider } = 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<UserConnectedProps> = (props) => {
const [collapsed, setCollapsed] = useState(false);
const [openKeys, setOpenKeys] = useState(['']);
const { pathname } = useLocation();
const {
user: {
menu, rootSubmenuKeys, indexAllMenuItemById,
indexValidMenuItemByPath,
},
} = 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) => {
const latestOpenKey = keys.find((key) => !openKeys.includes(key));
if (!rootSubmenuKeys.includes(`${latestOpenKey}`)) {
setOpenKeys(keys);
} else {
setOpenKeys(latestOpenKey ? [latestOpenKey] : ['']);
}
};
//所有MenuItem:
//有children的: 一定都有path, lable不动, children下的label修改为<Link to={path} />
//无children的: 有path的label修改为<Link to={path} />, 没path的label不动
const consumableMenu = handleRecursiveNestedData(
menu,
(item: API.MenuItem) => ({
...item,
label: item.path
? (
<Link
to={item.path}
style={{ color: 'inherit' }}
>{item.label}</Link>
)
: item.label,
}),
);
return (
<LayoutWrapper>
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
padding: '0',
height: 'auto',
}}
>
<Nav />
</Header>
<Layout>
<Sider
collapsible
theme="light"
collapsed={collapsed}
onCollapse={(value) => setCollapsed(value)}
>
<Menu
mode="inline"
openKeys={openKeys}
items={consumableMenu}
onOpenChange={onOpenChange}
selectedKeys={[selectedKeys]}
/>
</Sider>
<Layout>
<Content style={{ margin: '0 16px' }}>
{
//统一对所有有效路由做页面鉴权的处理
validMenuItem
? (
<PageAccess>
<Outlet />
</PageAccess>
)
//这里不再使用<Outlet />是因为当一条路由在菜单接口中没有返回, 但实际项目中创建了,
//此时访问应该显示404页面; 菜单中没有, 实际项目中有表示这条路由/页面当前登录用户无法访问,
//而如果继续使用<Outlet />则那条路由/页面依旧会被渲染, 因为对于umi来说这条路由/页面是有效的,
//是存在的, 而对于本项目来说则不是
: <Page404 />
}
</Content>
</Layout>
</Layout>
</Layout>
</LayoutWrapper>
);
};
export default connect(
({ user }: { user: UserConnectedProps['user'] }) => ({
user,
}),
)(BasicLayout);

74
src/models/global.ts Normal file
View File

@ -0,0 +1,74 @@
// models/profile.ts
import { Effect, Reducer } from 'umi';
export interface ProfileModelState {
avatar: string;
bio: string;
}
export interface ProfileModelType {
namespace: 'global';
state: any;
reducers: {
saveTabsRoutes: Reducer<GlobalModelState>;
};
effects: {
changeTabsRoutes: Effect;
};
}
export type tabsRoute = {
title: string;
path: string;
key: string;
children: any;
}
export type GlobalModelState = {
tabsRoutes: tabsRoute[];
};
const ProfileModel: ProfileModelType = {
namespace: 'global',
state: {
tabsRoutes: [],
},
reducers: {
saveTabsRoutes(state = { tabsRoutes: [] }, { payload }): GlobalModelState {
return {
...state,
tabsRoutes: payload
}
},
},
effects: {
* changeTabsRoutes({ payload, callback }, { put, select }) { // 缓存路由栈
const tabsRoutes: tabsRoute[] = yield select((state: ConnectState) => {
const { data } = payload
if (payload.action === 'add') {
const index = state.global.tabsRoutes.findIndex(n => n.path === data.path)
if (index === -1) { // 没缓存 则添加
return [...state.global.tabsRoutes, { ...data, index: state.global.tabsRoutes.length }]
}
// 否则不操作
return [...state.global.tabsRoutes]
}
if (payload.action === 'removeAll') {
return []
}
if (callback && typeof callback === 'function') {
callback.call()
}
// 移除单个/多个路由
return state.global.tabsRoutes.filter(n => !data.includes(n.path))
});
yield put({
type: 'saveTabsRoutes',
payload: tabsRoutes,
});
}
},
};
export default ProfileModel;

263
src/models/user.ts Normal file
View File

@ -0,0 +1,263 @@
import { history } from 'umi';
import {
userLogin, retrieveUserInfo, retrieveUserInfoAuthorityMenu, userLogout,
retrieveUserAuthorityMenu,
} from '@/services/user';
import type { Effect, Reducer, ConnectProps } from 'umi';
import handleRedirect from '@/utils/handleRedirect';
import handleGetRootSubmenuKeys from '@/utils/handleGetRootSubmenuKeys';
import handleGetEachDatumFromNestedDataByKey from '@/utils/handleGetEachDatumFromNestedDataByKey';
import handleGetIndexValidMenuItemByPath from '@/utils/handleGetIndexValidMenuItemByPath';
/**
*
* @description isLogin
* @description data
* @description menu
* @description authority
* @description loginBtnLoading loading状态
* @description rootSubmenuKeys key
* @description layoutWrapperLoading loading状态
* @description indexAllMenuItemById id索引索引菜单项的映射
* @description indexAllMenuItemByPath path索引所有菜单项的映射
* @description indexValidMenuItemByPath path索引有效菜单项的映射
*/
export type UserModelState = {
isLogin: boolean;
data: API.UserInfo;
menu: API.MenuData;
authority: string[];
loginBtnLoading: boolean;
rootSubmenuKeys: React.Key[];
layoutWrapperLoading: boolean;
indexAllMenuItemById: IndexAllMenuItemByKey<'id'>;
indexValidMenuItemByPath: IndexValidMenuItemByPath;
indexAllMenuItemByPath: IndexAllMenuItemByKey<'path'>;
}
export type UserConnectedProps = {
user: UserModelState;
} & ConnectProps;
type UserModelType = {
namespace: 'user';
state: UserModelState;
effects: {
login: Effect;
logout: Effect;
resetLoginStatus: Effect;
getUserInfoAuthorityMenu: Effect;
};
reducers: {
save: Reducer<UserModelState>;
};
}
/**
*
* @description |
*/
type ReqOrder = 'concurrent' | 'relay';
const UserModel: UserModelType = {
namespace: 'user',
state: {
data: {},
authority: [],
isLogin: false,
rootSubmenuKeys: [],
loginBtnLoading: false,
layoutWrapperLoading: true,
menu: [
{
id: 1,
key: '1',
path: '/',
label: '首页',
redirect: '',
},
],
indexAllMenuItemById: {
1: {
id: 1,
key: '1',
path: '/',
label: '首页',
redirect: '',
},
},
indexAllMenuItemByPath: {
'/': {
id: 1,
key: '1',
path: '/',
label: '首页',
redirect: '',
},
},
indexValidMenuItemByPath: {
'/': {
id: 1,
key: '1',
path: '/',
label: '首页',
redirect: '',
},
},
},
effects: {
//登录
*login({ payload }, { call, put }) {
yield put({
type: 'save',
payload: {
loginBtnLoading: true,
},
});
const res: API.LoginResponse = yield call(userLogin, payload);
//登录成功之后设置token并获取用户信息等数据
if (!res.code) {
localStorage.setItem('Authorization', res.data.token);
yield put({
type: 'getUserInfoAuthorityMenu',
payload: {
type: 'concurrent',
},
});
} else {
yield put({
type: 'save',
payload: {
loginBtnLoading: false,
},
});
}
},
//获取用户信息和权限以及菜单
*getUserInfoAuthorityMenu({ payload }, { call, put }) {
const { type }: { type: ReqOrder } = payload;
let userInfoRes: API.UserInfoResponse = {
data: {},
code: 0,
message: '',
};
let userAuthorityRes: API.UserAuthorityResponse = {
data: {
authority: [],
},
code: 0,
message: '',
};
let menuRes: API.MenuDataResponse = {
data: [],
code: 0,
message: '',
};
//用户在登录页登录完成之后执行
if (type === 'concurrent') {
const res: API.UserInfoAuthMenuResponse = yield call(retrieveUserInfoAuthorityMenu);
userInfoRes = res[0] as API.UserInfoResponse;
userAuthorityRes = res[1] as API.UserAuthorityResponse;
menuRes = res[2] as API.MenuDataResponse;
} else {
//其他情形首先查询用户的登录状态, 未登录则不继续操作
try {
userInfoRes = yield call(retrieveUserInfo);
} catch (error) {
//接口报错了, 比如返回了401
yield put({
type: 'save',
payload: {
layoutWrapperLoading: false,
},
});
return false;
}
const res: API.UserAuthMenuResponse = yield call(retrieveUserAuthorityMenu);
userAuthorityRes = res[0] as API.UserAuthorityResponse;
menuRes = res[1] as API.MenuDataResponse;
}
const indexAllMenuItemByPath = handleGetEachDatumFromNestedDataByKey(menuRes.data, 'path');
const indexValidMenuItemByPath = handleGetIndexValidMenuItemByPath(menuRes.data);
//在登录完获取菜单数据之后做是否需要重定向的操作
yield call(
handleRedirect,
window.location.pathname === '/user/login',
indexAllMenuItemByPath,
indexValidMenuItemByPath,
);
yield put({
type: 'save',
payload: {
isLogin: true,
menu: menuRes.data,
data: userInfoRes.data,
loginBtnLoading: false,
indexAllMenuItemByPath,
indexValidMenuItemByPath,
layoutWrapperLoading: false,
authority: userAuthorityRes.data.authority,
rootSubmenuKeys: handleGetRootSubmenuKeys(menuRes.data),
indexAllMenuItemById: handleGetEachDatumFromNestedDataByKey(menuRes.data, 'id'),
},
});
//为保证所有语句都return, 因此这里加一句这个
return true;
},
//登出
*logout({ payload }, { call, put }) {
const res: API.LogoutResponse = yield call(userLogout, payload);
if (!res.code) {
yield put({
type: 'resetLoginStatus',
});
}
},
//重置登录状态
*resetLoginStatus(_, { put }) {
localStorage.removeItem('Authorization');
yield put({
type: 'save',
payload: {
isLogin: false,
loginBtnLoading: false,
},
});
//当前页面不是登录页, 且没有redirect参数的时候再设置redirect参数
//而且这个redirect参数要包含url pathname和查询字符串参数, 即pathname及其后面的所有字符
if (window.location.pathname !== '/user/login') {
if (!/redirect=/.test(window.location.search)) {
const redirectValue = `${window.location.pathname}${window.location.search}`;
history.push(`/user/login?redirect=${encodeURIComponent(redirectValue)}`);
}
}
},
},
reducers: {
save(state, action) {
return {
...state,
...action.payload,
};
},
},
};
export default UserModel;

24
src/pages/404.tsx Normal file
View File

@ -0,0 +1,24 @@
import type { FC } from 'react';
import { history } from 'umi';
import { Button, Result } from 'antd';
const _404: FC = () => {
return (
<Result
title="404"
status="404"
subTitle="对不起, 您访问的页面不存在"
extra={(
<Button
type="primary"
onClick={
() => history.push('/')
}
></Button>
)}
/>
);
};
export default _404;

12
src/pages/about/index.tsx Normal file
View File

@ -0,0 +1,12 @@
import type { FC } from 'react';
const Index: FC = () => {
return (
<div>
<div>/about</div>
</div>
);
};
export default Index;

View File

@ -0,0 +1,5 @@
const authority: Authority = {
aboutMUpdate: '/about/m/update',
};
export default authority;

View File

@ -0,0 +1,24 @@
import type { FC } from 'react';
import Access from '@/components/Access';
import authority from './authority';
const { aboutMUpdate } = authority;
const Index: FC = () => {
return (
<div>
<div>/about/m</div>
<Access
authority={aboutMUpdate}
fallback={
<div>/</div>
}
>
<div>/</div>
</Access>
</div>
);
};
export default Index;

14
src/pages/about/u/$id.tsx Normal file
View File

@ -0,0 +1,14 @@
import { useParams } from 'umi';
const Index = () => {
const params = useParams();
console.log(params);
return (
<div>{`动态路由: /about/u/${params.id}`}</div>
);
};
export default Index;

View File

@ -0,0 +1,14 @@
import { PureComponent } from 'react';
class Index extends PureComponent {
render() {
return (
<div>/about/u</div>
);
}
}
export default Index;

View File

@ -0,0 +1,14 @@
import { PureComponent } from 'react';
class Index extends PureComponent {
render() {
return (
<div>/about/um</div>
);
}
}
export default Index;

31
src/pages/authority.ts Normal file
View File

@ -0,0 +1,31 @@
const authority: PageAuthority = {
'/': [
'/',
],
'/about/u/1': [
'/about/u/1',
],
'/about/u/2': [
'/about/u/2',
],
'/about/m': [
'/about/m',
],
'/about/um': [
'/about/um',
],
'/teacher/u': [
'/ttt',
],
'/teacher/m': [
'/teacher/m',
],
'/teacher/um': [
'/teacher/um',
],
'/student': [
'/student',
],
};
export default authority;

22
src/pages/index.tsx Normal file
View File

@ -0,0 +1,22 @@
import { PureComponent } from 'react';
import { connect } from 'umi';
import type { UserConnectedProps } from '@/models/user';
class Index extends PureComponent<UserConnectedProps> {
render() {
const { user } = this.props;
const { data } = user;
const { name } = data;
return (
<div>{name}</div>
);
}
}
export default connect(
({ user }: { user: UserConnectedProps['user'] }) => ({
user,
}),
)(Index);

View File

@ -0,0 +1,10 @@
import type { FC } from 'react';
const Index: FC = () => {
return (
<div>/teacher/m</div>
);
};
export default Index;

View File

@ -0,0 +1,10 @@
import type { FC } from 'react';
const Index: FC = () => {
return (
<div>/teacher/u</div>
);
};
export default Index;

View File

@ -0,0 +1,10 @@
import type { FC } from 'react';
const Index: FC = () => {
return (
<div>/teacher/um</div>
);
};
export default Index;

View File

@ -0,0 +1,168 @@
.lang {
width: 100%;
height: 64px;
line-height: 64px;
display:flex;
align-items: center;
background-color: #fff;
box-shadow: 0px 3px 9px 0px rgba(204,212,230,0.84);
:global(.ant-dropdown-trigger) {
margin-right: 24px;
}
}
.link{
display:flex;
align-items: center;
}
.headtitle {
color: #191919;
font-weight: 700;
font-size: 15px;
text-decoration: none;
}
.logo {
width: 35px;
height: 30px;
margin-right: 16px;
margin-left: 24px;
// vertical-align: top;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: auto;
background: #f0f2f5;
}
// .lang {
// width: 100%;
// height: 40px;
// line-height: 44px;
// text-align: right;
// :global(.ant-dropdown-trigger) {
// margin-right: 24px;
// }
// }
.content {
flex: 1;
padding: 32px 0;
}
@media (min-width: 768px) {
.container {
background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg');
background-repeat: no-repeat;
background-position: center 110px;
background-size: 100%;
}
.content {
padding: 32px 0 24px;
}
}
.icon {
margin-left: 8px;
color: rgba(0, 0, 0, 0.2);
font-size: 24px;
vertical-align: middle;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: #1890ff;
}
}
.footer{
width: 100%;
.ant-layout-footer{
background: #f0f2f5!important;
.ant-pro-global-footer{
background: #f0f2f5!important;
}
}
}
.top {
padding-left: 12%;
}
.header {
height: 44px;
line-height: 44px;
padding-left: 9%;
a {
text-decoration: none;
}
}
.loginbg {
width: 100%;
max-width: 875px;
max-height: 507px;
}
.title {
position: relative;
top: 2px;
font-size: 34px;
font-family: Avenir, 'Helvetica Neue', Arial, Helvetica, sans-serif;
}
.desc {
margin-top: 12px;
margin-bottom: 40px;
color: #787C86;
}
.main {
width: 675px;
// margin: 0 auto;
// margin-right: 10%;
background-color: #fff;
border: 1px solid #f5f5f5;
box-shadow: 0 4px 18px 0 rgba(175,175,175,0.43);
.title {
font-size: 26px;
text-align: center;
padding: 24px 0;
border-bottom: 1px solid #d7d8d9;
}
.form {
padding-top:14%;
}
.icon {
margin-left: 16px;
color: rgba(0, 0, 0, 0.2);
font-size: 24px;
vertical-align: middle;
cursor: pointer;
transition: color 0.3s;
}
.prefixIcon {
color: #D7D8D9;
}
.form-bottom {
margin: 10% 20% 36px 20%;
.divider {
font-size: 13px;
}
p {
color: #A7A7A7;
font-size: 12px;
}
}
}

View File

@ -0,0 +1,147 @@
import type { FC } from 'react';
import { useState, Fragment } from 'react';
import { Col, Divider, notification, Row, Space, Tabs, Typography } from 'antd';
import { connect, Link } from 'umi';
import {
AlipayCircleOutlined,
LockFilled,
LockOutlined,
MobileOutlined,
TaobaoCircleOutlined,
UserOutlined,
WeiboCircleOutlined,
} from '@ant-design/icons';
import { ProFormCaptcha, ProFormCheckbox, ProFormText, LoginForm } from '@ant-design/pro-components';
import Footer from '@/components/Footer';
import { retrieveCaptcha } from '@/services/user';
import type { UserConnectedProps } from '@/models/user';
import styles from './index.less';
import logo from '@/assets/logo.svg'
import loginBg from '@/assets/login-bg.png'
const Index: FC<UserConnectedProps> = (props) => {
const [type, setType] = useState<string>('account');
const { user, dispatch } = props;
const handleSubmit = async (values: API.LoginParams) => {
dispatch?.({
type: 'user/login',
payload: values,
});
};
return (
<div className={styles.container}>
<div className={styles.lang}>
<Link className={styles.link} to="/">
<img alt="logo" className={styles.logo} src={logo} />
<span className={styles.headtitle}>驿</span>
</Link>
</div>
<div className={styles.content}>
<Row gutter={16} align="middle" style={{ margin: 0, height: '100%' }}>
<Col xl={12} lg={16} md={12}>
<div className={styles.top}>
<div className={styles.header}>
<div className={styles.title}>Saas平台</div>
<div className={styles.desc}></div>
</div>
<img src={loginBg} className={styles.loginbg} alt="" />
</div>
</Col>
<Col xl={12} lg={8} md={12}>
<div className={styles.main}>
<div className={styles.title}></div>
<LoginForm
className={styles.form}
initialValues={{
autoLogin: true,
}}
submitter={{
searchConfig: {
submitText: '登录',
},
submitButtonProps: {
size: 'large',
// danger: true,
style: {
width: '100%',
// background: '#E2364B',
marginTop: 0
},
},
}}
onFinish={(values) => {
handleSubmit({
...values,
})
// LoginIP: baseInfo?.ip ? baseInfo?.ip : '',
// LoginPlace: `${baseInfo?.prov ? baseInfo?.prov : ''}${baseInfo?.prov && baseInfo?.city ? '-' : ''}${baseInfo?.city ? baseInfo?.city : ''}`,
// BrowserVersion: browser || '',
// OperatingSystem: systemInfo || '',
return Promise.resolve();
}}
>
<ProFormText
name="UserPassport"
fieldProps={{
size: 'large',
prefix: <UserOutlined className={styles.prefixIcon} />,
}}
placeholder='账号'
rules={[
{
required: true,
message: '请输入您的账号',
},
]}
/>
<ProFormText.Password
name="UserPassWord"
fieldProps={{
size: 'large',
prefix: <LockFilled className={styles.prefixIcon} />,
}}
placeholder='密码'
rules={[
{
required: true,
message: '请输入您的密码',
},
]}
/>
</LoginForm>
<div style={{ textAlign: 'center', width: 328, margin: 'auto' }}>
<Space split>
<Link to="/user/register"></Link> | <Link to="/user/forgetPassword"><Typography.Text type="secondary" >?</Typography.Text></Link>
</Space>
</div>
<div className={styles['form-bottom']}>
<Divider className={styles.divider}></Divider>
<p>
驿使
</p>
</div>
</div>
</Col>
</Row>
</div>
<div className={styles.footer}>
<Footer />
</div>
</div>
);
};
export default connect(
({ user }: { user: UserConnectedProps['user'] }) => ({
user,
}),
)(Index);

48
src/services/user.ts Normal file
View File

@ -0,0 +1,48 @@
import request from '@/utils/request';
//登录
export const userLogin = (params: Record<string, unknown>): Promise<API.LoginResponse> => (
request.post('/api/user/login', params)
);
//获取用户信息
export const retrieveUserInfo = (): Promise<API.UserInfoResponse> => (
request.get('/api/user/info')
);
//获取用户权限
export const retrieveUserAuthority = (): Promise<API.UserAuthorityResponse> => (
request.get('/api/user/authority')
);
//获取菜单数据
export const retrieveMenuData = (): Promise<API.MenuDataResponse> => (
request.get('/api/user/menu')
);
//获取用户信息和权限以及菜单
export const retrieveUserInfoAuthorityMenu = (): Promise<API.UserInfoAuthMenuResponse> => (
Promise.all([
retrieveUserInfo(),
retrieveUserAuthority(),
retrieveMenuData(),
])
);
//获取用户权限以及菜单
export const retrieveUserAuthorityMenu = (): Promise<API.UserAuthMenuResponse> => (
Promise.all([
retrieveUserAuthority(),
retrieveMenuData(),
])
);
//登出
export const userLogout = (): Promise<API.LogoutResponse> => (
request.post('/api/user/logout')
);
//获取验证码
export const retrieveCaptcha = (params: Record<string, string>): Promise<API.CaptchaResponse> => (
request.get('/api/user/captcha', { params })
);

View File

@ -0,0 +1,34 @@
/**
*
* @description , n级菜单,
* , ,
* @param currentMenuItem , url中的pathname不在菜单中, undefined
* @param indexAllMenuItemById id索引菜单项的映射
* @returns API.MenuItem[] | []
*/
const handleGetCurrentLocation = (
currentMenuItem: API.MenuItem | undefined,
indexAllMenuItemById: IndexAllMenuItemByKey<'id'>,
): API.MenuItem[] | [] => {
let res: API.MenuItem[] = [];
if (!currentMenuItem) return res;
res.push({
id: currentMenuItem.id,
key: currentMenuItem.key,
path: currentMenuItem.path,
label: currentMenuItem.label,
redirect: currentMenuItem.redirect,
});
if (currentMenuItem.pid) {
res = [
...res,
...handleGetCurrentLocation(indexAllMenuItemById[currentMenuItem.pid], indexAllMenuItemById),
];
}
return res.reverse();
};
export default handleGetCurrentLocation;

View File

@ -0,0 +1,32 @@
/**
* key索引每一条数据的映射
* @param data
* @param key key
* @returns key索引每一条数据的映射
*/
const handleGetEachDatumFromNestedDataByKey = <T extends { children?: T[] }>(
data: T[],
key: keyof T, //确定key的类型为T的键名
): Record<string, T> => {
//显式声明byKey的类型
let byKey: Record<string, T> = {};
data.forEach((datum) => {
//添加类型检查, 避免undefined作为对象的键名
if (typeof datum[key] !== 'undefined') {
//强制转换为字符串类型, 避免非字符串类型作为对象的键名
byKey[String(datum[key])] = datum;
}
if (datum.children) {
byKey = {
...byKey,
...handleGetEachDatumFromNestedDataByKey(datum.children, key),
};
}
});
return byKey;
};
export default handleGetEachDatumFromNestedDataByKey;

View File

@ -0,0 +1,25 @@
/**
* path索引菜单项的映射
* @param menu
* @returns path索引菜单项的映射
*/
const handleGetIndexValidMenuItemByPath = (
menu: API.MenuData,
): IndexValidMenuItemByPath => {
let byPath: IndexValidMenuItemByPath = {};
menu.forEach((item: API.MenuItem) => {
if (!item.children) {
byPath[item.path] = item;
} else {
byPath = {
...byPath,
...handleGetIndexValidMenuItemByPath(item.children),
};
}
});
return byPath;
};
export default handleGetIndexValidMenuItemByPath;

View File

@ -0,0 +1,18 @@
/**
* keys的方法
* @param menu
* @returns key数组
*/
const handleGetRootSubmenuKeys = (menu: API.MenuData): React.Key[] => {
const keys: React.Key[] = [];
menu.forEach((item: API.MenuItem) => {
if (item.children) {
keys.push(item.key);
}
});
return keys;
};
export default handleGetRootSubmenuKeys;

View File

@ -0,0 +1,32 @@
/**
*
* @param data
* @param datumCb
* @returns
*/
const handleRecursiveNestedData = (
data: API.MenuItem[],
datumCb: (datum: API.MenuItem) => API.MenuItem,
): API.MenuItem[] => {
const res = [] as API.MenuItem[];
for (const datum of data) {
if (datum.hideInMenu) {
continue;
}
if (datum.children) {
res.push({
...datum,
children: handleRecursiveNestedData(datum.children, datumCb),
});
} else {
res.push(datumCb(datum));
}
}
return res;
};
export default handleRecursiveNestedData;

View File

@ -0,0 +1,69 @@
import { history } from 'umi';
/**
*
* @description , url上有redirect参数则跳转到redirect参数, redirect的有效性,
* ; , , ,
* , , , Menu中可展开的形式,
* , umi处理404的情况
* @param isLoginPage
* @param indexAllMenuItemByPath IndexAllMenuItemByKey<'path'>
* @param indexValidMenuItemByPath IndexValidMenuItemByPath
* @returns Promise<boolean>
*/
const handleRedirect = (
isLoginPage: boolean,
indexAllMenuItemByPath: IndexAllMenuItemByKey<'path'>,
indexValidMenuItemByPath: IndexValidMenuItemByPath,
): Promise<boolean> => (
new Promise((resolve) => {
let routePath = Object.keys(indexValidMenuItemByPath)[0];
if (isLoginPage) {
const queryString = window.location.search;
if (queryString) {
const matchedRes = queryString.match(/redirect=(.*)/);
if (matchedRes) {
//还要考虑redirect参数是否有效
const decodeRedirect = decodeURIComponent(matchedRes[1]);
//有效: 跳转
if (indexValidMenuItemByPath[decodeRedirect]) {
routePath = decodeRedirect;
} else if (indexAllMenuItemByPath[decodeRedirect]) {
//无效
//有子路由: 跳子路由
routePath = indexAllMenuItemByPath[decodeRedirect].redirect;
} else {
//无子路由: 还是要跳, 此时就是交由umi处理404的情况了
routePath = decodeRedirect;
}
}
}
} else {
const {
location: { search, pathname },
} = window;
//考虑url上有查询字符串参数的情况
//有效
if (indexValidMenuItemByPath[pathname]) {
routePath = `${pathname}${search}`;
} else if (indexAllMenuItemByPath[pathname]) {
//无效
//有子路由: 跳子路由
routePath = `${indexAllMenuItemByPath[pathname].redirect}${search}`;
} else {
//无子路由: 也跳, 此时交由umi处理404的情况
routePath = `${pathname}${search}`;
}
}
history.push(routePath);
return resolve(true);
})
);
export default handleRedirect;

73
src/utils/request.ts Normal file
View File

@ -0,0 +1,73 @@
import axios from 'axios';
import { getDvaApp } from 'umi';
import { notification } from 'antd';
import type { AxiosRequestHeaders } from 'axios/index';
const { UMI_APP_BASEURL } = process.env;
const instance = axios.create({ baseURL: 'https://api.eshangtech.com/EShangApiMain' });
instance.interceptors.request.use(
(config) => {
config.headers = {
...config.headers,
Authorization: localStorage.getItem('Authorization') || '',
} as AxiosRequestHeaders;
return config;
},
(error) => Promise.reject(error),
);
instance.interceptors.response.use(
//状态码为2xx的时候执行
(response) => {
const { data } = response;
const { code } = data;
if (code) {
notification.error({
message: data.message,
});
}
return data;
},
//状态码不为2xx的时候执行
(error) => {
const { response } = error;
const { status } = response;
enum CodeMessage {
'发出的请求有错误,服务器没有进行新建或修改数据的操作。' = 400,
'用户未登录。' = 401,
'用户得到授权,但是访问是被禁止的。' = 403,
'发出的请求针对的是不存在的记录,服务器没有进行操作。' = 404,
'请求的格式不可得。' = 406,
'请求的资源被永久删除,且不会再得到的。' = 410,
'当创建一个对象时,发生一个验证错误。' = 422,
'服务器发生错误,请检查服务器。' = 500,
'网关错误。' = 502,
'服务不可用,服务器暂时过载或维护。' = 503,
'网关超时。' = 504
}
notification.error({
message: response.data.message || CodeMessage[status],
});
if (status === 401) {
const {
_store: { dispatch },
} = getDvaApp();
dispatch({
type: 'user/resetLoginStatus',
});
}
return Promise.reject(error);
},
);
export default instance;

39
tsconfig.json Normal file
View File

@ -0,0 +1,39 @@
{
"extends": "./src/.umi/tsconfig.json",
"compilerOptions": {
"outDir": "build/dist",
"module": "esnext",
"target": "esnext",
"lib": ["esnext", "dom"],
"sourceMap": true,
"baseUrl": ".",
"jsx": "react-jsx",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"allowJs": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"strict": true
},
"include": [
"mock/**/*",
"src/**/*",
"playwright.config.ts",
"tests/**/*",
"test/**/*",
"__test__/**/*",
"typings/**/*",
"config/**/*",
".eslintrc.js",
".stylelintrc.js",
".prettierrc.js",
"jest.config.js",
"mock/*",
"typings.d.ts"
],
"exclude": ["node_modules", "build", "dist", "scripts", "src/.umi/*", "webpack", "jest"]
}

162
typings.d.ts vendored Normal file
View File

@ -0,0 +1,162 @@
// import 'umi/typings';
/// reference path="./node_modules/@types/react/index.d.ts" />
declare module '*.less';
/**
*
* @description key是路由path, value是权限数组
*/
type PageAuthority = {
[path: string]: string[];
}
/**
*
* @description key是权限名称, value是具体的权限字符串
*/
type Authority = {
[key: string]: string;
}
/**
* path索引有效菜单项的映射(, url pathname的菜单项)
* @description key是path, value是API.MenuItem
*/
type IndexValidMenuItemByPath = {
[path: string]: API.MenuItem;
}
/**
* API.MenuItem的key索引所有菜单项的映射(, url pathname的菜单项)
* @description key是API.MenuItem的key, value是API.MenuItem
*/
type IndexAllMenuItemByKey<T extends keyof API.MenuItem> = T extends keyof API.MenuItem
? {
[key in API.MenuItem[T]]: API.MenuItem
}
: never;
declare namespace API {
/**
*
* @description data
* @description code 返回码: 0 ,
* @description message 消息: 返回的消息
*/
type ResponstBody<T> = {
data: T;
code: number;
message: string;
};
/**
*
* @description token token票据
*/
type LoginData = {
token: string;
};
/** 登录响应结果 */
type LoginResponse = ResponstBody<LoginData>;
/** 用户信息数据 */
type UserInfo = {
name?: string;
avatar?: string;
userid?: string;
email?: string;
signature?: string;
title?: string;
group?: string;
tags?: { key?: string; label?: string }[];
notifyCount?: number;
unreadCount?: number;
country?: string;
access?: string;
geographic?: {
province?: { label?: string; key?: string };
city?: { label?: string; key?: string };
};
address?: string;
phone?: string;
};
/** 用户信息响应结果 */
type UserInfoResponse = ResponstBody<UserInfo>;
/**
*
* @description authority ()
*/
type UserAuthorityData = {
authority: string[];
}
/** 用户权限响应结果 */
type UserAuthorityResponse = ResponstBody<UserAuthorityData>;
/**
*
* @description id id
* @description pid id(id)
* @description key , 使string类型代替React.Key:
* https://ant.design/components/menu-cn#itemtype, 不然会出现key类型不对导致的菜单项无法被选中的问题
* @description lable
* @description hideInMenu
* @description path ,
* children的菜单都会有这个字段, children的菜单跳转这个值, children的跳redirect,
* children表示这个菜单是可展开的, children的path只是表示它的一个位置,
* @description redirect ,
* children的菜单有, children中有可选中的菜单时, path,
* children中没可以选中的菜单, children时,
* children中的children的第一个可选中的菜单的path,
* , , mock数据中的菜单数据
* , , ,
*
* @description children
*/
type MenuItem = {
id: number;
pid?: number;
key: string;
path: string;
icon?: string;
redirect: string;
hideInMenu?: boolean;
label: React.ReactElement | string;
name: React.ReactElement | string;
children?: MenuItem[];
}
/** 菜单数据 */
type MenuData = MenuItem[];
/** 菜单数据响应结果 */
type MenuDataResponse = ResponstBody<MenuData>;
/** 用户信息权限菜单响应结果 */
type UserInfoAuthMenuResponse = (UserInfoResponse | UserAuthorityResponse | MenuDataResponse)[];
/** 用户权限菜单响应结果 */
type UserAuthMenuResponse = (UserAuthorityResponse | MenuDataResponse)[];
/** 登出响应结果 */
type LogoutResponse = ResponstBody<Record<string, never>>;
/** 登录参数 */
type LoginParams = {
username?: string;
password?: string;
autoLogin?: boolean;
type?: string;
};
/** 验证码数据 */
type CaptchaData = {
captcha: string;
}
/** 验证码响应结果 */
type CaptchaResponse = ResponstBody<CaptchaData>;
}

BIN
umi4-admin-main.zip Normal file

Binary file not shown.

11376
yarn.lock Normal file

File diff suppressed because it is too large Load Diff