├── .commitlintrc.js
├── .editorconfig
├── .github
└── workflows
│ └── release-notes.yaml
├── .gitignore
├── .prettierrc
├── .storybook
├── addons.js
└── config.js
├── .vscode
└── settings.json
├── README.md
├── config-overrides.js
├── husky.config.js
├── json-server.json
├── mock
├── auth.js
├── dashboard.js
├── db.js
└── routes.json
├── package-lock.json
├── package.json
├── public
├── css
│ └── nprogress.css
├── images
│ ├── Screen Shot 2019-03-16 at 5.45.08 PM.png
│ ├── exception_403.svg
│ ├── exception_404.svg
│ ├── exception_500.svg
│ ├── logo.svg
│ └── react-typescript-admin.png
├── index.html
└── manifest.json
├── src
├── App.css
├── App.test.tsx
├── App.tsx
├── Page.tsx
├── __snapshots__
│ └── App.test.tsx.snap
├── components
│ ├── BreadCrumbs
│ │ ├── BreadCrumbs.stories.tsx
│ │ ├── BreadCrumbs.tsx
│ │ └── __tests__
│ │ │ ├── BreadCrumbs.test.tsx
│ │ │ └── __snapshots__
│ │ │ └── BreadCrumbs.test.tsx.snap
│ ├── Content
│ │ └── Content.tsx
│ ├── ErrorBoundary
│ │ └── ErrorBoundary.tsx
│ ├── Footer
│ │ └── Footer.tsx
│ ├── Header
│ │ ├── Header.module.scss
│ │ ├── Header.tsx
│ │ └── NoticePane.tsx
│ ├── Loading
│ │ ├── Loading.module.scss
│ │ ├── Loading.stories.tsx
│ │ └── Loading.tsx
│ ├── SideBar
│ │ ├── BaseMenu.tsx
│ │ ├── Logo.tsx
│ │ ├── SideBar.module.scss
│ │ └── SideBar.tsx
│ └── index.ts
├── env.ts
├── index.css
├── index.tsx
├── logo.svg
├── models
│ ├── global.ts
│ ├── index.ts
│ └── typed.d.ts
├── pages
│ ├── Dashboard
│ │ ├── Dashboard.module.scss
│ │ ├── Dashboard.tsx
│ │ ├── index.tsx
│ │ └── models
│ │ │ └── dashboard.ts
│ ├── Exception
│ │ ├── 403.tsx
│ │ ├── 404.tsx
│ │ ├── 500.tsx
│ │ └── index.ts
│ ├── Login
│ │ ├── Login.module.scss
│ │ └── Login.tsx
│ └── index.ts
├── react-app-env.d.ts
├── routes
│ ├── AppRoutes.tsx
│ ├── RouteWithSubRoutes.tsx
│ ├── config.ts
│ ├── index.ts
│ └── routes.ts
├── services
│ ├── ApiConfig.ts
│ ├── AxiosInstance.ts
│ ├── DashboardService.ts
│ └── GlobalService.ts
├── setupTests.ts
├── styles
│ ├── _global.scss
│ └── animate.css
├── typings
│ └── types.d.ts
└── utils
│ ├── __test__
│ └── utils.test.ts
│ ├── constant.ts
│ ├── errorHandle.ts
│ ├── regTool.ts
│ └── utils.ts
├── tsconfig.json
├── tslint.json
└── yarn.lock
/.commitlintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = { extends: ['@commitlint/config-conventional'] }
2 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 | max_line_length = 120
12 |
--------------------------------------------------------------------------------
/.github/workflows/release-notes.yaml:
--------------------------------------------------------------------------------
1 | name: Release-Notes-Preview
2 |
3 | on:
4 | pull_request:
5 | branches: [master]
6 | issue_comment:
7 | types: [edited]
8 |
9 | jobs:
10 | preview:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 | - run: |
15 | git fetch --prune --unshallow --tags
16 | - uses: snyk/release-notes-preview@v1.6.1
17 | with:
18 | releaseBranch: master
19 | env:
20 | GITHUB_PR_USERNAME: ${{ github.actor }}
21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "singleQuote": true,
4 | "trailingComma": "es5",
5 | "arrowParens": "always"
6 | }
7 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-actions/register';
2 | import '@storybook/addon-links/register';
3 | import '@storybook/addon-knobs/register';
4 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react';
2 |
3 | import 'antd/dist/antd.css';
4 | import '../src/index.css';
5 |
6 | // automatically import all files ending in *.stories.tsx
7 | const req = require.context('../src', true, /\.stories\.tsx$/);
8 |
9 | function loadStories() {
10 | req.keys().forEach(req);
11 | }
12 |
13 | configure(loadStories, module);
14 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.tabSize": 2
4 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Typescript Admin 开发说明文档
2 |
3 | 该项目由 [Create React App](https://github.com/facebook/create-react-app) 提供技术支持
4 |
5 | 
6 | 
7 | 
8 |
9 | ## 🔨 运行环境
10 |
11 | * node >= 8.9.0
12 | * typescript >= 3.0
13 | * yarn >= 1.14.0 or npm >= 6.7.0
14 | * git >= 2.10.1
15 |
16 | ## 🔧 开发环境
17 |
18 | * [VS Code](https://code.visualstudio.com/)
19 | * [Chrome](https://www.google.com/chrome/)
20 | * [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en-US)
21 | * [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en-US)
22 | * [Node](https://nodejs.org/en/)
23 | * [Typescript](https://github.com/Microsoft/TypeScript)
24 | * [Mac命令行工具](https://zhuanlan.zhihu.com/p/53380250)
25 |
26 | ## ✨ VSCode插件推荐
27 |
28 | * [GitLens](https://gitlens.amod.io)
29 | * [Auto Close Tag](https://marketplace.visualstudio.com/items?itemName=formulahendry.auto-close-tag)
30 | * [Auto Rename Tag](https://marketplace.visualstudio.com/items?itemName=formulahendry.auto-rename-tag)
31 | * [ES7 React/Redux/GraphQL/React-Native snippets](https://marketplace.visualstudio.com/items?itemName=dsznajder.es7-react-js-snippets)
32 | * [TSLint](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-typescript-tslint-plugin)
33 | * [Path Autocomplete](https://marketplace.visualstudio.com/items?itemName=ionutvmi.path-autocomplete)
34 | * [Bracket Pair Colorizer](https://marketplace.visualstudio.com/items?itemName=CoenraadS.bracket-pair-colorizer)
35 | * [TODO Highlight](https://marketplace.visualstudio.com/items?itemName=wayou.vscode-todo-highlight)
36 | * [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker)
37 |
38 | ## 👣 可运行的命令
39 |
40 | 在该项目目录下, 你可以运行以下命令:
41 |
42 | `yarn start` or `npm start`
43 |
44 | 以开发模式运行该项目
45 | 然后打开Chrome浏览器访问[http://localhost:3000](http://localhost:3000).
46 |
47 | `yarn mock` or `npm run mock`
48 |
49 | 运行mock模拟数据服务, 启动后可以用Chrome浏览器访问[http://localhost:3031](http://localhost:3031)
50 | 查看模拟数据服务是否启动
51 | 相关详情请阅读[json-server](https://github.com/typicode/json-server), [faker](https://github.com/Marak/faker.js)
52 |
53 | `yarn test` or `npm test`
54 |
55 | 以监控模式运行Jest测试用例,编写测试的时候可以用此命令
56 | 相关详情请阅读[running tests](https://facebook.github.io/create-react-app/docs/running-tests)
57 |
58 | `yarn jest` or `npm jest`
59 |
60 | 以普通模式运行Jest测试用例
61 |
62 | `yarn build` or `npm run build`
63 |
64 | 构建生产资源到 `build` 目录, 此时环境变量为`production`
65 | 相关详情请阅读 [deployment](https://facebook.github.io/create-react-app/docs/deployment)
66 |
67 | `yarn check` or `npm run check`
68 |
69 | TSLint 语法检查,检查Typescript文件是否符合 tslint.json 配置规范
70 | 相关详情请阅读 [TSLint Rules](https://github.com/palantir/tslint/tree/master/test/rules)
71 |
72 | `yarn tslint` or `npm run tslint`
73 |
74 | 自动修复不符合tslint.json规范的代码,遇到`git commit`提交报tslint错误时,可以运行此命令修复
75 |
76 | ## 🏯项目架构说明
77 |
78 | ### 项目目录结构
79 |
80 | ``` Typescript
81 | .
82 | ├── mock/ # 模拟数据服务
83 | │ ├── db.js # 模拟数据DB
84 | │ └── routes.json # 模拟数据API路由配置
85 | │ └── ...
86 | ├── public/ # 静态资源文件(包括css, images, fonts, index.html等)
87 | │ └── ...
88 | ├── src/
89 | │ ├── components/ # 公用React组件
90 | │ │ └── ...
91 | │ ├── models/ # Dva数据Store层
92 | │ │ └── ...
93 | │ ├── pages/ # 页面模块
94 | │ │ └── ...
95 | │ ├── routes/ # App页面路由配置
96 | │ │ └── ...
97 | │ ├── services/ # API请求服务
98 | │ │ └── ...
99 | │ ├── style/ # 通用CSS样式
100 | │ │ └── ...
101 | │ ├── utils/ # 通用工具模块
102 | │ │ └── ...
103 | │ ├── App.css # App 页面CSS样式
104 | │ ├── App.tsx # App 全局页面
105 | │ ├── App.test.tsx # App 页面Jest测试用例
106 | │ ├── ent.ts # App 环境变量配置
107 | │ ├── index.css # 全局CSS样式
108 | │ ├── index.tsx # React入口文件
109 | │ ├── logo.svg # App logo
110 | │ ├── Page.tsx # 全局页面路由
111 | │ │
112 | ├── build/ # 生成环境静态资源文件
113 | ├── .gitignore # Git ignore 配置(禁止随意篡改配置!!!)
114 | ├── .editorconfig # 编辑器配置(禁止随意篡改配置!!!)
115 | ├── config-overrides.js # Webpack 默认配置覆盖操作
116 | ├── tsconfig.json # Typescript规则配置(禁止随意篡改规则)
117 | ├── tslint.json # tslint规则配置(禁止随意篡改规则)
118 | └── package.json # 构建脚本和依赖配置(禁止随意篡改配置)
119 | └── yarn.lock # Yarn文件
120 |
121 | ```
122 |
123 | ### 技术栈
124 |
125 | [`React`](https://github.com/facebook/react) [`Create React App`](https://facebook.github.io/create-react-app/docs/getting-started) [`Typescript`](https://github.com/Microsoft/TypeScript) [`React Router`](https://github.com/ReactTraining/react-router) [`Redux`](https://github.com/reduxjs/redux) [`Dva`](https://github.com/dvajs/dva)
126 | [`Ant Design`](https://github.com/ant-design/ant-design) [`Jest`](https://github.com/facebook/jest) [`ECharts`](https://github.com/apache/incubator-echarts) [`faker.js`](https://github.com/Marak/faker.js) [`React Hot Loader`](https://github.com/gaearon/react-hot-loader) [`React Loadable`](https://github.com/jamiebuilds/react-loadable)
127 | [`Webpack`](https://github.com/webpack/webpack) [`Babel`](https://github.com/babel/babel) [`enzyme`](https://github.com/airbnb/enzyme) [`json-server`](https://github.com/typicode/json-server)
128 |
129 | ### React Typescript编写规范
130 |
131 | [react-redux-typescript-guide](https://github.com/piotrwitek/react-redux-typescript-guide)
132 |
133 | ### 引入资源说明
134 |
135 | 1. 图片资源需放入`public/images/`下, 在组件和CSS中引入图片资源时, 需用[create react app 环境变量](https://facebook.github.io/create-react-app/docs/using-the-public-folder) `process.env.PUBLIC_URL` 来引入, 避免大量的图片资源在TS中import
136 |
137 | ### CSS样式说明
138 |
139 | 1. CSS样式推荐采用的方式来写,Create React App原生支持
140 |
141 | * 可以避免全局样式和局部样式的冲突
142 | * JS和CSS可以共享变量,支持局部作用域和全局作用域
143 | * 可以与Sass或Less灵活搭配使用
144 |
145 | 2. 编写CSS样式时需结构清晰, 避免嵌套过深, 特别是在写Sass或Less的时候, 尤其容易层级嵌套过深,层级最好不大于4级, 尽量使用`AntDesign`原生的布局组件和样式. 样式需适当的空行, 例如:
146 |
147 | ```CSS
148 | .exampleWrapper {
149 | background-color: #f2f2f2;
150 |
151 | .exampleChild1 {
152 | color: #666666;
153 | }
154 |
155 | .exampleChild2 {
156 | font-size: 16px;
157 | }
158 | }
159 | ```
160 |
161 | 3. 每一个组件或页面需要有独立的CSS文件, 常用页面或组件样式可以写成全局的CSS样式模块, 对于样式相对较少的组件或页面, 可以以JS对象的形式编写在组件内
162 | 4. CSS的类名和ID命名需语义清晰, 避免含糊不清的命名, 类名的英文单词和简写需符合常用英文语法习惯, 禁止自造英文单词和不那么规范的简写形式
163 | 5. 禁止在css中使用*选择器和`!important`
164 | * *选择器因为需要遍历页面的所有元素,所以编译的时候会非常消耗时间
165 | * `!important`会覆盖所以样式, 破坏CSS样式的权重关系, 导致样式难以维护
166 |
167 | 更多CSS规范请求阅读 [CSS编码规范](https://github.com/fex-team/styleguide/blob/master/css.md)
168 |
169 | #### CSS相关学习资源
170 |
171 | [CSS Modules 详解及 React 中实践](https://github.com/camsong/blog/issues/5)
172 | [Sass中文文档](http://sass.bootcss.com/docs/sass-reference/)
173 |
174 | ## 🌿Git分支管理说明
175 |
176 | ```Git
177 | git-flow 是目前流传最广的 Git 分支管理实践。git-flow 围绕的核心概念是版本发布(release)
178 | git-flow 流程中包含 5 类分支,分别是 master、develop、新功能分支(feature)、发布分支(release)和 hotfix
179 | ```
180 |
181 | ### 相关分支说明
182 |
183 | | 分支类型 | 命名规范 | 创建自 | 合并到 | 说明 |
184 | | ------ | ------ | ------ | ------ | ------ |
185 | | feature | feature/* | develop | develop | 新功能 |
186 | | release | release/* | develop | develop 和 master | 新版本发布 |
187 | | hotfix | hotfix/* | master 或 release | release 和 master | production 或 release 中bug修复 |
188 |
189 | 1. `master` 是部署到生产环境中的代码, 一般不允许随意合并其他分支到此分支上
190 | 2. `develop`为开发分支, 是一个进行代码集成的分支, 该分支会及时合并最新代码, 新需求的开发都从此分支上创建
191 | 3. `feature/my-awesome-feature` 为新功能分支, 开发新需求时, 需从`develop`分支创建
192 | 4. `hotfix/fix-bug` 为热修复bug分支, 主要是针对`release`或`master`分支测试出现的bug进行修复
193 | 5. `release/0.0.1`分支为部署到持续集成服务器上进行测试的分支, 是一个相对稳定的可供测试的分支
194 |
195 | ### `feature`分支创建流程
196 |
197 | 1. 从 `develop`分支创建一个新的`feature`分支, 如`feature/my-awesome-feature`
198 | 2. 在该`feature`分支上进行开发相关需求,完成后提交代码并 push 到远端仓库
199 | 3. 当代码完成之后,提`pull request`, 代码审核通过后合并到`develop`分支, 之后可删除当前`feature`分支
200 |
201 | ### `hotfix`分支创建流程
202 |
203 | 1. 从`develop`分支创建一个新的`release`分支,如 `release/0.0.1`
204 | 2. 把`release`分支部署到持续集成服务器上, 并交给相关测试人员进行测试
205 | 3. 对于测试中发现的问题,直接在`release`分支上创建`hotfix/fix-bug`分支, 进行相关的bug修复
206 | 4. 合并`hotfix/fix-bug`分支到`release`分支, 再次部署并交给测试人员进行测试
207 |
208 | ### 代码提交说明
209 |
210 | [Commit message 和 Change log 编写指南](http://www.ruanyifeng.com/blog/2016/01/commit_message_change_log.html)
211 |
212 | ## Jest测试
213 |
214 | Jest 测试框架官方网站[Jest](https://jestjs.io/)
215 |
216 | ## 🔭 学习更多
217 |
218 | 想获取更多信息,可以访问[Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
219 |
220 | 想学习更多React内容,可访问React官方网站 [React documentation](https://reactjs.org/).
221 |
--------------------------------------------------------------------------------
/config-overrides.js:
--------------------------------------------------------------------------------
1 | /* config-overrides.js */
2 | const { override, fixBabelImports } = require('customize-cra');
3 | const reactHotLoader = require('react-app-rewire-hot-loader');
4 |
5 | // Webpack 默认配置覆盖操作,慎改!!!
6 | module.exports = override(
7 | fixBabelImports('import', {
8 | libraryName: 'antd',
9 | libraryDirectory: 'es',
10 | style: 'css',
11 | }),
12 | // React hot loader
13 | reactHotLoader,
14 | );
15 |
--------------------------------------------------------------------------------
/husky.config.js:
--------------------------------------------------------------------------------
1 | const isWin = process.platform === 'win32';
2 | const commands = ['lint-staged', isWin ? 'npm run jest:win' : 'npm run jest'];
3 |
4 | const tasks = (arr) => arr.join(' && ');
5 |
6 | module.exports = {
7 | hooks: {
8 | 'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS',
9 | 'pre-commit': tasks(commands),
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/json-server.json:
--------------------------------------------------------------------------------
1 | {
2 | "port": "3031",
3 | "watch": true,
4 | "delay": "2000",
5 | "routes": "mock/routes.json"
6 | }
7 |
--------------------------------------------------------------------------------
/mock/auth.js:
--------------------------------------------------------------------------------
1 | const faker = require('faker');
2 | const Random = faker.random;
3 |
4 | module.exports = function () {
5 | return {
6 | isAuthenticated: true,
7 | };
8 | }
9 |
--------------------------------------------------------------------------------
/mock/dashboard.js:
--------------------------------------------------------------------------------
1 | const faker = require('faker');
2 | const Random = faker.random;
3 |
4 | module.exports = function() {
5 | let dashboard = [];
6 |
7 | for (let i = 0; i < 10; i++) {
8 | dashboard.push({
9 | id: Random.uuid(),
10 | title: Random.word(4, 8),
11 | desc: Random.words(30, 50),
12 | tag: Random.word(2, 5),
13 | views: Random.number(),
14 | images: Random.image(),
15 | });
16 | }
17 |
18 | return dashboard;
19 | }
20 |
--------------------------------------------------------------------------------
/mock/db.js:
--------------------------------------------------------------------------------
1 | const auth = require('./auth');
2 | const dashboard = require('./dashboard');
3 |
4 | module.exports = function () {
5 | return {
6 | auth: auth(),
7 | dashboard: dashboard(),
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/mock/routes.json:
--------------------------------------------------------------------------------
1 | {
2 | "/api/auth": "/auth",
3 | "/api/dashboard/comments": "/dashboard"
4 | }
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-typescript-admin",
3 | "description": "A starter template for TypeScript and React with AntDesign and Dva",
4 | "version": "1.0.1",
5 | "private": true,
6 | "dependencies": {
7 | "@ant-design/compatible": "^1.0.8",
8 | "@commitlint/cli": "^12.0.1",
9 | "@commitlint/config-conventional": "^12.0.1",
10 | "@storybook/addon-knobs": "^6.1.21",
11 | "@testing-library/react": "^11.2.5",
12 | "@types/classnames": "^2.2.11",
13 | "@types/dva": "^1.1.0",
14 | "@types/enzyme": "^3.10.8",
15 | "@types/enzyme-adapter-react-16": "^1.0.6",
16 | "@types/jest": "^26.0.20",
17 | "@types/node": "^14.14.34",
18 | "@types/nprogress": "^0.2.0",
19 | "@types/qs": "^6.9.6",
20 | "@types/react": "^17.0.3",
21 | "@types/react-document-title": "^2.0.4",
22 | "@types/react-dom": "^17.0.2",
23 | "@types/react-infinite-scroller": "^1.2.1",
24 | "@types/react-loadable": "^5.5.4",
25 | "@types/react-motion": "^0.0.29",
26 | "@types/redux-logger": "^3.0.8",
27 | "@types/storybook__addon-knobs": "^5.0.4",
28 | "@types/storybook__react": "^5.2.1",
29 | "antd": "^4.14.0",
30 | "axios": "^0.21.1",
31 | "classnames": "^2.2.6",
32 | "commitizen": "^4.2.3",
33 | "date-fns": "^2.19.0",
34 | "dva": "^2.4.1",
35 | "dva-loading": "^3.0.22",
36 | "enzyme": "^3.11.0",
37 | "enzyme-adapter-react-16": "^1.15.6",
38 | "enzyme-to-json": "^3.6.1",
39 | "faker": "^5.4.0",
40 | "history": "^5.0.0",
41 | "jest-fetch-mock": "^3.0.3",
42 | "lodash": "^4.17.21",
43 | "lodash-decorators": "^6.0.1",
44 | "node-sass": "^5.0.0",
45 | "nprogress": "^0.2.0",
46 | "path-to-regexp": "^6.2.0",
47 | "qs": "^6.9.6",
48 | "query-string": "^6.14.1",
49 | "react": "^17.0.1",
50 | "react-document-title": "^2.0.3",
51 | "react-dom": "^17.0.1",
52 | "react-hot-loader": "^4.13.0",
53 | "react-infinite-scroller": "^1.2.4",
54 | "react-loadable": "^5.5.0",
55 | "react-motion": "^0.5.2",
56 | "react-test-renderer": "^17.0.1",
57 | "redux-logger": "^3.0.6",
58 | "source-map-explorer": "^2.5.2",
59 | "typescript": "^4.2.3"
60 | },
61 | "scripts": {
62 | "analyze": "source-map-explorer build/static/js/main.*",
63 | "check": "tslint --project ./tsconfig.json",
64 | "start": "react-app-rewired start",
65 | "build": "react-app-rewired build",
66 | "staging": "cross-env REACT_APP_BUILD=staging react-app-rewired start",
67 | "test": "react-app-rewired test",
68 | "jest": "CI=true yarn test",
69 | "jest:win": "set CI=true&&yarn test",
70 | "coverage": "npm test -- --coverage",
71 | "tslint": "tslint --fix 'src/**/*.(ts|tsx)'",
72 | "mock": "json-server mock/db.js",
73 | "storybook": "start-storybook -p 9009 -s public",
74 | "build-storybook": "build-storybook -s public"
75 | },
76 | "lint-staged": {
77 | "*.(ts|tsx)": [
78 | "npm run check"
79 | ]
80 | },
81 | "eslintConfig": {
82 | "extends": "react-app"
83 | },
84 | "browserslist": [
85 | ">0.2%",
86 | "not dead",
87 | "not ie <= 11",
88 | "not op_mini all"
89 | ],
90 | "devDependencies": {
91 | "@storybook/addon-actions": "^6.1.21",
92 | "@storybook/addon-info": "^5.3.21",
93 | "@storybook/addon-links": "^6.1.21",
94 | "@storybook/addons": "^6.1.21",
95 | "@storybook/react": "^6.1.21",
96 | "babel-plugin-import": "^1.13.3",
97 | "cross-env": "^7.0.3",
98 | "customize-cra": "^1.0.0",
99 | "husky": "^5.1.3",
100 | "lint-staged": "^10.5.4",
101 | "react-app-rewire-hot-loader": "^2.0.1",
102 | "react-app-rewired": "^2.1.8",
103 | "react-scripts": "^4.0.3",
104 | "redux-mock-store": "^1.5.4",
105 | "tslint": "^6.1.3",
106 | "tslint-eslint-rules": "^5.4.0",
107 | "tslint-lines-between-class-members": "^1.3.4",
108 | "tslint-react": "^5.0.0",
109 | "tslint-react-hooks": "^2.2.2",
110 | "webpack-cli": "^4.5.0"
111 | },
112 | "engines": {
113 | "node": ">= 8.9.0"
114 | },
115 | "config": {
116 | "commitizen": {
117 | "path": "./node_modules/cz-conventional-changelog"
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/public/css/nprogress.css:
--------------------------------------------------------------------------------
1 | /* Make clicks pass-through */
2 | #nprogress {
3 | pointer-events: none;
4 | }
5 |
6 | #nprogress .bar {
7 | background: #29d;
8 |
9 | position: fixed;
10 | z-index: 1031;
11 | top: 0;
12 | left: 0;
13 |
14 | width: 100%;
15 | height: 2px;
16 | }
17 |
18 | /* Fancy blur effect */
19 | #nprogress .peg {
20 | display: block;
21 | position: absolute;
22 | right: 0px;
23 | width: 100px;
24 | height: 100%;
25 | box-shadow: 0 0 10px #29d, 0 0 5px #29d;
26 | opacity: 1.0;
27 |
28 | -webkit-transform: rotate(3deg) translate(0px, -4px);
29 | -ms-transform: rotate(3deg) translate(0px, -4px);
30 | transform: rotate(3deg) translate(0px, -4px);
31 | }
32 |
33 | /* Remove these to get rid of the spinner */
34 | #nprogress .spinner {
35 | display: block;
36 | position: fixed;
37 | z-index: 1031;
38 | top: 15px;
39 | right: 15px;
40 | }
41 |
42 | #nprogress .spinner-icon {
43 | width: 18px;
44 | height: 18px;
45 | box-sizing: border-box;
46 |
47 | border: solid 2px transparent;
48 | border-top-color: #29d;
49 | border-left-color: #29d;
50 | border-radius: 50%;
51 |
52 | -webkit-animation: nprogress-spinner 400ms linear infinite;
53 | animation: nprogress-spinner 400ms linear infinite;
54 | }
55 |
56 | .nprogress-custom-parent {
57 | overflow: hidden;
58 | position: relative;
59 | }
60 |
61 | .nprogress-custom-parent #nprogress .spinner,
62 | .nprogress-custom-parent #nprogress .bar {
63 | position: absolute;
64 | }
65 |
66 | @-webkit-keyframes nprogress-spinner {
67 | 0% { -webkit-transform: rotate(0deg); }
68 | 100% { -webkit-transform: rotate(360deg); }
69 | }
70 | @keyframes nprogress-spinner {
71 | 0% { transform: rotate(0deg); }
72 | 100% { transform: rotate(360deg); }
73 | }
74 |
75 |
--------------------------------------------------------------------------------
/public/images/Screen Shot 2019-03-16 at 5.45.08 PM.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chachaxw/react-typescript-admin/4a8381932f232a79cb19f26341f67e0e24e3f23e/public/images/Screen Shot 2019-03-16 at 5.45.08 PM.png
--------------------------------------------------------------------------------
/public/images/exception_403.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/images/react-typescript-admin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chachaxw/react-typescript-admin/4a8381932f232a79cb19f26341f67e0e24e3f23e/public/images/react-typescript-admin.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
15 |
16 |
17 |
26 | React Typescript Admin Starter Template
27 |
28 |
29 |
35 |
36 |
37 |
38 |
39 |
40 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | height: 100%;
3 | }
4 |
5 | .Footer {
6 | text-align: center;
7 | padding: 12px 50px;
8 | }
9 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import dva from 'dva';
2 | import { StaticRouter } from 'dva/router';
3 | import { shallow } from 'enzyme';
4 | import toJson from 'enzyme-to-json';
5 | import React from 'react';
6 |
7 | import App from './App';
8 | import { Global } from './models';
9 |
10 | let app: any;
11 | let wrapper: any;
12 |
13 | beforeAll(() => {
14 | app = dva();
15 | app.model(Global);
16 | app.router(() => ({}));
17 | app.start();
18 | wrapper = shallow(
19 |
20 |
21 |
22 | );
23 | });
24 |
25 | afterAll(() => {
26 | wrapper.unmount();
27 | });
28 |
29 | describe('App Test', () => {
30 | it('Capturing Snapshot of APP', () => {
31 | expect(toJson(wrapper)).toMatchSnapshot();
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'antd';
2 | import { connect } from 'dva';
3 | import React, { FC } from 'react';
4 |
5 | import { BreadCrumbs, Content, ErrorBoundary, Footer, Header, SideBar } from './components';
6 | import { Auth } from './models/global';
7 | import { AppRoutes } from './routes';
8 | import { appRoutes } from './routes/config';
9 | import { getFlatMenuKeys } from './utils/utils';
10 |
11 | import './App.css';
12 |
13 | const menu = appRoutes.app;
14 | const flatMenuKeys = getFlatMenuKeys(menu);
15 |
16 | interface DvaProps {
17 | auth: Auth;
18 | location: Location;
19 | collapsed: boolean;
20 | onCollapse: (collapsed: boolean) => void;
21 | }
22 |
23 | interface Props extends DvaProps {
24 | app: any;
25 | }
26 |
27 | const App: FC = (props: Props) => {
28 | const { app, auth, collapsed, location, onCollapse } = props;
29 |
30 | return (
31 |
32 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | const mapStateToProps = ({ global }: any) => {
54 | return {
55 | auth: global.auth,
56 | collapsed: global.collapsed,
57 | };
58 | };
59 |
60 | const mapDispatchToProps = (dispatch: any) => {
61 | return {
62 | onCollapse: (collapsed: boolean) => {
63 | dispatch({
64 | type: 'global/changeLayoutCollapsed',
65 | payload: collapsed,
66 | });
67 | },
68 | };
69 | };
70 |
71 | export default connect(mapStateToProps, mapDispatchToProps)(App);
72 |
--------------------------------------------------------------------------------
/src/Page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { BrowserRouter, Redirect, Route, Switch } from 'dva/router';
4 | import { Env, EnvType } from './env';
5 |
6 | import { hot } from 'react-hot-loader';
7 | import App from './App';
8 | import NoPermission from './pages/Exception/403';
9 | import NotFound from './pages/Exception/404';
10 | import ServerError from './pages/Exception/500';
11 | import Login from './pages/Login/Login';
12 |
13 | // Global pages router
14 | const Page = ({ app }: any) => (
15 |
16 |
17 | } />
18 | } />
19 |
20 | } />
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 |
29 | export default Env === EnvType.Development ? hot(module)(Page) : Page;
30 |
--------------------------------------------------------------------------------
/src/__snapshots__/App.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`App Test Capturing Snapshot of APP 1`] = `
4 |
25 |
43 |
44 | `;
45 |
--------------------------------------------------------------------------------
/src/components/BreadCrumbs/BreadCrumbs.stories.tsx:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/react';
2 | import React from 'react';
3 |
4 | import { appRoutes } from '../../routes';
5 | import BreadCrumbs from './BreadCrumbs';
6 |
7 | storiesOf('BreadCrumbs', module).add('default', () => );
8 |
--------------------------------------------------------------------------------
/src/components/BreadCrumbs/BreadCrumbs.tsx:
--------------------------------------------------------------------------------
1 | import { Breadcrumb, Typography } from 'antd';
2 | import { Link } from 'dva/router';
3 | import React, { FC } from 'react';
4 |
5 | import { RouteConfig } from '../../routes';
6 | import { getBreadcrumbNameMap, urlToList } from '../../utils/utils';
7 |
8 | const { Item } = Breadcrumb;
9 | const { Text } = Typography;
10 |
11 | // 渲染 Breadcrumb 子节点
12 | // Render the Breadcrumb child node
13 | interface DefaultItemProps {
14 | path: string;
15 | name: string;
16 | component?: string;
17 | }
18 |
19 | export const DefaultItem: FC = (props) => {
20 | const { path, component, name } = props;
21 | return component ? {name} : {name};
22 | };
23 |
24 | interface Props {
25 | url: string;
26 | menu: RouteConfig[];
27 | }
28 |
29 | export const BreadCrumbs: FC = (props) => {
30 | const { url, menu } = props;
31 | const urlList = urlToList(url);
32 | const breadCrumbsMap = getBreadcrumbNameMap(menu);
33 | const breadCrumbs = urlList.map((item: string) => breadCrumbsMap[item]).filter((item) => item && item);
34 |
35 | if (!(breadCrumbs && breadCrumbs.length)) {
36 | return null;
37 | }
38 |
39 | return (
40 |
41 | {breadCrumbs.map((item: RouteConfig) => (
42 | -
43 |
44 |
45 | ))}
46 |
47 | );
48 | };
49 |
50 | export default BreadCrumbs;
51 |
--------------------------------------------------------------------------------
/src/components/BreadCrumbs/__tests__/BreadCrumbs.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react';
2 | import { StaticRouter } from 'dva/router';
3 | import { shallow } from 'enzyme';
4 | import toJson from 'enzyme-to-json';
5 | import React from 'react';
6 |
7 | import { appRoutes, Routes } from '../../../routes';
8 | import BreadCrumbs, { DefaultItem } from '../BreadCrumbs';
9 |
10 | describe('DefaultItem test', () => {
11 | it('should display a text when without prop component', () => {
12 | const tree = shallow();
13 | const props = tree.props();
14 |
15 | expect(props.children).toEqual('Dashboard');
16 | expect(toJson(tree)).toMatchSnapshot();
17 | });
18 |
19 | it('should display a link when with prop component', () => {
20 | const tree = shallow(
21 |
22 |
23 |
24 | );
25 | const { getByText } = render(
26 |
27 |
28 |
29 | );
30 | const link = getByText('Dashboard');
31 |
32 | expect(link.nodeName).toBe('A');
33 | expect(link.href).toBe('http://localhost/app/dashboard');
34 | expect(toJson(tree)).toMatchSnapshot();
35 | });
36 | });
37 |
38 | describe('BreadCrumbs test', () => {
39 | it('should display a breadcrumbs', () => {
40 | const tree = shallow();
41 | const props = tree.props();
42 |
43 | expect(props.children.length).toEqual(1);
44 | expect(toJson(tree)).toMatchSnapshot();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/components/BreadCrumbs/__tests__/__snapshots__/BreadCrumbs.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`BreadCrumbs test should display a breadcrumbs 1`] = `
4 |
11 |
14 |
19 |
20 |
21 | `;
22 |
23 | exports[`DefaultItem test should display a link when with prop component 1`] = `
24 |
45 |
50 |
51 | `;
52 |
53 | exports[`DefaultItem test should display a text when without prop component 1`] = `
54 |
55 | Dashboard
56 |
57 | `;
58 |
--------------------------------------------------------------------------------
/src/components/Content/Content.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'antd';
2 | import React from 'react';
3 |
4 | interface InternalProps {
5 | children?: any;
6 | }
7 |
8 | export default function Content(props: InternalProps) {
9 | return (
10 | {props.children}
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { WarningOutlined } from '@ant-design/icons';
2 | import { Col, Row, Typography } from 'antd';
3 | import React, { Component, ReactNode } from 'react';
4 |
5 | const { Title } = Typography;
6 |
7 | interface InternalProps {
8 | children: ReactNode;
9 | }
10 |
11 | interface InternalState {
12 | hasError: boolean;
13 | error: Error | null;
14 | info: any;
15 | }
16 |
17 | export default class ErrorBoundary extends Component {
18 | constructor(props: InternalProps) {
19 | super(props);
20 | this.state = {
21 | hasError: false,
22 | error: null,
23 | info: null,
24 | };
25 | }
26 |
27 | public componentDidCatch(error: Error, info: any) {
28 | this.setState({
29 | hasError: true,
30 | error,
31 | info,
32 | });
33 | // TODO: Log the error to an error reporting service
34 | // ErrorReportService(error, info);
35 | }
36 |
37 | public render() {
38 | const { hasError, error, info } = this.state;
39 |
40 | if (hasError) {
41 | return (
42 |
43 |
44 |
45 | 出错了!
46 |
47 |
48 |
49 |
50 | {error && error.toString()}
51 |
52 | {info && info.componentStack}
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | return this.props.children;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'antd';
2 | import { getYear } from 'date-fns';
3 | import React, { FunctionComponent } from 'react';
4 |
5 | const Footer: FunctionComponent = (props) => {
6 | return (
7 |
8 | Copyright ©{getYear(new Date())} Created by Chacha
9 |
10 | );
11 | };
12 |
13 | export default Footer;
14 |
--------------------------------------------------------------------------------
/src/components/Header/Header.module.scss:
--------------------------------------------------------------------------------
1 | @import '../../styles/_global';
2 |
3 | .header {
4 | display: flex;
5 | padding: 0 40px 0 0;
6 | background-color: white;
7 | justify-content: space-between;
8 | box-shadow: 0 1px 5px rgba(0, 0, 0, 0.08);
9 | }
10 |
11 | .headerIcon {
12 | height: 64px;
13 | padding: 0 12px;
14 | font-size: 18px;
15 | cursor: pointer;
16 | text-align: center;
17 | box-sizing: border-box;
18 |
19 | &:hover {
20 | background-color: #f5f5f5;
21 | border-bottom: 1px solid #e8e8e8;
22 | }
23 |
24 | :global(.ant-badge) {
25 | font-size: 18px;
26 | }
27 | }
28 |
29 | .menuBtn {
30 | composes: headerIcon;
31 | width: 64px;
32 | }
33 |
34 | .headerRight {
35 | display: inline-flex;
36 | align-items: flex-end;
37 | justify-content: flex-end;
38 | }
39 |
40 | .dropdownMenu {
41 | :global(.ant-dropdown-menu-item-group-list) {
42 | list-style: none;
43 | padding: 0;
44 | min-width: 160px;
45 | }
46 |
47 | :global(.ant-dropdown-menu-item-group-title),
48 | :global(.ant-dropdown-menu-item-group-list .ant-dropdown-menu-item) {
49 | height: 40px;
50 | line-height: 30px;
51 | }
52 |
53 | :global(.ant-dropdown-menu-item-group-list .ant-dropdown-menu-item) {
54 | padding-left: 20px;
55 | }
56 | }
57 |
58 | .noticePane {
59 | width: 336px;
60 | height: 490px;
61 | overflow: hidden;
62 | border-radius: 4px;
63 | background-color: #ffffff;
64 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
65 | }
66 |
67 | .noticeTop,
68 | .noticeBottom {
69 | padding: 12px;
70 | text-align: center;
71 | background-color: #ffffff;
72 | }
73 |
74 | .noticeTop {
75 | color: #1890ff;
76 | }
77 |
78 | .noticeBottom {
79 | cursor: pointer;
80 | }
81 |
82 | .noticePaneList {
83 | min-height: 400px;
84 | max-height: 400px;
85 | overflow: auto;
86 | background-color: #f5f5f5;
87 |
88 | :global(.ant-list-item-content.ant-list-item-content-single) {
89 | padding: 0 16px;
90 | }
91 |
92 | :global(.ant-skeleton-paragraph) {
93 | padding-left: 0;
94 | margin-bottom: 0;
95 | }
96 |
97 | :global(.ant-empty) {
98 | margin: 100px auto;
99 | }
100 |
101 | :global(.ant-list-item .ant-list-item-meta) {
102 | padding-left: 20px;
103 | }
104 | }
105 |
106 | .loadMore {
107 | padding: 8px;
108 | color: #1890ff;
109 | text-align: center;
110 | }
111 |
112 | .noMore {
113 | composes: loadMore;
114 | color: rgba($color: #000000, $alpha: 0.25);
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ArrowsAltOutlined,
3 | BellOutlined,
4 | LogoutOutlined,
5 | MenuFoldOutlined,
6 | MenuUnfoldOutlined,
7 | ProfileOutlined,
8 | SearchOutlined,
9 | SettingOutlined,
10 | UserOutlined,
11 | } from '@ant-design/icons';
12 | import { Avatar, Badge, Dropdown, Layout, Menu } from 'antd';
13 | import { connect } from 'dva';
14 | import Debounce from 'lodash-decorators/debounce';
15 | import React, { PureComponent } from 'react';
16 |
17 | import { UserInfo } from '../../models/global';
18 | import styles from './Header.module.scss';
19 | import NoticePane from './NoticePane';
20 |
21 | const { ItemGroup } = Menu;
22 |
23 | interface DvaProps {
24 | logout: () => void;
25 | loadMoreNotices: (params?: any) => void;
26 | }
27 |
28 | interface InternalProps extends DvaProps {
29 | notices: any[];
30 | userInfo: UserInfo;
31 | collapsed: boolean;
32 | hasMore: boolean;
33 | fetchingNotices: boolean;
34 | onCollapse: (collapsed: boolean) => void;
35 | }
36 |
37 | interface InternalState {
38 | showNoticePane: boolean;
39 | }
40 |
41 | export class Header extends PureComponent {
42 | private isFullScreen = false;
43 |
44 | private constructor(props: InternalProps) {
45 | super(props);
46 | this.state = {
47 | showNoticePane: false,
48 | };
49 | }
50 |
51 | public componentWillUnmount() {
52 | // 移除 window.resize 事件监听
53 | document.removeEventListener('resize', this.triggerResizeEvent.bind(this));
54 | }
55 |
56 | @Debounce(500)
57 | public triggerResizeEvent() {
58 | const event = document.createEvent('HTMLEvents');
59 | event.initEvent('resize', true, false);
60 | window.dispatchEvent(event);
61 | }
62 |
63 | public setFullScreen() {
64 | const body = document.getElementsByTagName('body')[0];
65 | if (!this.isFullScreen) {
66 | this.isFullScreen = true;
67 | body.requestFullscreen();
68 | } else {
69 | this.isFullScreen = false;
70 | document.exitFullscreen();
71 | }
72 | }
73 |
74 | public signOut() {
75 | this.props.logout();
76 | }
77 |
78 | public onVisibleChange(visible: boolean) {
79 | this.setState({ showNoticePane: visible });
80 | }
81 |
82 | public renderDropdownMenu(props: UserInfo) {
83 | return (
84 |
103 | );
104 | }
105 |
106 | public render() {
107 | const { showNoticePane } = this.state;
108 | const { collapsed, hasMore, notices, fetchingNotices, loadMoreNotices, onCollapse, userInfo } = this.props;
109 |
110 | return (
111 |
112 | onCollapse(!collapsed)}>
113 | {collapsed ? : }
114 |
115 |
116 |
117 |
118 |
119 |
this.setFullScreen()}>
120 |
121 |
122 |
this.onVisibleChange(visible)}
125 | overlay={
126 | loadMoreNotices(params)}
131 | closeNoticePane={(visible: boolean) => this.onVisibleChange(visible)}
132 | />
133 | }
134 | >
135 |
136 |
137 |
138 |
139 |
140 |
141 |
this.renderDropdownMenu(userInfo)}>
142 |
143 |
144 | C
145 |
146 |
147 |
148 |
149 |
150 | );
151 | }
152 | }
153 |
154 | const mapStateToProps = ({ global, loading }: any) => {
155 | return {
156 | notices: global.notices,
157 | hasMore: global.hasMore,
158 | userInfo: global.userInfo,
159 | collapsed: global.collapsed,
160 | fetchingNotices: loading.effects['global/fetchNotifications'],
161 | };
162 | };
163 |
164 | const mapDispatchToProps = (dispatch: any) => {
165 | return {
166 | onCollapse: (collapsed: boolean) => {
167 | dispatch({ type: 'global/changeLayoutCollapsed', payload: collapsed });
168 | },
169 |
170 | logout: () => {
171 | dispatch({ type: 'global/logout' });
172 | },
173 |
174 | loadMoreNotices: (params?: any) => {
175 | dispatch({ type: 'global/fetchNotifications', payload: params });
176 | },
177 | };
178 | };
179 |
180 | export default connect(mapStateToProps, mapDispatchToProps)(Header);
181 |
--------------------------------------------------------------------------------
/src/components/Header/NoticePane.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, Empty, List, Skeleton } from 'antd';
2 | import { format } from 'date-fns';
3 | import React, { PureComponent } from 'react';
4 | import InfiniteScroll from 'react-infinite-scroller';
5 | import Loading from '../Loading/Loading';
6 |
7 | import styles from './Header.module.scss';
8 |
9 | interface InternalProps {
10 | notices: any[];
11 | loading: boolean;
12 | hasMore: boolean;
13 | loadMoreNotices: (params: any) => void;
14 | closeNoticePane: (visible: boolean) => void;
15 | }
16 |
17 | interface InternalState {
18 | page: number;
19 | }
20 |
21 | class NoticePane extends PureComponent {
22 |
23 | constructor(props: InternalProps) {
24 | super(props);
25 | this.state = {
26 | page: 0,
27 | };
28 | }
29 |
30 | public renderItem(props: any) {
31 | return (
32 |
33 |
34 | }
36 | title={
37 | Welcome to use React Typescript Admin
38 | }
39 | description={format(props.createdTime, 'YYYY-MM-DD HH:mm:ss')}
40 | />
41 |
42 |
43 | );
44 | }
45 |
46 | public loadMore() {
47 | const { hasMore, loadMoreNotices } = this.props;
48 |
49 | if (!hasMore) {
50 | return;
51 | }
52 |
53 | const page = this.state.page + 1;
54 | loadMoreNotices({ page });
55 | }
56 |
57 | public render() {
58 | const { notices, closeNoticePane, loading, hasMore } = this.props;
59 |
60 | return (
61 |
62 |
消息中心
63 |
64 |
}
69 | loadMore={() => this.loadMore()}
70 | hasMore={!loading && hasMore}
71 | >
72 | {notices && notices.length ?
73 |
this.renderItem(item)}>
74 | {hasMore ?
75 | 加载更多
:
76 | 没有更多通知!
77 | }
78 |
:
79 | }
80 |
81 |
82 |
closeNoticePane(false)}>
83 | 关闭窗口
84 |
85 |
86 | );
87 | }
88 | }
89 |
90 | export default NoticePane;
91 |
--------------------------------------------------------------------------------
/src/components/Loading/Loading.module.scss:
--------------------------------------------------------------------------------
1 | .loading {
2 | height: 100%;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/Loading/Loading.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { storiesOf } from '@storybook/react';
4 |
5 | import Loading from './Loading';
6 |
7 | storiesOf('Loading', module)
8 | .add('default', () => );
9 |
--------------------------------------------------------------------------------
/src/components/Loading/Loading.tsx:
--------------------------------------------------------------------------------
1 | import NProgress from 'nprogress';
2 | import React, { FC, useEffect } from 'react';
3 | import { Motion, spring } from 'react-motion';
4 |
5 | import styles from './Loading.module.scss';
6 |
7 | NProgress.configure({ showSpinner: false });
8 |
9 | export const Loading: FC = (props) => {
10 | const { children } = props;
11 |
12 | useEffect(() => {
13 | NProgress.start();
14 | return () => {
15 | NProgress.done();
16 | };
17 | }, []);
18 |
19 | return (
20 |
21 | {({ isReady }: any) => {
22 | if (isReady === 1) {
23 | NProgress.done();
24 | }
25 |
26 | return (
27 |
28 | {children &&
{children}
}
29 |
30 | );
31 | }}
32 |
33 | );
34 | };
35 |
36 | export default Loading;
37 |
--------------------------------------------------------------------------------
/src/components/SideBar/BaseMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@ant-design/compatible';
2 | import { Menu } from 'antd';
3 | import { Link } from 'dva/router';
4 | import React, { PureComponent } from 'react';
5 |
6 | import { getMenuMatches, urlToList } from '../../utils/utils';
7 |
8 | const { SubMenu, Item } = Menu;
9 |
10 | export interface MenuItemProps {
11 | name: string;
12 | icon?: string;
13 | path: string;
14 | children?: any;
15 | hideInMenu?: boolean;
16 | }
17 |
18 | interface InternalProps {
19 | location: Location;
20 | menu: MenuItemProps[];
21 | collapsed: boolean;
22 | className?: string;
23 | openKeys: string[];
24 | flatMenuKeys: string[];
25 | theme?: 'dark' | 'light';
26 | mode?: 'inline' | 'vertical' | 'horizontal';
27 | onOpenChange: any;
28 | }
29 |
30 | class BaseMenu extends PureComponent {
31 | private constructor(props: InternalProps) {
32 | super(props);
33 | }
34 |
35 | public getSelectedMenuKeys(pathname: string) {
36 | const { flatMenuKeys } = this.props;
37 | return urlToList(pathname).map((path: string) => getMenuMatches(flatMenuKeys, path).pop()) as string[];
38 | }
39 |
40 | public renderMenuItem(item: MenuItemProps) {
41 | const { path, icon, name, hideInMenu } = item;
42 |
43 | if (hideInMenu) {
44 | return;
45 | }
46 |
47 | return (
48 | -
49 |
50 | {icon && }
51 | {name}
52 |
53 |
54 | );
55 | }
56 |
57 | public renderSubMenu(props: MenuItemProps) {
58 | const { name, icon, path, children } = props;
59 |
60 | return (
61 |
66 |
67 | {name}
68 |
69 | ) : (
70 | name
71 | )
72 | }
73 | >
74 | {children && children.length
75 | ? children.map((item: MenuItemProps) =>
76 | item.children ? this.renderSubMenu(item) : this.renderMenuItem(item)
77 | )
78 | : null}
79 |
80 | );
81 | }
82 |
83 | public render() {
84 | const { className, collapsed, theme, openKeys, mode, menu, onOpenChange, location } = this.props;
85 |
86 | let selectedKeys = this.getSelectedMenuKeys(location.pathname);
87 | if (!selectedKeys.length && openKeys) {
88 | selectedKeys = [openKeys[openKeys.length - 1]];
89 | }
90 |
91 | let props = {};
92 | if (openKeys && !collapsed) {
93 | props = {
94 | openKeys: openKeys.length === 0 ? [...selectedKeys] : openKeys,
95 | };
96 | }
97 |
98 | return (
99 |
115 | );
116 | }
117 | }
118 |
119 | export default BaseMenu;
120 |
--------------------------------------------------------------------------------
/src/components/SideBar/Logo.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import styles from './SideBar.module.scss';
4 |
5 | export default function Logo(props: any) {
6 | return (
7 |
8 |

9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/SideBar/SideBar.module.scss:
--------------------------------------------------------------------------------
1 | .sideBar {
2 | color: #ffffff;
3 | overflow-y: auto;
4 | }
5 |
6 | .baseMenu {
7 | font-weight: bolder;
8 | }
9 |
10 | .logo {
11 | height: 50px;
12 | margin-bottom: 16px;
13 | text-align: center;
14 | white-space: nowrap;
15 | overflow: hidden;
16 |
17 | img {
18 | height: 50px;
19 | width: 120px;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/SideBar/SideBar.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'antd';
2 | import React, { PureComponent } from 'react';
3 |
4 | import { getDefaultCollapsedSubMenus } from '../../utils/utils';
5 | import BaseMenu, { MenuItemProps } from './BaseMenu';
6 | import Logo from './Logo';
7 | import styles from './SideBar.module.scss';
8 |
9 | let firstMount = true;
10 | const { Sider } = Layout;
11 |
12 | interface InternalProps {
13 | collapsed: boolean;
14 | location: Location;
15 | width?: number | string;
16 | menu: MenuItemProps[];
17 | flatMenuKeys: string[];
18 | onCollapse: (collapsed: boolean) => void;
19 | }
20 |
21 | interface InternalState {
22 | openKeys: string[];
23 | pathname: string;
24 | flatMenuKeysLen: number;
25 | }
26 |
27 | class SideBar extends PureComponent {
28 | private constructor(props: InternalProps) {
29 | super(props);
30 |
31 | this.handleOpenChange = this.handleOpenChange.bind(this);
32 | this.state = {
33 | openKeys: [],
34 | pathname: '/',
35 | flatMenuKeysLen: 0,
36 | };
37 | }
38 |
39 | private static getDerivedStateFromProps(props: InternalProps, state: InternalState) {
40 | const { pathname, flatMenuKeysLen } = state;
41 | const { location, flatMenuKeys } = props;
42 |
43 | if (location.pathname !== pathname || flatMenuKeys.length !== flatMenuKeysLen) {
44 | return {
45 | pathname: location.pathname,
46 | flatMenuKeysLen: flatMenuKeys.length,
47 | openKeys: getDefaultCollapsedSubMenus(location.pathname, flatMenuKeys),
48 | };
49 | }
50 | return null;
51 | }
52 |
53 | public componentDidMount() {
54 | firstMount = false;
55 | }
56 |
57 | public isMainMenu(key: string): boolean {
58 | const { menu } = this.props;
59 | return menu.some((item: MenuItemProps) => {
60 | if (!key) {
61 | return false;
62 | }
63 | return item.path === key;
64 | });
65 | }
66 |
67 | public handleOpenChange(openKeys: string[]) {
68 | this.setState({ openKeys });
69 | }
70 |
71 | public render() {
72 | const { openKeys } = this.state;
73 | const { menu, flatMenuKeys, location, width, collapsed, onCollapse } = this.props;
74 | return (
75 | {
81 | if (!firstMount) {
82 | onCollapse(!collapsed);
83 | }
84 | }}
85 | >
86 |
87 |
96 |
97 | );
98 | }
99 | }
100 |
101 | export default SideBar;
102 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | import Loadable from 'react-loadable';
2 |
3 | import BreadCrumbs from './BreadCrumbs/BreadCrumbs';
4 | import ErrorBoundary from './ErrorBoundary/ErrorBoundary';
5 | import Footer from './Footer/Footer';
6 | import Header from './Header/Header';
7 | import Loading from './Loading/Loading';
8 | import SideBar from './SideBar/SideBar';
9 |
10 | const Content = Loadable({
11 | loader: () => import('./Content/Content'),
12 | loading: Loading,
13 | });
14 |
15 | export { BreadCrumbs, Content, ErrorBoundary, Footer, Header, Loading, SideBar };
16 |
--------------------------------------------------------------------------------
/src/env.ts:
--------------------------------------------------------------------------------
1 | export enum EnvType {
2 | Development = 'development',
3 | Production = 'production',
4 | Local = 'local',
5 | Test = 'test',
6 | }
7 |
8 | interface ServerConfig {
9 | host: string;
10 | api: string;
11 | name: string;
12 | }
13 |
14 | const serverEnvironments: {
15 | test: ServerConfig;
16 | local: ServerConfig;
17 | development: ServerConfig;
18 | production: ServerConfig;
19 | } = {
20 | [EnvType.Local]: {
21 | name: 'Local',
22 | host: 'http://localhost:3000',
23 | api: 'http://localhost:3031',
24 | },
25 | [EnvType.Test]: {
26 | name: 'Test',
27 | host: 'http://localhost:3000',
28 | api: 'http://localhost:3031',
29 | },
30 | [EnvType.Development]: {
31 | name: 'Development',
32 | host: 'http://localhost:3000',
33 | api: 'http://localhost:3031',
34 | },
35 | [EnvType.Production]: {
36 | name: '',
37 | host: 'https://chachaxw.github.io',
38 | api: 'https://chachaxw.github.io',
39 | },
40 | };
41 |
42 | // Node process env variable
43 | export const Env: EnvType = process.env.NODE_ENV as EnvType;
44 |
45 | // Node process.env.REACT_APP_BUILD
46 | export const BuildEnv: EnvType = process.env.REACT_APP_BUILD as EnvType;
47 |
48 | // Env 是否为开发环境
49 | export const __DEV__: boolean = Env === EnvType.Development || BuildEnv === EnvType.Development;
50 |
51 | // Env 是否为测试环境
52 | export const __TEST__: boolean = BuildEnv === EnvType.Test;
53 |
54 | // Env 是否为生产环境
55 | export const __PROD__: boolean = Env === EnvType.Production;
56 |
57 | // 获取服务端环境变量函数
58 | export const ServerEnv = (): ServerConfig => {
59 | if (process.env.REACT_APP_BUILD === EnvType.Test) {
60 | // For staging build
61 | return serverEnvironments[EnvType.Test];
62 | }
63 |
64 | if (process.env.REACT_APP_BUILD === EnvType.Development) {
65 | // For development build
66 | return serverEnvironments[EnvType.Development];
67 | }
68 | return serverEnvironments[Env];
69 | };
70 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
6 | sans-serif;
7 | -webkit-font-smoothing: antialiased;
8 | -moz-osx-font-smoothing: grayscale;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
13 | monospace;
14 | }
15 |
16 | #root {
17 | height: 100%;
18 | }
19 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import dva from 'dva';
2 | import createLoading from 'dva-loading';
3 | import * as React from 'react';
4 | import { createLogger } from 'redux-logger';
5 |
6 | import { Env, EnvType } from './env';
7 | import './index.css';
8 | import { Global } from './models';
9 | import Page from './Page';
10 | import { errorHandle } from './utils/errorHandle';
11 |
12 | // Dva middleware
13 | const middleware = [];
14 |
15 | if (Env === EnvType.Development) {
16 | middleware.push(createLogger());
17 | }
18 |
19 | const app: any = dva({
20 | onError(error: any) {
21 | // Catch redux action errors
22 | errorHandle(error, app._store.dispatch);
23 | },
24 |
25 | onAction: middleware,
26 |
27 | });
28 |
29 | // 第三方插件
30 | app.use(createLoading());
31 |
32 | app.router((props: any) => );
33 |
34 | // Register dva global model
35 | app.model(Global);
36 |
37 | app.start('#root');
38 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/models/global.ts:
--------------------------------------------------------------------------------
1 | import { routerRedux } from 'dva/router';
2 | import { GlobalService } from '../services/GlobalService';
3 | import { exceptionRoutes, localStorageKey } from '../utils/constant';
4 | import { isEmpty } from '../utils/utils';
5 |
6 | // Global declare
7 | declare global {
8 | interface Window { ga: any; }
9 | }
10 |
11 | // Global auth interface
12 | export interface Auth {
13 | isAuthenticated: boolean;
14 | permissions: string[];
15 | }
16 |
17 | // Global userInfo interface
18 | export interface UserInfo {
19 | name: string;
20 | mobile: string;
21 | avatarUrl: string;
22 | nickname: string;
23 | }
24 |
25 | // Global state interface
26 | export interface GlobalState {
27 | auth: Auth;
28 | collapsed: boolean;
29 | notices: any[];
30 | userInfo: UserInfo;
31 | }
32 |
33 | export default {
34 | namespace: 'global', // model的命名空间
35 |
36 | state: { // 应用的状态数据
37 | auth: {
38 | isAuthenticated: true,
39 | permissions: [],
40 | },
41 | userInfo: {
42 | name: '',
43 | mobile: '',
44 | avatarUrl: '',
45 | nickname: '',
46 | },
47 | collapsed: false,
48 | notices: [],
49 | hasMore: false,
50 | },
51 |
52 | effects: { // 异步请求处理和业务逻辑操作
53 | *login(action: any, { call, put }: any) {
54 | try {
55 | const { payload } = action;
56 | yield call(GlobalService.auth, payload);
57 | yield put({
58 | type: 'authorize',
59 | payload: {
60 | isAuthenticated: true,
61 | permissions: [],
62 | },
63 | });
64 | yield put(routerRedux.push('/app'));
65 | } catch (error) {
66 | throw error;
67 | }
68 | },
69 |
70 | *logout(action: any, { put }: any) {
71 | yield put({
72 | type: 'deauthorize',
73 | payload: {
74 | isAuthenticated: false,
75 | permissions: [],
76 | collapsed: false,
77 | },
78 | });
79 | yield put(routerRedux.push('/login'));
80 | },
81 | },
82 |
83 | reducers: { // Redux reducers
84 | authorize(state: GlobalState, { payload }: any) {
85 | localStorage.setItem(localStorageKey.APP_KEY_STORE, JSON.stringify(payload));
86 | return { ...state, auth: payload };
87 | },
88 |
89 | deauthorize(state: GlobalState, { payload }: any) {
90 | localStorage.removeItem(localStorageKey.APP_KEY_STORE);
91 | localStorage.removeItem(localStorageKey.USER_KEY_STORE);
92 | return { ...state, auth: payload };
93 | },
94 |
95 | changeLayoutCollapsed(state: GlobalState, { payload }: any) {
96 | localStorage.setItem(localStorageKey.APP_VIEW_STORE, JSON.stringify({ collapsed: payload }));
97 | return { ...state, collapsed: payload };
98 | },
99 |
100 | loadUserInfo(state: GlobalState, { payload }: any) {
101 | return { ...state, userInfo: payload };
102 | },
103 |
104 | loadNotices(state: GlobalState, { payload, hasMore }: any) {
105 | return { ...state, notices: payload, hasMore };
106 | },
107 |
108 | saveAuthData(state: GlobalState, { payload }: any) {
109 | localStorage.setItem(localStorageKey.APP_KEY_STORE, JSON.stringify(payload));
110 | return state;
111 | },
112 | },
113 |
114 | subscriptions: { // 用于订阅一个数据源, 然后根据需要 dispatch 相应的 action
115 | setup({ dispatch, history }: any) {
116 | return history.listen(({ pathname, search }: Location) => {
117 | if (exceptionRoutes.includes(pathname)) {
118 | return;
119 | }
120 |
121 | const appData = JSON.parse(localStorage.getItem(localStorageKey.APP_KEY_STORE) || '{}');
122 | const appView = JSON.parse(localStorage.getItem(localStorageKey.APP_VIEW_STORE) || '{}');
123 |
124 | try {
125 | if (!isEmpty(appData)) {
126 | dispatch({
127 | type: 'changeLayoutCollapsed',
128 | payload: !isEmpty(appView) ? appView.collapsed : false,
129 | });
130 |
131 | dispatch({
132 | type: 'authorize',
133 | payload: appData,
134 | });
135 |
136 | if (pathname === '/login') {
137 | dispatch(routerRedux.push('/'));
138 | }
139 | } else {
140 | if (pathname === '/login') {
141 | return;
142 | }
143 | }
144 | } catch (error) {
145 | throw error;
146 | }
147 | });
148 | },
149 | },
150 | };
151 |
--------------------------------------------------------------------------------
/src/models/index.ts:
--------------------------------------------------------------------------------
1 | import { DvaInstance, Model } from 'dva';
2 |
3 | export { default as Global } from './global';
4 |
5 | // 动态注册 model 并缓存已注册过的 model
6 | const cached = {};
7 |
8 | export function registerModel(app: DvaInstance, model: Model) {
9 | if (!cached[model.namespace]) {
10 | app.model(model);
11 | cached[model.namespace] = 1;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/models/typed.d.ts:
--------------------------------------------------------------------------------
1 | // Global declare
2 | declare global {
3 | interface Window {
4 | ga: any;
5 | WxLogin: any;
6 | }
7 | }
8 |
9 | // Global auth interface
10 | export interface Auth {
11 | isAuthenticated: boolean;
12 | permissions: string[];
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/Dashboard/Dashboard.module.scss:
--------------------------------------------------------------------------------
1 | .dashboard {
2 | margin: 0 20px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/pages/Dashboard/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@ant-design/compatible';
2 | import { Card, Col, Row, Skeleton, Statistic } from 'antd';
3 | import { connect } from 'dva';
4 | import React, { FunctionComponent } from 'react';
5 |
6 | import styles from './Dashboard.module.scss';
7 |
8 | const Countdown = Statistic.Countdown;
9 | const deadline = Date.now() + 1000 * 60 * 60 * 24 * 2 + 1000 * 30;
10 |
11 | interface DvaProps {
12 | summary?: any;
13 | loading: boolean;
14 | }
15 |
16 | const Dashboard: FunctionComponent = (props) => {
17 | const { loading } = props;
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | }
31 | suffix="%"
32 | />
33 |
34 |
35 |
36 |
37 |
38 |
39 | }
45 | suffix="%"
46 | />
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | } />
61 |
62 |
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | const mapStateToProps = (state: any) => {
70 | return {
71 | total: state.dashboard.total,
72 | today: state.dashboard.today,
73 | loading: state.dashboard.loading,
74 | };
75 | };
76 |
77 | export default connect(mapStateToProps)(Dashboard);
78 |
--------------------------------------------------------------------------------
/src/pages/Dashboard/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Loadable from 'react-loadable';
3 |
4 | import { Loading } from '../../components';
5 | import { registerModel } from '../../models';
6 |
7 | const Dashboard = Loadable.Map({
8 | loader: {
9 | Dashboard: () => import('./Dashboard'),
10 | model: () => import('./models/dashboard'),
11 | },
12 | loading: Loading,
13 | render(loaded: any, props: any) {
14 | const Dashboard = loaded.Dashboard.default;
15 | const model = loaded.model.default;
16 | registerModel(props.app, model);
17 |
18 | return (
19 |
20 |
21 |
22 | );
23 | },
24 | });
25 |
26 | export { Dashboard };
27 |
--------------------------------------------------------------------------------
/src/pages/Dashboard/models/dashboard.ts:
--------------------------------------------------------------------------------
1 | // Dashboard state
2 | export default {
3 | namespace: 'dashboard', // model的命名空间
4 |
5 | state: {
6 | // 应用的状态数据
7 | },
8 |
9 | effects: {},
10 |
11 | reducers: {
12 | // Redux reducers
13 | },
14 |
15 | subscriptions: {
16 | setup({ dispatch, history }: any) {
17 | return history.listen(({ pathname }: any) => {
18 | if (pathname === '/app/dashboard') {
19 | }
20 | });
21 | },
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/src/pages/Exception/403.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from 'antd';
2 | import { Link } from 'dva/router';
3 | import React, { PureComponent } from 'react';
4 | import DocumentTitle from 'react-document-title';
5 |
6 | interface InternalState {
7 | animated: string;
8 | }
9 |
10 | export default class NoPermission extends PureComponent {
11 |
12 | public render() {
13 | const styles = {
14 | height: '100%',
15 | display: 'flex',
16 | alignItems: 'center',
17 | justifyContent: 'center',
18 | background: '#ececec',
19 | overflow: 'hidden',
20 | };
21 |
22 | return (
23 |
24 |
25 |

26 |
27 |
403
28 |
抱歉,你无权访问该页面
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/pages/Exception/404.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from 'antd';
2 | import { Link } from 'dva/router';
3 | import React, { PureComponent } from 'react';
4 | import DocumentTitle from 'react-document-title';
5 |
6 | interface InternalState {
7 | animated: string;
8 | }
9 |
10 | export default class NotFound extends PureComponent {
11 |
12 | public render() {
13 | const styles = {
14 | height: '100%',
15 | display: 'flex',
16 | alignItems: 'center',
17 | justifyContent: 'center',
18 | background: '#ececec',
19 | overflow: 'hidden',
20 | };
21 |
22 | return (
23 |
24 |
25 |

26 |
27 |
404
28 |
抱歉,你访问的页面不存在
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/pages/Exception/500.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from 'antd';
2 | import { Link } from 'dva/router';
3 | import React, { PureComponent } from 'react';
4 | import DocumentTitle from 'react-document-title';
5 |
6 | interface InternalState {
7 | animated: string;
8 | }
9 |
10 | export default class ServerError extends PureComponent {
11 |
12 | public render() {
13 | const styles = {
14 | height: '100%',
15 | display: 'flex',
16 | alignItems: 'center',
17 | justifyContent: 'center',
18 | background: '#ececec',
19 | overflow: 'hidden',
20 | };
21 |
22 | return (
23 |
24 |
25 |

26 |
27 |
500
28 |
抱歉,服务器出错了
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/pages/Exception/index.ts:
--------------------------------------------------------------------------------
1 | import Loadable from 'react-loadable';
2 |
3 | import { Loading } from '../../components';
4 |
5 | const NoPermission = Loadable({
6 | loader: () => import('./403'),
7 | loading: Loading,
8 | });
9 |
10 | const NotFound = Loadable({
11 | loader: () => import('./404'),
12 | loading: Loading,
13 | });
14 |
15 | const ServerError = Loadable({
16 | loader: () => import('./500'),
17 | loading: Loading,
18 | });
19 |
20 | export { NoPermission, NotFound, ServerError };
21 |
--------------------------------------------------------------------------------
/src/pages/Login/Login.module.scss:
--------------------------------------------------------------------------------
1 | .login {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | height: 100%;
6 | font-size: 72px;
7 | overflow: hidden;
8 | background-color: #f8f8f8;
9 | }
10 |
11 | .container {
12 | width: 380px;
13 | padding: 40px;
14 | border-radius: 2px;
15 | box-sizing: border-box;
16 | background-color: #fff;
17 | overflow: hidden;
18 | perspective: 2000px;
19 | box-shadow: 0 1px 5px rgba(0, 0, 0, .08);
20 | }
21 |
22 | .loginForm {
23 | padding-top: 40px;
24 | text-align: left;
25 |
26 | :global(.ant-input) {
27 | border-radius: 20px;
28 | }
29 |
30 | :global(.ant-input:focus) {
31 | box-shadow: none;
32 | }
33 |
34 | :global(.ant-btn) {
35 | font-size: 16px;
36 | }
37 | }
38 |
39 | .Header__svg {
40 | position: absolute;
41 | width: 100%;
42 | top: 50%;
43 | transform: translateY(-50%);
44 | z-index: -1;
45 | will-change: transform;
46 | }
47 | .Header__title {
48 | font-family: Avenir, Futura, 'Open Sans', 'Gill Sans', 'Helvetica Neue', Ariel, sans-serif;
49 | font-weight: bold;
50 | font-size: 6vw;
51 | margin: 0;
52 | }
53 |
54 | .bigSquare {
55 | animation-name: bigSquare;
56 | }
57 | @keyframes bigSquare {
58 | from { transform: translateY(10%) rotate(-80deg) scale(0); }
59 | to { transform: translateY(0) rotate(0deg) scale(1); }
60 | }
61 | .littleSquare {
62 | animation-name: littleSquare;
63 | }
64 | @keyframes littleSquare {
65 | from { transform: translate(226%, 183%) rotate(140deg) scale(0) ; }
66 | to { transform: translate(0%, 0%) rotate(0deg) scale(1); }
67 | }
68 | .triangle {
69 | animation-name: triangle;
70 | }
71 | @keyframes triangle {
72 | from { transform: rotate(-140deg) scale(0) ; }
73 | to { transform: rotate(0deg) scale(1); }
74 | }
75 | .hoop {
76 | animation-name: hoop;
77 | }
78 | @keyframes hoop {
79 | from { transform: translate(-160%, -33%) scale(0) ; }
80 | to { transform: translate(0%, 0%) scale(1); }
81 | }
82 | .bigCircle {
83 | animation-name: bigCircle;
84 | }
85 | @keyframes bigCircle {
86 | from { transform: scale(0) translate(60%, 3%); }
87 | to { transform: scale(1) translate(0%, 0%); }
88 | }
89 | .littleCircle {
90 | animation-name: littleCircle;
91 | }
92 | @keyframes littleCircle {
93 | from { transform: scale(0) }
94 | to { transform: scale(1) }
95 | }
96 |
97 | // some lovely sass fun to stagger the animation
98 |
99 | @for $i from 1 to 12 {
100 | .Header__shape:nth-child(#{$i}) {
101 | animation-delay: $i * 0.16s;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/pages/Login/Login.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from '@ant-design/compatible';
2 | import { Button, Form, Input } from 'antd';
3 | import { connect } from 'dva';
4 | import React, { FC } from 'react';
5 | import { EmailRxp } from '../../utils/constant';
6 | import styles from './Login.module.scss';
7 |
8 | interface Props {
9 | loading: boolean;
10 | login: (params: any) => void;
11 | }
12 |
13 | export const Login: FC = (props) => {
14 | const color = 'rgba(0,0,0,.25)';
15 | const { loading, login } = props;
16 | const [form] = Form.useForm();
17 |
18 | return (
19 |
69 | );
70 | };
71 |
72 | const mapStateToProps = ({ global, loading }: any) => {
73 | return {
74 | loading: loading.effects['global/login'],
75 | };
76 | };
77 |
78 | const mapDispatchToProps = (dispatch: any, ownProps: any) => {
79 | return {
80 | login: (params: object) => {
81 | dispatch({ type: 'global/login', payload: params });
82 | },
83 | };
84 | };
85 |
86 | export default connect(mapStateToProps, mapDispatchToProps)(Login);
87 |
--------------------------------------------------------------------------------
/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | import { Dashboard } from './Dashboard';
2 | import { NoPermission, NotFound, ServerError } from './Exception';
3 |
4 | /************** Public Pages **************/
5 |
6 | export const PublicPages = { NoPermission, NotFound, ServerError };
7 |
8 | export default { Dashboard };
9 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/routes/AppRoutes.tsx:
--------------------------------------------------------------------------------
1 | import { Redirect, Route, RouteComponentProps, Switch } from 'dva/router';
2 | import queryString from 'query-string';
3 | import React, { Component } from 'react';
4 | import DocumentTitle from 'react-document-title';
5 |
6 | import { ServerEnv } from '../env';
7 | import { Auth } from '../models/typed';
8 | import AllPages, { PublicPages } from '../pages';
9 | import { UrlQueryReg } from '../utils/regTool';
10 | import { RouteConfig } from './config';
11 | import { Routes } from './routes';
12 | import RouteWithSubRoutes from './RouteWithSubRoutes';
13 |
14 | const env = ServerEnv();
15 | const { NoPermission, NotFound, ServerError } = PublicPages;
16 |
17 | interface InternalProps {
18 | app: any;
19 | auth: Auth;
20 | menu: RouteConfig[];
21 | }
22 |
23 | class AppRoutes extends Component {
24 | private constructor(props: InternalProps) {
25 | super(props);
26 | }
27 |
28 | public requireAuth(permission: string, component: any) {
29 | const { auth } = this.props;
30 | const { permissions } = auth;
31 |
32 | if (!permissions || !permissions.includes(permission)) {
33 | return ;
34 | }
35 |
36 | return component;
37 | }
38 |
39 | public requireLogin(component: any, permission?: string) {
40 | const { auth } = this.props;
41 | const { isAuthenticated, permissions } = auth;
42 |
43 | if (!isAuthenticated || !permissions) {
44 | // 判断是否登录
45 | return ;
46 | }
47 |
48 | return permission ? this.requireAuth(permission, component) : component;
49 | }
50 |
51 | public renderRoute(route: RouteConfig): any {
52 | if (route.component && route.children) {
53 | return this.requireLogin();
54 | }
55 |
56 | if (route.children) {
57 | return route.children.map((item) => this.renderRoute(item));
58 | }
59 |
60 | return this.renderComponent(route);
61 | }
62 |
63 | public renderComponent(config: RouteConfig) {
64 | const { app } = this.props;
65 | const { path, component, name } = config;
66 | const Component = component ? AllPages[component] : null;
67 |
68 | return (
69 | Component && (
70 | ) => {
75 | // 匹配 location query 字段
76 | const queryParams = window.location.hash.match(UrlQueryReg);
77 | const { params } = props.match;
78 | const title = `${env.name !== '' ? env.name + '-' : ''} React Typescript Admin Starter Template--${name}`;
79 |
80 | Object.keys(params).forEach((key: string) => {
81 | params[key] = params[key] && params[key].replace(UrlQueryReg, '');
82 | });
83 |
84 | props.match.params = { ...params };
85 |
86 | const mergeProps = {
87 | app,
88 | ...props,
89 | query: queryParams ? queryString.parse(queryParams[0]) : {},
90 | };
91 |
92 | return this.requireLogin(
93 |
94 |
95 | ,
96 | config.auth
97 | );
98 | }}
99 | />
100 | )
101 | );
102 | }
103 |
104 | public render() {
105 | const { menu } = this.props;
106 |
107 | return (
108 |
109 | {menu.map((config: RouteConfig) => this.renderRoute(config))}
110 |
111 |
112 |
113 | } />
114 |
115 | );
116 | }
117 | }
118 |
119 | export default AppRoutes;
120 |
--------------------------------------------------------------------------------
/src/routes/RouteWithSubRoutes.tsx:
--------------------------------------------------------------------------------
1 | import { Route, RouteComponentProps } from 'dva/router';
2 | import queryString from 'query-string';
3 | import React, { FC } from 'react';
4 | import DocumentTitle from 'react-document-title';
5 |
6 | import { ServerEnv } from '../env';
7 | import AllPages, { PublicPages } from '../pages';
8 | import { RouteConfig } from './config';
9 |
10 | const env = ServerEnv();
11 | const { NotFound } = PublicPages;
12 |
13 | interface Props extends RouteConfig {
14 | app: any;
15 | }
16 |
17 | export const RouteWithSubRoutes: FC = (route) => {
18 | const { path, name, app, children, auth, component } = route;
19 | const Component = component ? AllPages[component] : null;
20 |
21 | return (
22 | ) => {
25 | // Match location query string
26 | const reg = /\?\S*/g;
27 | const queryParams = window.location.href.match(reg);
28 | const { params } = props.match;
29 | const title = `${env.name !== '' ? env.name + '-' : ''} React Typescript Admin Starter Template-${name}`;
30 |
31 | Object.keys(params).forEach((key: string) => {
32 | params[key] = params[key] && params[key].replace(reg, '');
33 | });
34 |
35 | props.match.params = { ...params };
36 |
37 | const mergeProps = {
38 | app,
39 | auth,
40 | ...props,
41 | query: queryParams ? queryString.parse(queryParams[0]) : {},
42 | };
43 |
44 | return Component ? (
45 |
46 |
47 |
48 | ) : (
49 |
50 | );
51 | }}
52 | />
53 | );
54 | };
55 |
56 | export default RouteWithSubRoutes;
57 |
--------------------------------------------------------------------------------
/src/routes/config.ts:
--------------------------------------------------------------------------------
1 | export interface RouteConfig {
2 | name: string;
3 | icon: string;
4 | path: string;
5 | auth?: string;
6 | query?: string;
7 | hideInMenu?: boolean;
8 | component?: string;
9 | children?: RouteConfig[];
10 | }
11 |
12 | export const appRoutes: { [key: string]: RouteConfig[] } = {
13 | app: [
14 | {
15 | name: '控制台',
16 | icon: 'dashboard',
17 | path: '/app/dashboard',
18 | component: 'Dashboard',
19 | },
20 | {
21 | name: '订单中心',
22 | icon: 'user',
23 | path: '/app/order',
24 | component: 'OrderCenter',
25 | },
26 | {
27 | name: '地图中心',
28 | icon: 'environment',
29 | path: '/app/map',
30 | component: 'MapCenter',
31 | },
32 | {
33 | name: '设置中心',
34 | icon: 'setting',
35 | path: '/app/setting',
36 | children: [
37 | {
38 | name: '消息设置',
39 | icon: 'setting',
40 | path: '/app/setting/message',
41 | component: 'MessageSetting',
42 | },
43 | {
44 | name: '系统设置',
45 | icon: 'setting',
46 | path: '/app/setting/system',
47 | component: 'MessageSetting',
48 | },
49 | {
50 | name: '用户设置',
51 | icon: 'setting',
52 | path: '/app/setting/user',
53 | component: 'SettingCenter',
54 | },
55 | ],
56 | },
57 | {
58 | name: '用户中心',
59 | icon: 'user',
60 | path: '/app/user',
61 | component: 'UserCenter',
62 | },
63 | ],
64 | user: [],
65 | };
66 |
--------------------------------------------------------------------------------
/src/routes/index.ts:
--------------------------------------------------------------------------------
1 | import AppRoutes from './AppRoutes';
2 | import { appRoutes, RouteConfig as RouteConfigTyped } from './config';
3 | import { ExceptionRoutes, Routes } from './routes';
4 | import RouteWithSubRoutes from './RouteWithSubRoutes';
5 |
6 | export type RouteConfig = RouteConfigTyped;
7 |
8 | export { AppRoutes, appRoutes, ExceptionRoutes, RouteWithSubRoutes, Routes };
9 |
--------------------------------------------------------------------------------
/src/routes/routes.ts:
--------------------------------------------------------------------------------
1 | // Global routes enum
2 | export enum Routes {
3 | App = '/app',
4 | Login = '/login',
5 |
6 | // 首页
7 | Dashboard = '/app/dashboard',
8 |
9 | // 异常报错
10 | NoPermission = '/exception/403',
11 | NotFound = '/exception/404',
12 | ServerError = '/exception/500',
13 | AppNoPermission = '/app/exception/403',
14 | AppNotFound = '/app/exception/404',
15 | AppServerError = '/app/exception/500',
16 | }
17 |
18 | // 异常路由列表
19 | export const ExceptionRoutes: string[] = [Routes.AppNoPermission, Routes.AppNotFound, Routes.AppServerError];
20 |
--------------------------------------------------------------------------------
/src/services/ApiConfig.ts:
--------------------------------------------------------------------------------
1 | // API url config
2 | export const ApiUrl = {
3 | auth: '/auth',
4 | dashboard: '/dashboard',
5 | };
6 |
--------------------------------------------------------------------------------
/src/services/AxiosInstance.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
2 | import qs from 'qs';
3 |
4 | import { ServerEnv } from '../env';
5 |
6 | const env = ServerEnv();
7 |
8 | export interface Pagination {
9 | page: number;
10 | pageSize: number;
11 | totalCount: number;
12 | totalPageCount: number;
13 | }
14 |
15 | interface CustomizeConfig extends AxiosRequestConfig {
16 | retry: number;
17 | retryDelay: number;
18 | }
19 |
20 | // axios config options
21 | const options: CustomizeConfig = {
22 | retry: 1,
23 | timeout: 10000,
24 | retryDelay: 1000,
25 | baseURL: env.api + '/api',
26 | // 查询对象序列化函数
27 | paramsSerializer: (params: any) => qs.stringify(params),
28 | };
29 |
30 | const AxiosInstance = axios.create(options);
31 |
32 | // 设置请求重试机制
33 | AxiosInstance.interceptors.response.use(undefined, (err) => {
34 | const config = err.config;
35 |
36 | if (!config || !config.retry) {
37 | return Promise.reject(err);
38 | }
39 |
40 | config.__retryCount = config.__retryCount || 0;
41 |
42 | if (config.__retryCount >= config.retry) {
43 | return Promise.reject(err);
44 | }
45 |
46 | config.__retryCount += 1;
47 |
48 | return new Promise((resolve) => {
49 | setTimeout(() => resolve(null), config.retryDelay);
50 | }).then(() => axios(config));
51 | });
52 |
53 | // GET 获取数据
54 | export const GET = (url: string, params?: any, config?: AxiosRequestConfig) => {
55 | return new Promise((resolve, reject) => {
56 | AxiosInstance.get(url, { params, ...config })
57 | .then((res: AxiosResponse) => {
58 | resolve(res);
59 | })
60 | .catch((error: any) => reject(error));
61 | });
62 | };
63 |
64 | // POST 提交数据
65 | export const POST = (url: string, data?: any, config?: AxiosRequestConfig) => {
66 | return new Promise((resolve, reject) => {
67 | AxiosInstance.post(url, data, config)
68 | .then((res: AxiosResponse) => resolve(res))
69 | .catch((error: any) => reject(error));
70 | });
71 | };
72 |
73 | // PATCH 修改数据
74 | export const PATCH = (url: string, data?: any, config?: AxiosRequestConfig) => {
75 | return new Promise((resolve, reject) => {
76 | AxiosInstance.patch(url, data, config)
77 | .then((res: AxiosResponse) => resolve(res))
78 | .catch((error: any) => reject(error));
79 | });
80 | };
81 |
82 | // DELETE 删除数据
83 | export const DELETE = (url: string, config?: AxiosRequestConfig) => {
84 | return new Promise((resolve, reject) => {
85 | AxiosInstance.delete(url, config)
86 | .then((res: AxiosResponse) => resolve(res))
87 | .catch((error: any) => reject(error));
88 | });
89 | };
90 |
91 | export default AxiosInstance;
92 |
--------------------------------------------------------------------------------
/src/services/DashboardService.ts:
--------------------------------------------------------------------------------
1 | import { ApiUrl } from './ApiConfig';
2 | import AxiosInstance from './AxiosInstance';
3 |
4 | export class DashboardService {
5 |
6 | }
7 |
--------------------------------------------------------------------------------
/src/services/GlobalService.ts:
--------------------------------------------------------------------------------
1 | import { ApiUrl } from './ApiConfig';
2 | import { POST } from './AxiosInstance';
3 |
4 | export class GlobalService {
5 |
6 | public static auth(params: any): Promise {
7 | return POST(ApiUrl.auth, params);
8 | }
9 |
10 | }
11 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // setupTests.ts
2 | import Enzyme from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
4 | import fetch from 'jest-fetch-mock';
5 |
6 | Enzyme.configure({ adapter: new Adapter() });
7 |
8 | const globalAny: any = global;
9 |
10 | // Fetch mock
11 | globalAny.fetch = fetch;
12 |
13 | // localStorage mock
14 | const localStorageMock = {
15 | getItem: jest.fn(),
16 | setItem: jest.fn(),
17 | clear: jest.fn(),
18 | };
19 |
20 | globalAny.localStorage = localStorageMock;
21 |
--------------------------------------------------------------------------------
/src/styles/_global.scss:
--------------------------------------------------------------------------------
1 | /********** Color Variables **********/
2 | $primary-color: #1890ff;
3 | $primary-color-light: #f2f7ff;
4 | $base-dark-color: #3e4a5b;
5 | $black-color: #000;
6 | $white-color: #fff;
7 | $green-color: #07bd13;
8 | $blue-grey-color: #b2c0c8;
9 | $page-background: #f0f2f5;
10 | $light-grey-color: #f4f5f7;
11 | $scroll-bar-background: #828384;
12 | $grey-border-color: #d9d9d9;
13 | $danger-color: #e85050;
14 | $secondary-color: #42a1c1;
15 | $layout-side-color: #16171a;
16 |
17 | // Media query variables
18 | $screen-xs: 575px;
19 | $screen-sm: 767px;
20 | $screen-md: 991px;
21 | $screen-lg: 1199px;
22 | $screen-sl: 1599px;
23 |
24 | // Layout variables
25 | $page-header: 64px;
26 | $page-footer: 70px;
27 |
28 | // Media query mixin function
29 | @mixin respond-to($media) {
30 | @if $media== 'screen-xs' {
31 | @media only screen and (max-width: $screen-xs) {
32 | @content;
33 | }
34 | } @else if $media== 'screen-sm' {
35 | @media only screen and (min-width: $screen-xs + 1) and (max-width: $screen-sm) {
36 | @content;
37 | }
38 | } @else if $media== 'screen-md' {
39 | @media only screen and (min-width: $screen-sm + 1) and (max-width: $screen-md) {
40 | @content;
41 | }
42 | } @else if $media== 'screen-lg' {
43 | @media only screen and (min-width: $screen-md + 1) and (max-width: $screen-lg) {
44 | @content;
45 | }
46 | } @else if $media== 'screen-xl' {
47 | @media only screen and (min-width: $screen-lg + 1) and (max-width: $screen-sl) {
48 | @content;
49 | }
50 | } @else if $media== 'screen-sl' {
51 | @media only screen and (min-width: $screen-sl + 1) {
52 | @content;
53 | }
54 | } @else {
55 | @error 'No value found for `#{$media}`. ';
56 | }
57 | }
58 |
59 | /********** Global styles **********/
60 |
61 | .ant-modal {
62 | .ant-modal-body {
63 | max-height: 600px;
64 | overflow: auto;
65 |
66 | .ant-form {
67 | .ant-form-item {
68 | margin-bottom: 12px;
69 | }
70 | }
71 |
72 | // 手机端最大高度 380px
73 | @include respond-to('screen-xs') {
74 | max-height: 380px;
75 | }
76 | }
77 | }
78 |
79 | @mixin customize-scroll-bar($width: 6px, $radius: 10px, $color: $scroll-bar-background) {
80 | &::-webkit-scrollbar {
81 | width: $width;
82 | }
83 |
84 | &::-webkit-scrollbar-thumb {
85 | border-radius: $radius;
86 | box-shadow: inset 0 0 5px rgba($black-color, 0.2);
87 | background: $color;
88 | }
89 |
90 | &::-webkit-scrollbar-track {
91 | border-radius: 0;
92 | background: rgba($white-color, 0.1);
93 | box-shadow: inset 0 0 5px rgba($black-color, 0.2);
94 | }
95 | }
96 |
97 | /***** 全局弹性盒子 *****/
98 | @mixin flex-box($direction: row, $justify: center, $align: center, $flex-wrap: null) {
99 | display: flex;
100 |
101 | @if ($direction !=null) {
102 | flex-direction: $direction;
103 | }
104 |
105 | @if ($justify !=null) {
106 | justify-content: $justify;
107 | }
108 |
109 | @if ($align !=null) {
110 | align-items: $align;
111 | }
112 |
113 | @if ($flex-wrap !=null) {
114 | flex-wrap: $flex-wrap;
115 | }
116 | }
117 |
118 | @mixin flex-center {
119 | display: flex;
120 | align-items: center;
121 | justify-content: center;
122 | }
123 |
124 | /***** 单行多余字符省略... *****/
125 | @mixin ellipsis() {
126 | overflow: hidden;
127 | text-overflow: ellipsis;
128 | white-space: nowrap;
129 | }
130 |
131 | /***** 椭圆渐变 *****/
132 | .gradientOval {
133 | position: absolute;
134 | left: 50%;
135 | top: -53px;
136 | width: 90%;
137 | height: 60px;
138 | border-radius: 50%;
139 | transform: translateX(-50%);
140 | opacity: 0.7;
141 | filter: blur(18px);
142 | background-image: linear-gradient(90deg, #2b55ff 12%, #2bb7ff 87%);
143 | }
144 |
145 | /***** 全局 Table 基础样式 *****/
146 | .base-table {
147 | position: relative;
148 | margin: 12px 16px 16px;
149 | background-color: $white-color;
150 | }
151 |
152 | .button-group .ant-btn-link {
153 | padding: 0 8px;
154 | }
155 |
156 | /***** 全局 Form 基础样式 *****/
157 | .base-form,
158 | .base-table .ant-table-title {
159 | .ant-form-item {
160 | margin-bottom: 12px;
161 | }
162 |
163 | .ant-form-item-label {
164 | line-height: 32px;
165 | }
166 | }
167 |
168 | /***** 全局 Tabs Table 基础样式 *****/
169 | .ant-tabs .ant-tabs-top-content > .ant-tabs-tabpane,
170 | .ant-tabs .ant-tabs-bottom-content > .ant-tabs-tabpane {
171 | .base-table {
172 | margin: 0;
173 | }
174 | }
175 |
176 | .card-hoverable {
177 | box-sizing: border-box;
178 | box-shadow: rgba(0, 0, 0, 0.05) 0px 5px 10px, rgba(0, 0, 0, 0.2) 0px 0px 1px;
179 | }
180 |
181 | /***** 全局 Cascader 基础样式 *****/
182 | .ant-cascader-menu {
183 | height: 260px;
184 | }
185 |
--------------------------------------------------------------------------------
/src/typings/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'dva-loading';
2 |
--------------------------------------------------------------------------------
/src/utils/__test__/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { formatSeconds, isEmpty } from '../utils';
2 |
3 | describe('formatSeconds test', () => {
4 | it('should return 00:01:30 when formatSeconds(90)', () => {
5 | const timeStr = formatSeconds(90);
6 | expect(timeStr).toStrictEqual('1分30秒');
7 | });
8 |
9 | it('should return 00:01:30 when formatSeconds(4230)', () => {
10 | const timeStr = formatSeconds(4230);
11 | expect(timeStr).toStrictEqual('1小时10分30秒');
12 | });
13 | });
14 |
15 | describe('isEmpty test', () => {
16 | it('should return true when isEmpty({})', () => {
17 | const empty = isEmpty({});
18 | expect(empty).toStrictEqual(true);
19 | });
20 |
21 | it('should return true when isEmpty([])', () => {
22 | const empty = isEmpty([]);
23 | expect(empty).toStrictEqual(true);
24 | });
25 |
26 | it('should return true when isEmpty("")', () => {
27 | const empty = isEmpty('');
28 | expect(empty).toStrictEqual(true);
29 | });
30 |
31 | it('should return false when isEmpty({name: "Hello World"})', () => {
32 | const empty = isEmpty({ name: 'Hello World' });
33 | expect(empty).toStrictEqual(false);
34 | });
35 |
36 | it('should return false when isEmpty([1, 2, 3])', () => {
37 | const empty = isEmpty({ name: 'Hello World' });
38 | expect(empty).toStrictEqual(false);
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/utils/constant.ts:
--------------------------------------------------------------------------------
1 | export const MobileRxp = /^(0|86|17951)?(13[0-9]|15[012356789]|166|17[3678]|18[0-9]|14[57])[0-9]{8}$/;
2 |
3 | export const EmailRxp = /\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*/;
4 |
5 | // API request error code message
6 | export const codeMessage = {
7 | 200: '服务器成功返回请求的数据。',
8 | 201: '新建或修改数据成功。',
9 | 202: '一个请求已经进入后台排队(异步任务)。',
10 | 204: '删除数据成功。',
11 | 400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
12 | 401: '用户没有权限(令牌、用户名、密码错误)。',
13 | 403: '用户得到授权,但是访问是被禁止的。',
14 | 404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',
15 | 406: '请求的格式不可得。',
16 | 410: '请求的资源被永久删除,且不会再得到的。',
17 | 422: '当创建一个对象时,发生一个验证错误。',
18 | 500: '服务器发生错误,请检查服务器。',
19 | 502: '网关错误。',
20 | 503: '服务不可用,服务器暂时过载或维护。',
21 | 504: '网关超时。',
22 | };
23 |
24 | export const localStorageKey = {
25 | APP_KEY_STORE: 'ADMIN-KEY',
26 | USER_KEY_STORE: 'ADMIN-USER',
27 | APP_VIEW_STORE: 'ADMIN-VIEW',
28 | };
29 |
30 | export const exceptionRoutes: string[] = [
31 | '/app/exception/403',
32 | '/app/exception/404',
33 | '/app/exception/500',
34 | ];
35 |
--------------------------------------------------------------------------------
/src/utils/errorHandle.ts:
--------------------------------------------------------------------------------
1 | import { notification } from 'antd';
2 | import { routerRedux } from 'dva/router';
3 |
4 | import { codeMessage } from './constant';
5 |
6 | export const errorHandle = (error: any, dispatch: any) => {
7 | const { response } = error;
8 |
9 | if (response) {
10 | const { status, data } = response;
11 | const errorText = codeMessage[status] || data.error;
12 |
13 | if (status === 401) {
14 | notification.error({
15 | message: errorText,
16 | });
17 | dispatch({
18 | type: 'global/deauthorize',
19 | payload: { isAuthenticated: false, permissions: [] },
20 | });
21 | return;
22 | }
23 |
24 | notification.error({
25 | message: `请求错误 ${status}`,
26 | description: errorText,
27 | });
28 |
29 | if (status === 403) {
30 | dispatch(routerRedux.push('/app/exception/403'));
31 | return;
32 | }
33 |
34 | if (status <= 504 && status >= 500) {
35 | dispatch(routerRedux.push('/app/exception/500'));
36 | return;
37 | }
38 |
39 | if (status >= 404 && status < 422) {
40 | dispatch(routerRedux.push('/app/exception/404'));
41 | }
42 | } else {
43 | notification.error({
44 | message: `Request Error`,
45 | description: 'Network error, please try again!',
46 | });
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/src/utils/regTool.ts:
--------------------------------------------------------------------------------
1 | export const PositiveIntegerReg = /^[1-9]([0-9])*$/;
2 |
3 | export const UsernameReg = /^[0-9a-z]{5,32}$/i;
4 |
5 | export const UrlQueryReg = /\?\S*/g;
6 |
7 | export const DecimalReg = /^\d+(?:\.\d{0,10})?$/;
8 |
9 | export const PhoneReg = /^((0\d{2,3}-\d{7,8})|(1[3456789]\d{9}))$/;
10 |
11 | export const PhoneRegSetting = /^((400-?\d{3}-?\d{4})|(0\d{2,3}-\d{7,8})|(1[3456789]\d{9}))$/;
12 |
13 | export const NumberReg = /^[0-9]+$/;
14 |
15 | export const PasswordReg = /^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{8,32}$/;
16 |
17 | export const FloorReg = /^(((-?\d|\d+|[A-Z]|\d+[A-Z])|(-?\d|\d+)[~~](-?\d|\d+))[ ,,]+)*((-?\d|\d+|[A-Z]|\d+[A-Z])|(-?\d|\d+)[~~](-?\d|\d+))$/;
18 |
19 | export const MacAddressReg = /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}/;
20 |
21 | export const EmailReg = /\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*/;
22 |
23 | export const IdCard = /^[1-9]\d{5}(18|19|20|(3\d))\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
24 |
25 | export const UUIDReg = /[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}/;
26 |
--------------------------------------------------------------------------------
/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import { pathToRegexp } from 'path-to-regexp';
2 |
3 | import { RouteConfig } from '../routes';
4 |
5 | /**
6 | * Transfer url to url list
7 | * Example:
8 | * '/userinfo/2019/id' => ['/userinfo', '/useinfo/2019, '/userindo/2019/id']
9 | *
10 | * @param {string} url
11 | * @return {string[]}
12 | */
13 | export function urlToList(url: string): string[] {
14 | const urlList = url.split('/').filter((i: string) => i);
15 | return urlList.map((item: string, index) => `/${urlList.slice(0, index + 1).join('/')}`);
16 | }
17 |
18 | /**
19 | * Recursively flatten the data
20 | * Example:
21 | * [{path: string}, {path: string}] => {path1, path2}
22 | *
23 | * @param {array} menu
24 | * @return {string[]}
25 | */
26 | export function getFlatMenuKeys(menu: any[]): string[] {
27 | let keys: any[] = [];
28 | menu.forEach((item: any) => {
29 | keys.push(item.path);
30 | if (item.children) {
31 | keys = keys.concat(getFlatMenuKeys(item.children));
32 | }
33 | });
34 | return keys;
35 | }
36 |
37 | /**
38 | * Get menu matches
39 | * @param {array} flatMenuKeys
40 | * @param {string[]}
41 | */
42 | export function getMenuMatches(flatMenuKeys: string[], path: string): string[] {
43 | const menus = flatMenuKeys.filter((item: string) => {
44 | if (!item) {
45 | return [];
46 | }
47 | return pathToRegexp(item).test(path);
48 | });
49 | return menus;
50 | }
51 |
52 | /**
53 | * Get default collapsed sub menus
54 | *
55 | * @param {string} pathname
56 | * @param {string[]} flatMenuKeys
57 | * @return {string[]}
58 | */
59 | export function getDefaultCollapsedSubMenus(pathname: string, flatMenuKeys: string[]): string[] {
60 | const subMenus = urlToList(pathname)
61 | .map((item: string) => getMenuMatches(flatMenuKeys, item)[0])
62 | .filter((item: string) => item)
63 | .reduce((acc: any, curr: any) => [...acc, curr], ['/']);
64 |
65 | return subMenus;
66 | }
67 |
68 | /**
69 | * 获取面包屑路径映射
70 | * @param RouteConfig[] menuData 菜单配置
71 | */
72 | export function getBreadcrumbNameMap(menuData: RouteConfig[]): { [key: string]: RouteConfig } {
73 | const routerMap: { [key: string]: RouteConfig } = {};
74 | const flattenMenuData: (data: RouteConfig[]) => void = (data) => {
75 | data.forEach((menuItem: RouteConfig) => {
76 | if (!menuItem) {
77 | return;
78 | }
79 | if (menuItem && menuItem.children) {
80 | flattenMenuData(menuItem.children);
81 | }
82 | routerMap[menuItem.path] = menuItem;
83 | });
84 | };
85 | flattenMenuData(menuData);
86 | return routerMap;
87 | }
88 |
89 | /**
90 | * Transfer number to `x小时 y分 z秒`
91 | * @param {number} num
92 | * @return {string}
93 | */
94 | export function formatSeconds(num: number): string {
95 | if (!num) {
96 | return '';
97 | }
98 |
99 | const minutes = ~~((num / 60) % 60);
100 | const hours = ~~(num / (60 * 60));
101 | const seconds = ~~(num % 60);
102 |
103 | return `${hours > 0 ? hours + '小时' : ''}${minutes > 0 ? minutes + '分' : ''}${seconds > 0 ? seconds + '秒' : ''}`;
104 | }
105 |
106 | /**
107 | * 判断对象或者数组是否为空
108 | * @param {array | object} obj
109 | */
110 | export function isEmpty(obj: any): boolean {
111 | return [Object, Array].includes((obj || {}).constructor) && !Object.entries(obj || {}).length;
112 | }
113 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "experimentalDecorators": true,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "strictNullChecks": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "jsx": "react-jsx",
18 | "module": "esnext",
19 | "moduleResolution": "node",
20 | "resolveJsonModule": true,
21 | "isolatedModules": true,
22 | "noEmit": true,
23 | "rootDir": "src",
24 | "typeRoots": [
25 | "src/typings"
26 | ],
27 | "noImplicitReturns": true,
28 | "suppressImplicitAnyIndexErrors": true,
29 | "noFallthroughCasesInSwitch": true
30 | },
31 | "formatCodeOptions": {
32 | "indentSize": 2,
33 | "tabSize": 2,
34 | "convertTabsToSpaces": true
35 | },
36 | "include": [
37 | "src"
38 | ],
39 | "exclude": [
40 | "node_modules",
41 | "build",
42 | "scripts",
43 | "acceptance-tests",
44 | "webpack",
45 | "jest",
46 | "tslint:latest",
47 | "tslint-config-prettier"
48 | ]
49 | }
50 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "tslint:recommended",
4 | "tslint-react",
5 | "tslint-react-hooks"
6 | ],
7 | "rulesDirectory": [
8 | "node_modules/tslint-lines-between-class-members"
9 | ],
10 | "rules": {
11 | "quotemark": [
12 | true,
13 | "single",
14 | "jsx-double"
15 | ],
16 | "curly": true,
17 | "lines-between-class-members": true,
18 | "object-literal-sort-keys": false,
19 | "ordered-imports": true,
20 | "member-ordering": false,
21 | "variable-name": false,
22 | "jsx-no-multiline-js": false,
23 | "jsx-no-lambda": false,
24 | "jsx-boolean-value": false,
25 | "interface-name": false,
26 | "jsx-no-string-ref": false,
27 | "no-shadowed-variable": false,
28 | "no-debugger": false,
29 | "jsx-alignment": false,
30 | "prefer-for-of": true,
31 | "no-eval": true,
32 | "no-bitwise": false,
33 | "jsdoc-format": true,
34 | "triple-equals": true,
35 | "no-empty-interface": true,
36 | "no-unnecessary-initializer": true,
37 | "no-conditional-assignment": true,
38 | "react-hooks-nesting": "error",
39 | "array-type": [
40 | true,
41 | "array"
42 | ],
43 | "no-unused-expression": [
44 | true,
45 | "allow-fast-null-checks"
46 | ],
47 | "no-empty": [
48 | false,
49 | "allow-empty-catch"
50 | ],
51 | "no-trailing-whitespace": [
52 | true,
53 | "ignore-comments",
54 | "ignore-jsdoc"
55 | ],
56 | "comment-format": [
57 | true,
58 | "check-space"
59 | ],
60 | "max-line-length": [
61 | true,
62 | 120
63 | ],
64 | "max-file-line-count": [
65 | true,
66 | 500
67 | ],
68 | "max-classes-per-file":[
69 | true,
70 | 1
71 | ],
72 | "one-variable-per-declaration": [
73 | true,
74 | "ignore-for-loop"
75 | ],
76 | "trailing-comma": [
77 | true,
78 | {
79 | "singleline": "never",
80 | "multiline": {
81 | "objects": "always",
82 | "arrays": "always",
83 | "functions": "ignore",
84 | "typeLiterals": "ignore"
85 | },
86 | "esSpecCompliant": true
87 | }
88 | ]
89 | }
90 | }
91 |
--------------------------------------------------------------------------------