├── mock
├── .gitkeep
├── utils.js
├── notices.js
├── profile.js
├── chart.js
├── rule.js
└── api.js
├── src
├── routes
│ ├── Dashboard
│ │ ├── Analysis.less
│ │ ├── Monitor.less
│ │ ├── Workplace.less
│ │ ├── Monitor.js
│ │ ├── Workplace.js
│ │ └── Analysis.js
│ ├── Forms
│ │ ├── BasicForm.less
│ │ └── BasicForm.js
│ └── User
│ │ ├── Login.less
│ │ └── Login.js
├── assets
│ └── login-bg.jpg
├── common
│ ├── config.js
│ └── nav.js
├── services
│ ├── user.js
│ └── api.js
├── components
│ └── theme.js
├── layouts
│ ├── UserLayout.less
│ ├── UserLayout.js
│ ├── BasicLayout.less
│ └── BasicLayout.js
├── index.ejs
├── rollbar.js
├── models
│ ├── index.js
│ ├── user.js
│ ├── login.js
│ └── global.js
├── index.less
├── app.js
├── utils
│ ├── utils.less
│ ├── request.js
│ └── utils.js
└── router.js
├── jsconfig.json
├── .editorconfig
├── .gitignore
├── .webpackrc
├── .travis.yml
├── .stylelintrc
├── .eslintrc
├── .roadhogrc.mock.js
├── package.json
└── README.md
/mock/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/routes/Dashboard/Analysis.less:
--------------------------------------------------------------------------------
1 | div {
2 | font-size: 13px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/routes/Dashboard/Monitor.less:
--------------------------------------------------------------------------------
1 | div {
2 | font-size: 13px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/routes/Forms/BasicForm.less:
--------------------------------------------------------------------------------
1 | div {
2 | font-size: 13px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/routes/Dashboard/Workplace.less:
--------------------------------------------------------------------------------
1 | div {
2 | font-size: 13px;
3 | }
4 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "experimentalDecorators": true
4 | }
5 | }
--------------------------------------------------------------------------------
/src/assets/login-bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiubug/react-antd-dva/HEAD/src/assets/login-bg.jpg
--------------------------------------------------------------------------------
/src/common/config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | api: 'http://admin.sosout.com',
3 | USER_TOKEN: 'USER_TOKEN',
4 | USER_ID: 'USER_ID',
5 | };
6 |
--------------------------------------------------------------------------------
/src/services/user.js:
--------------------------------------------------------------------------------
1 | import { stringify } from 'qs';
2 | import request from '../utils/request';
3 |
4 | export async function queryCurrent(params) {
5 | return request(`/user/getUser?${stringify(params)}`);
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/theme.js:
--------------------------------------------------------------------------------
1 | // https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
2 | module.exports = {
3 | // 'primary-color': '#10e99b',
4 | 'card-actions-background': '#f5f8fa',
5 | };
6 |
--------------------------------------------------------------------------------
/src/layouts/UserLayout.less:
--------------------------------------------------------------------------------
1 | @import "~antd/lib/style/themes/default.less";
2 |
3 | .user-layout {
4 | position: relative;
5 | width: 100%;
6 | min-width: 1000px;
7 | height: 100vh;
8 | background: url('../assets/login-bg.jpg') no-repeat 50%;
9 | background-size: cover;
10 | }
11 |
--------------------------------------------------------------------------------
/.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 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
15 | [Makefile]
16 | indent_style = tab
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | # roadhog-api-doc ignore
6 | /src/utils/request-temp.js
7 | _roadhog-api-doc
8 |
9 | # production
10 | /dist
11 |
12 | # misc
13 | .DS_Store
14 | npm-debug.log*
15 |
16 | /coverage
17 |
--------------------------------------------------------------------------------
/src/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | react antd dva
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/rollbar.js:
--------------------------------------------------------------------------------
1 | import Rollbar from 'rollbar';
2 |
3 | // 检测、诊断和调试错误
4 | if (location.host === 'dva.sosout.com') {
5 | Rollbar.init({
6 | accessToken: 'a7bfb90e615c42cbb4bd11196ae23afd',
7 | captureUncaught: true,
8 | captureUnhandledRejections: true,
9 | payload: {
10 | environment: 'production',
11 | },
12 | });
13 | }
14 |
--------------------------------------------------------------------------------
/src/services/api.js:
--------------------------------------------------------------------------------
1 | import Store from 'store';
2 | import request from '../utils/request';
3 |
4 | // 用户登录
5 | export async function signIn(params) {
6 | return request('/user/login', {
7 | method: 'POST',
8 | body: params,
9 | });
10 | }
11 |
12 | // 用户退出了
13 | export async function signOut() {
14 | // 清除TOKEN,模拟退出
15 | Store.clearAll();
16 | return true;
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/src/routes/Dashboard/Monitor.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import { connect } from 'dva';
3 |
4 | @connect(state => ({
5 | monitor: state.monitor,
6 | }))
7 | export default class Monitor extends PureComponent {
8 | state = {}
9 |
10 | componentDidMount() {}
11 |
12 | render() {
13 | return (
14 |
15 | 监控页
16 |
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/models/index.js:
--------------------------------------------------------------------------------
1 | // Use require.context to require reducers automatically
2 | // Ref: https://webpack.github.io/docs/context.html
3 | const context = require.context('./', false, /\.js$/);
4 | const keys = context.keys().filter(item => item !== './index.js');
5 |
6 | const models = [];
7 | for (let i = 0; i < keys.length; i += 1) {
8 | models.push(context(keys[i]));
9 | }
10 |
11 | export default models;
12 |
--------------------------------------------------------------------------------
/src/routes/Dashboard/Workplace.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import { connect } from 'dva';
3 |
4 | @connect(state => ({
5 | monitor: state.monitor,
6 | }))
7 | export default class Workplace extends PureComponent {
8 | state = {}
9 |
10 | componentDidMount() {}
11 |
12 | render() {
13 | return (
14 |
15 | 工作台
16 |
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/routes/Forms/BasicForm.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react';
2 | import { connect } from 'dva';
3 |
4 | @connect(state => ({
5 | monitor: state.monitor,
6 | }))
7 | export default class BasicForms extends PureComponent {
8 | state = {}
9 |
10 | componentDidMount() {}
11 |
12 | render() {
13 | return (
14 |
15 | 基础表单
16 |
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/routes/Dashboard/Analysis.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'dva';
3 |
4 | @connect(state => ({
5 | chart: state.chart,
6 | }))
7 | export default class Analysis extends Component {
8 | state = {}
9 |
10 | componentDidMount() {}
11 |
12 | componentWillUnmount() {
13 | }
14 |
15 | render() {
16 | return (
17 |
18 | 分析页
19 |
20 | );
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/index.less:
--------------------------------------------------------------------------------
1 | body, html {
2 | width: 100%;
3 | height: 100%;
4 | }
5 |
6 | body {
7 | height: 100%;
8 | overflow-y: hidden;
9 | background-color: #f4f7ed;
10 | text-rendering: optimizeLegibility;
11 | -webkit-font-smoothing: antialiased;
12 | -moz-osx-font-smoothing: grayscale;
13 | }
14 |
15 | :global(#root) {
16 | height: 100vh;
17 | position: relative;
18 | }
19 |
20 | .globalSpin {
21 | width: 100%;
22 | margin: 40px 0 !important;
23 | }
24 |
--------------------------------------------------------------------------------
/src/models/user.js:
--------------------------------------------------------------------------------
1 | import { queryCurrent } from '../services/user';
2 |
3 | export default {
4 | namespace: 'user',
5 | state: {
6 | currentUser: {},
7 | },
8 |
9 | effects: {
10 | *fetchCurrent({ payload }, { call, put }) {
11 | const response = yield call(queryCurrent, payload);
12 | yield put({
13 | type: 'saveCurrentUser',
14 | payload: response,
15 | });
16 | },
17 | },
18 |
19 | reducers: {
20 | saveCurrentUser(state, action) {
21 | return {
22 | ...state,
23 | currentUser: action.payload[0],
24 | };
25 | },
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/.webpackrc:
--------------------------------------------------------------------------------
1 | {
2 | "entry": "src/app.js",
3 | "publicPath": "/",
4 | "extraBabelPlugins": [
5 | ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": true }]
6 | ],
7 | "env": {
8 | "development": {
9 | "extraBabelPlugins": [
10 | "dva-hmr"
11 | ]
12 | }
13 | },
14 | "browserslist": [
15 | ">1%",
16 | "last 4 versions",
17 | "Firefox ESR",
18 | "not ie < 9" // React doesn't support IE8 anyway
19 | ],
20 | "externals": {},
21 | "ignoreMomentLocale": true,
22 | "theme": "./src/components/theme.js",
23 | "html": {
24 | "template": "./src/index.ejs"
25 | },
26 | "hash": true
27 | }
28 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "8"
5 |
6 | env:
7 | matrix:
8 | - TEST_TYPE=lint
9 | - TEST_TYPE=test-all
10 | - TEST_TYPE=test-dist
11 |
12 | addons:
13 | apt:
14 | packages:
15 | - xvfb
16 |
17 | install:
18 | - export DISPLAY=':99.0'
19 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
20 | - npm install
21 |
22 | script:
23 | - |
24 | if [ "$TEST_TYPE" = lint ]; then
25 | npm run lint
26 | elif [ "$TEST_TYPE" = test-all ]; then
27 | npm run test:all
28 | elif [ "$TEST_TYPE" = test-dist ]; then
29 | npm run site
30 | mv dist/* ./
31 | php -S localhost:8000 &
32 | npm test .e2e.js
33 | fi
34 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import '@babel/polyfill';
2 | import dva from 'dva';
3 | import 'moment/locale/zh-cn';
4 | import browserHistory from 'history/createBrowserHistory';
5 | // import { createLogger } from 'redux-logger';
6 | import { message } from 'antd';
7 | import './rollbar';
8 | import './index.less';
9 | import router from './router';
10 |
11 | // 1. 创建应用,返回 dva 实例
12 | const app = dva({
13 | history: browserHistory(),
14 | onError(e) {
15 | message.error(e.message, /* duration */3);
16 | },
17 | // onAction: createLogger({}),
18 | });
19 |
20 | // 2. 配置 hooks 或者注册插件
21 | // app.use({});
22 |
23 | // 3. 注册 model
24 | app.model(require('./models/global').default);
25 |
26 | // 4. 注册路由表
27 | app.router(router);
28 |
29 | // 5. 启动应用
30 | app.start('#root');
31 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "stylelint-config-standard",
3 | "rules": {
4 | "selector-pseudo-class-no-unknown": null,
5 | "shorthand-property-no-redundant-values": null,
6 | "at-rule-empty-line-before": null,
7 | "at-rule-name-space-after": null,
8 | "comment-empty-line-before": null,
9 | "declaration-bang-space-before": null,
10 | "declaration-empty-line-before": null,
11 | "function-comma-newline-after": null,
12 | "function-name-case": null,
13 | "function-parentheses-newline-inside": null,
14 | "function-max-empty-lines": null,
15 | "function-whitespace-after": null,
16 | "number-leading-zero": null,
17 | "number-no-trailing-zeros": null,
18 | "rule-empty-line-before": null,
19 | "selector-combinator-space-after": null,
20 | "selector-list-comma-newline-after": null,
21 | "selector-pseudo-element-colon-notation": null,
22 | "unit-no-unknown": null,
23 | "value-list-max-empty-lines": null
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/routes/User/Login.less:
--------------------------------------------------------------------------------
1 | @import "~antd/lib/style/themes/default.less";
2 |
3 | .login-form {
4 | position: absolute;
5 | top: 50%;
6 | left: 50%;
7 | margin: -160px 0 0 -160px;
8 | width: 320px;
9 | height: 320px;
10 | padding: 36px;
11 | box-shadow: 0 0 100px rgba(0, 0, 0, .08);
12 | background-color: #fff;
13 | border-radius: 4px;
14 | .login-logo {
15 | text-align: center;
16 | height: 40px;
17 | line-height: 40px;
18 | cursor: pointer;
19 | margin-bottom: 24px;
20 | img {
21 | width: 40px;
22 | margin-right: 8px;
23 | }
24 | span {
25 | font-size: 16px;
26 | text-transform: uppercase;
27 | display: inline-block;
28 | }
29 | }
30 | button {
31 | width: 100%;
32 | }
33 | .login-account {
34 | color: #999;
35 | text-align: center;
36 | margin-top: 16px;
37 | span {
38 | &:first-child {
39 | margin-right: 16px;
40 | }
41 | }
42 | }
43 | :global {
44 | .ant-form-item {
45 | margin-bottom: 24px;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/utils/utils.less:
--------------------------------------------------------------------------------
1 | .textOverflow() {
2 | overflow: hidden;
3 | text-overflow: ellipsis;
4 | word-break: break-all;
5 | white-space: nowrap;
6 | }
7 |
8 | .textOverflowMulti(@line: 3, @bg: #fff) {
9 | overflow: hidden;
10 | position: relative;
11 | line-height: 1.5em;
12 | max-height: @line * 1.5em;
13 | text-align: justify;
14 | margin-right: -1em;
15 | padding-right: 1em;
16 | &:before {
17 | background: @bg;
18 | content: '...';
19 | padding: 0 1px;
20 | position: absolute;
21 | right: 14px;
22 | bottom: 0;
23 | }
24 | &:after {
25 | background: white;
26 | content: '';
27 | margin-top: 0.2em;
28 | position: absolute;
29 | right: 14px;
30 | width: 1em;
31 | height: 1em;
32 | }
33 | }
34 |
35 | // mixins for clearfix
36 | // ------------------------
37 | .clearfix() {
38 | zoom: 1;
39 | &:before,
40 | &:after {
41 | content: " ";
42 | display: table;
43 | }
44 | &:after {
45 | clear: both;
46 | visibility: hidden;
47 | font-size: 0;
48 | height: 0;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/models/login.js:
--------------------------------------------------------------------------------
1 | import { routerRedux } from 'dva/router';
2 | import { signIn, signOut } from '../services/api';
3 |
4 | export default {
5 | namespace: 'login',
6 |
7 | state: {
8 | status: undefined,
9 | },
10 |
11 | effects: {
12 | *accountSubmit({ payload }, { call, put }) {
13 | yield put({
14 | type: 'changeSubmitting',
15 | payload: true,
16 | });
17 | const response = yield call(signIn, payload);
18 | yield put({
19 | type: 'changeLoginStatus',
20 | payload: response,
21 | });
22 | yield put({
23 | type: 'changeSubmitting',
24 | payload: false,
25 | });
26 | },
27 | *logout(_, { call, put }) {
28 | const response = yield call(signOut);
29 | if (response) {
30 | yield put(routerRedux.push('/user/login'));
31 | }
32 | },
33 | },
34 |
35 | reducers: {
36 | changeLoginStatus(state, { payload }) {
37 | return {
38 | ...state,
39 | status: payload.length > 0 ? 'ok' : 'error',
40 | type: payload.type,
41 | info: payload,
42 | };
43 | },
44 | changeSubmitting(state, { payload }) {
45 | return {
46 | ...state,
47 | status: payload,
48 | submitting: payload,
49 | };
50 | },
51 | },
52 | };
53 |
--------------------------------------------------------------------------------
/src/layouts/UserLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Route } from 'dva/router';
4 | import DocumentTitle from 'react-document-title';
5 | import styles from './UserLayout.less';
6 |
7 | class UserLayout extends React.PureComponent {
8 | static childContextTypes = {
9 | location: PropTypes.object,
10 | }
11 | getChildContext() {
12 | const { location } = this.props;
13 | return { location };
14 | }
15 | getPageTitle() {
16 | const { getRouteData, location } = this.props;
17 | const { pathname } = location;
18 | let title = 'react antd dva';
19 | getRouteData('UserLayout').forEach((item) => {
20 | if (item.path === pathname) {
21 | title = `${item.name} - react antd dva`;
22 | }
23 | });
24 | return title;
25 | }
26 | render() {
27 | const { getRouteData } = this.props;
28 | return (
29 |
30 |
31 | {
32 | getRouteData('UserLayout').map(item =>
33 | (
34 |
40 | )
41 | )
42 | }
43 |
44 |
45 | );
46 | }
47 | }
48 |
49 | export default UserLayout;
50 |
--------------------------------------------------------------------------------
/mock/utils.js:
--------------------------------------------------------------------------------
1 | export const imgMap = {
2 | user: 'https://gw.alipayobjects.com/zos/rmsportal/UjusLxePxWGkttaqqmUI.png',
3 | a: 'https://gw.alipayobjects.com/zos/rmsportal/ZrkcSjizAKNWwJTwcadT.png',
4 | b: 'https://gw.alipayobjects.com/zos/rmsportal/KYlwHMeomKQbhJDRUVvt.png',
5 | c: 'https://gw.alipayobjects.com/zos/rmsportal/gabvleTstEvzkbQRfjxu.png',
6 | d: 'https://gw.alipayobjects.com/zos/rmsportal/jvpNzacxUYLlNsHTtrAD.png',
7 | };
8 |
9 | // refers: https://www.sitepoint.com/get-url-parameters-with-javascript/
10 | export function getUrlParams(url) {
11 | const d = decodeURIComponent;
12 | let queryString = url ? url.split('?')[1] : window.location.search.slice(1);
13 | const obj = {};
14 | if (queryString) {
15 | queryString = queryString.split('#')[0]; // eslint-disable-line
16 | const arr = queryString.split('&');
17 | for (let i = 0; i < arr.length; i += 1) {
18 | const a = arr[i].split('=');
19 | let paramNum;
20 | const paramName = a[0].replace(/\[\d*\]/, (v) => {
21 | paramNum = v.slice(1, -1);
22 | return '';
23 | });
24 | const paramValue = typeof (a[1]) === 'undefined' ? true : a[1];
25 | if (obj[paramName]) {
26 | if (typeof obj[paramName] === 'string') {
27 | obj[paramName] = d([obj[paramName]]);
28 | }
29 | if (typeof paramNum === 'undefined') {
30 | obj[paramName].push(d(paramValue));
31 | } else {
32 | obj[paramName][paramNum] = d(paramValue);
33 | }
34 | } else {
35 | obj[paramName] = d(paramValue);
36 | }
37 | }
38 | }
39 | return obj;
40 | }
41 |
42 | export default {
43 | getUrlParams,
44 | imgMap,
45 | };
46 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "plugins": ["compat"],
5 | "env": {
6 | "browser": true,
7 | "node": true,
8 | "es6": true,
9 | "mocha": true,
10 | "jest": true,
11 | "jasmine": true
12 | },
13 | "rules": {
14 | "linebreak-style": "off",
15 | "generator-star-spacing": [0],
16 | "consistent-return": [0],
17 | "react/forbid-prop-types": [0],
18 | "react/jsx-filename-extension": [1, { "extensions": [".js"] }],
19 | "global-require": [1],
20 | "import/prefer-default-export": [0],
21 | "react/jsx-no-bind": [0],
22 | "react/prop-types": [0],
23 | "react/prefer-stateless-function": [0],
24 | "no-else-return": [0],
25 | "no-restricted-syntax": [0],
26 | "import/no-extraneous-dependencies": [0],
27 | "no-use-before-define": [0],
28 | "jsx-a11y/no-static-element-interactions": [0],
29 | "jsx-a11y/no-noninteractive-element-interactions": [0],
30 | "jsx-a11y/click-events-have-key-events": [0],
31 | "jsx-a11y/anchor-is-valid": [0],
32 | "no-nested-ternary": [0],
33 | "arrow-body-style": [0],
34 | "import/extensions": [0],
35 | "no-bitwise": [0],
36 | "no-cond-assign": [0],
37 | "import/no-unresolved": [0],
38 | "comma-dangle": ["error", {
39 | "arrays": "always-multiline",
40 | "objects": "always-multiline",
41 | "imports": "always-multiline",
42 | "exports": "always-multiline",
43 | "functions": "ignore"
44 | }],
45 | "object-curly-newline": [0],
46 | "function-paren-newline": [0],
47 | "no-restricted-globals": [0],
48 | "require-yield": [1],
49 | "compat/compat": "error"
50 | },
51 | "parserOptions": {
52 | "ecmaFeatures": {
53 | "experimentalObjectRestSpread": true
54 | }
55 | },
56 | "settings": {
57 | "polyfills": ["fetch", "promises"]
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/utils/request.js:
--------------------------------------------------------------------------------
1 | import fetch from 'dva/fetch';
2 | import { notification } from 'antd';
3 | import Config from '../common/config';
4 |
5 | function checkStatus(response) {
6 | if (response.status >= 200 && response.status < 300) {
7 | return response;
8 | }
9 | notification.error({
10 | message: `请求错误 ${response.status}: ${response.url}`,
11 | description: response.statusText,
12 | });
13 | const error = new Error(response.statusText);
14 | error.response = response;
15 | throw error;
16 | }
17 |
18 | /**
19 | * Requests a URL, returning a promise.
20 | *
21 | * @param {string} url The URL we want to request
22 | * @param {object} [options] The options we want to pass to "fetch"
23 | * @return {object} An object containing either "data" or "err"
24 | */
25 | export default function request(sUrl, options) {
26 | const url = Config.api + sUrl;
27 | const defaultOptions = {
28 | mode: 'cors',
29 | // cache: 'force-cache', 表示fetch请求不顾一切的依赖缓存, 即使缓存过期了, 它依然从缓存中读取. 除非没有任何缓存, 那么它将发送一个正常的request.
30 | // credentials: 'include', Fetch 请求默认是不带 cookie 的,需要设置 fetch(url, {credentials: 'include'})
31 | };
32 | const newOptions = { ...defaultOptions, ...options };
33 | if (newOptions.method === 'POST' || newOptions.method === 'PUT') {
34 | newOptions.headers = {
35 | Accept: 'application/json',
36 | 'Content-Type': 'application/json; charset=utf-8',
37 | ...newOptions.headers,
38 | };
39 | newOptions.body = JSON.stringify(newOptions.body);
40 | }
41 |
42 | return fetch(url, newOptions)
43 | .then(checkStatus)
44 | .then(response => response.json())
45 | .catch((error) => {
46 | if (error.code) {
47 | notification.error({
48 | message: error.name,
49 | description: error.message,
50 | });
51 | }
52 | if ('stack' in error && 'message' in error) {
53 | notification.error({
54 | message: `请求错误: ${url}`,
55 | description: error.message,
56 | });
57 | }
58 | return error;
59 | });
60 | }
61 |
--------------------------------------------------------------------------------
/src/common/nav.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 我们为了统一方便的管理路由和页面的关系,将配置信息统一抽离到 common/nav.js 下,同时应用动态路由
3 | */
4 |
5 | import dynamic from 'dva/dynamic';
6 |
7 | // dynamic包装 函数
8 | const dynamicWrapper = (app, models, component) => dynamic({
9 | app,
10 | models: () => models.map(m => import(`../models/${m}.js`)),
11 | component,
12 | });
13 |
14 | // nav data
15 | export const getNavData = app => [
16 | {
17 | component: dynamicWrapper(app, ['user', 'login'], () => import('../layouts/BasicLayout')),
18 | layout: 'BasicLayout',
19 | name: '首页',
20 | path: '/',
21 | children: [
22 | {
23 | name: 'Dashboard',
24 | icon: 'dashboard',
25 | path: 'dashboard',
26 | children: [
27 | {
28 | name: '分析页',
29 | path: 'analysis',
30 | component: dynamicWrapper(app, [], () => import('../routes/Dashboard/Analysis')),
31 | },
32 | {
33 | name: '监控页',
34 | path: 'monitor',
35 | component: dynamicWrapper(app, [], () => import('../routes/Dashboard/Monitor')),
36 | },
37 | {
38 | name: '工作台',
39 | path: 'workplace',
40 | component: dynamicWrapper(app, [], () => import('../routes/Dashboard/Workplace')),
41 | },
42 | ],
43 | },
44 | {
45 | name: '表单页',
46 | path: 'form',
47 | icon: 'form',
48 | children: [
49 | {
50 | name: '基础表单',
51 | path: 'basic-form',
52 | component: dynamicWrapper(app, [], () => import('../routes/Forms/BasicForm')),
53 | },
54 | ],
55 | },
56 | ],
57 | },
58 | {
59 | component: dynamicWrapper(app, [], () => import('../layouts/UserLayout')),
60 | path: '/user',
61 | layout: 'UserLayout',
62 | children: [
63 | {
64 | name: '账户',
65 | icon: 'user',
66 | path: 'user',
67 | children: [
68 | {
69 | name: '登录',
70 | path: 'login',
71 | component: dynamicWrapper(app, ['login'], () => import('../routes/User/Login')),
72 | },
73 | ],
74 | },
75 | ],
76 | },
77 | ];
78 |
--------------------------------------------------------------------------------
/src/models/global.js:
--------------------------------------------------------------------------------
1 | import { messageError } from '../utils/utils';
2 |
3 | export default {
4 | namespace: 'global', // model 的命名空间,同时也是他在全局 state 上的属性,只能用字符串,不支持通过 . 的方式创建多层命名空间
5 |
6 | state: { // 初始值,优先级低于传给 dva() 的 opts.initialState。
7 | collapsed: false,
8 | messageStatus: false,
9 | },
10 |
11 | /**
12 | * 以 key/value 格式定义 effect。用于处理异步操作和业务逻辑,不直接修改 state。由 action 触发,
13 | * 可以触发 action,可以和服务器交互,可以获取全局 state 的数据等等。
14 | * '格式为 *(action, effects) => void 或 [*(action, effects) => void, { type }]。
15 | * type 类型有:
16 | * takeEvery
17 | * takeLatest
18 | * throttle
19 | * watcher
20 | */
21 | effects: {
22 | *changeMessage({ payload }, { call, put }) {
23 | yield put({
24 | type: 'changeMessageStatus',
25 | payload: true,
26 | });
27 | const response = yield call(messageError, payload);
28 | yield put({
29 | type: 'changeMessageStatus',
30 | payload: response,
31 | });
32 | },
33 | },
34 |
35 | /**
36 | * 以 key/value 格式定义 reducer。用于处理同步操作,唯一可以修改 state 的地方。由 action 触发。
37 | * 格式为 (state, action) => newState 或 [(state, action) => newState, enhancer]。
38 | */
39 | reducers: {
40 | changeLayoutCollapsed(state, { payload }) {
41 | return {
42 | ...state,
43 | collapsed: payload,
44 | };
45 | },
46 | changeMessageStatus(state, { payload }) {
47 | return {
48 | ...state,
49 | messageStatus: payload,
50 | };
51 | },
52 | },
53 |
54 | /**
55 | * 以 key/value 格式定义 subscription。subscription 是订阅,用于订阅一个数据源,
56 | * 然后根据需要 dispatch 相应的 action。在 app.start() 时被执行,数据源可以是当前的时间、
57 | * 服务器的 websocket 连接、keyboard 输入、geolocation 变化、history 路由变化等等。
58 | * 格式为 ({ dispatch, history }, done) => unlistenFunction。
59 | * 注意:如果要使用 app.unmodel(),subscription 必须返回 unlisten 方法,用于取消数据订阅。
60 | */
61 | subscriptions: {
62 | setup({ history }) {
63 | // Subscribe history(url) change, trigger `load` action if pathname is `/`
64 | return history.listen(({ pathname, search }) => {
65 | if (typeof window.ga !== 'undefined') {
66 | window.ga('send', 'pageview', pathname + search);
67 | }
68 | });
69 | },
70 | },
71 | };
72 |
--------------------------------------------------------------------------------
/.roadhogrc.mock.js:
--------------------------------------------------------------------------------
1 | import mockjs from 'mockjs';
2 | import { getRule, postRule } from './mock/rule';
3 | import { getActivities, getNotice, getFakeList } from './mock/api';
4 | import { getFakeChartData } from './mock/chart';
5 | import { imgMap } from './mock/utils';
6 | import { getProfileBasicData } from './mock/profile';
7 | import { getProfileAdvancedData } from './mock/profile';
8 | import { getNotices } from './mock/notices';
9 | import { format, delay } from 'roadhog-api-doc';
10 |
11 | // 是否禁用代理
12 | const noProxy = process.env.NO_PROXY === 'true';
13 |
14 | // 代码中会兼容本地 service mock 以及部署站点的静态数据
15 | const proxy = {
16 | // 支持值为 Object 和 Array
17 | 'GET /api/currentUser': {
18 | $desc: "获取当前用户接口",
19 | $params: {
20 | pageSize: {
21 | desc: '分页',
22 | exp: 2,
23 | },
24 | },
25 | $body: {
26 | name: 'Serati Ma',
27 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/dRFVcIqZOYPcSNrlJsqQ.png',
28 | userid: '00000001',
29 | notifyCount: 12,
30 | },
31 | },
32 | // GET POST 可省略
33 | 'GET /api/users': [{
34 | key: '1',
35 | name: 'John Brown',
36 | age: 32,
37 | address: 'New York No. 1 Lake Park',
38 | }, {
39 | key: '2',
40 | name: 'Jim Green',
41 | age: 42,
42 | address: 'London No. 1 Lake Park',
43 | }, {
44 | key: '3',
45 | name: 'Joe Black',
46 | age: 32,
47 | address: 'Sidney No. 1 Lake Park',
48 | }],
49 | 'GET /api/project/notice': getNotice,
50 | 'GET /api/activities': getActivities,
51 | 'GET /api/rule': getRule,
52 | 'POST /api/rule': {
53 | $params: {
54 | pageSize: {
55 | desc: '分页',
56 | exp: 2,
57 | },
58 | },
59 | $body: postRule,
60 | },
61 | 'POST /api/forms': (req, res) => {
62 | res.send({ message: 'Ok' });
63 | },
64 | 'GET /api/tags': mockjs.mock({
65 | 'list|100': [{ name: '@city', 'value|1-100': 150, 'type|0-2': 1 }]
66 | }),
67 | 'GET /api/fake_list': getFakeList,
68 | 'GET /api/fake_chart_data': getFakeChartData,
69 | 'GET /api/profile/basic': getProfileBasicData,
70 | 'GET /api/profile/advanced': getProfileAdvancedData,
71 | 'POST /api/login/account': (req, res) => {
72 | const { password, userName } = req.body;
73 | res.send({ status: password === 'sosout' && userName === 'sosout' ? 'ok' : 'error', type: 'account' });
74 | },
75 | 'POST /api/login/mobile': (req, res) => {
76 | res.send({ status: 'ok', type: 'mobile' });
77 | },
78 | 'POST /api/register': (req, res) => {
79 | res.send({ status: 'ok' });
80 | },
81 | 'GET /api/notices': getNotices,
82 | };
83 |
84 | export default noProxy ? {} : delay(proxy, 1000);
85 |
--------------------------------------------------------------------------------
/src/router.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Router, Route, Switch, Redirect } from 'dva/router';
3 | import { LocaleProvider, Spin } from 'antd';
4 | import zhCN from 'antd/lib/locale-provider/zh_CN';
5 | import dynamic from 'dva/dynamic';
6 | import cloneDeep from 'lodash/cloneDeep';
7 | import Store from 'store';
8 | import { getNavData } from './common/nav';
9 | import { getPlainNode } from './utils/utils';
10 |
11 | import styles from './index.less';
12 | import Config from './common/config';
13 |
14 | // 设置默认的加载组件
15 | dynamic.setDefaultLoadingComponent(() => {
16 | return ;
17 | });
18 |
19 | function getRouteData(navData, path) {
20 | if (!navData.some(item => item.layout === path) ||
21 | !(navData.filter(item => item.layout === path)[0].children)) {
22 | return null;
23 | }
24 | const route = cloneDeep(navData.filter(item => item.layout === path)[0]);
25 | const nodeList = getPlainNode(route.children);
26 | return nodeList;
27 | }
28 |
29 | function getLayout(navData, path) {
30 | if (!navData.some(item => item.layout === path) ||
31 | !(navData.filter(item => item.layout === path)[0].children)) {
32 | return null;
33 | }
34 | const route = navData.filter(item => item.layout === path)[0];
35 | return {
36 | component: route.component,
37 | layout: route.layout,
38 | name: route.name,
39 | path: route.path,
40 | };
41 | }
42 |
43 | // 登录验证
44 | function requireAuth(Layout, props, passProps) {
45 | // 模拟token失效时间
46 | const token = Store.get(Config.USER_TOKEN);
47 | const current = (new Date()).getTime();
48 | if (token && current - token < 7200000) {
49 | return ;
50 | } else {
51 | return ;
52 | }
53 | }
54 |
55 | function RouterConfig({ history, app }) {
56 | const navData = getNavData(app);
57 | const UserLayout = getLayout(navData, 'UserLayout').component;
58 | const BasicLayout = getLayout(navData, 'BasicLayout').component;
59 | const passProps = {
60 | app,
61 | navData: navData.filter((item) => {
62 | return item.layout !== 'UserLayout';
63 | }), // 剔除掉无需登录模块
64 | getRouteData: (path) => {
65 | return getRouteData(navData, path);
66 | },
67 | };
68 | return (
69 |
70 |
71 |
72 | } />
73 | requireAuth(BasicLayout, props, passProps)} />
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | export default RouterConfig;
82 |
--------------------------------------------------------------------------------
/mock/notices.js:
--------------------------------------------------------------------------------
1 | export default {
2 | getNotices(req, res) {
3 | res.json([{
4 | id: '000000001',
5 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
6 | title: '你收到了 14 份新周报',
7 | datetime: '2017-08-09',
8 | type: '通知',
9 | }, {
10 | id: '000000002',
11 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png',
12 | title: '你推荐的 曲妮妮 已通过第三轮面试',
13 | datetime: '2017-08-08',
14 | type: '通知',
15 | }, {
16 | id: '000000003',
17 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png',
18 | title: '这种模板可以区分多种通知类型',
19 | datetime: '2017-08-07',
20 | read: true,
21 | type: '通知',
22 | }, {
23 | id: '000000004',
24 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png',
25 | title: '左侧图标用于区分不同的类型',
26 | datetime: '2017-08-07',
27 | type: '通知',
28 | }, {
29 | id: '000000005',
30 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
31 | title: '内容不要超过两行字,超出时自动截断',
32 | datetime: '2017-08-07',
33 | type: '通知',
34 | }, {
35 | id: '000000006',
36 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
37 | title: '曲丽丽 评论了你',
38 | description: '描述信息描述信息描述信息',
39 | datetime: '2017-08-07',
40 | type: '消息',
41 | }, {
42 | id: '000000007',
43 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
44 | title: '朱偏右 回复了你',
45 | description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
46 | datetime: '2017-08-07',
47 | type: '消息',
48 | }, {
49 | id: '000000008',
50 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
51 | title: '标题',
52 | description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
53 | datetime: '2017-08-07',
54 | type: '消息',
55 | }, {
56 | id: '000000009',
57 | title: '任务名称',
58 | description: '任务需要在 2017-01-12 20:00 前启动',
59 | extra: '未开始',
60 | status: 'todo',
61 | type: '待办',
62 | }, {
63 | id: '000000010',
64 | title: '第三方紧急代码变更',
65 | description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
66 | extra: '马上到期',
67 | status: 'urgent',
68 | type: '待办',
69 | }, {
70 | id: '000000011',
71 | title: '信息安全考试',
72 | description: '指派竹尔于 2017-01-09 前完成更新并发布',
73 | extra: '已耗时 8 天',
74 | status: 'doing',
75 | type: '待办',
76 | }, {
77 | id: '000000012',
78 | title: 'ABCD 版本发布',
79 | description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
80 | extra: '进行中',
81 | status: 'processing',
82 | type: '待办',
83 | }]);
84 | },
85 | };
86 |
--------------------------------------------------------------------------------
/src/utils/utils.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import { message } from 'antd';
3 |
4 | export function fixedZero(val) {
5 | return val * 1 < 10 ? `0${val}` : val;
6 | }
7 |
8 | export function getTimeDistance(type) {
9 | const now = new Date();
10 | const oneDay = 1000 * 60 * 60 * 24;
11 |
12 | if (type === 'today') {
13 | now.setHours(0);
14 | now.setMinutes(0);
15 | now.setSeconds(0);
16 | return [moment(now), moment(now.getTime() + (oneDay - 1000))];
17 | }
18 |
19 | if (type === 'week') {
20 | let day = now.getDay();
21 | now.setHours(0);
22 | now.setMinutes(0);
23 | now.setSeconds(0);
24 |
25 | if (day === 0) {
26 | day = 6;
27 | } else {
28 | day -= 1;
29 | }
30 |
31 | const beginTime = now.getTime() - (day * oneDay);
32 |
33 | return [moment(beginTime), moment(beginTime + ((7 * oneDay) - 1000))];
34 | }
35 |
36 | if (type === 'month') {
37 | const year = now.getFullYear();
38 | const month = now.getMonth();
39 | const nextDate = moment(now).add(1, 'months');
40 | const nextYear = nextDate.year();
41 | const nextMonth = nextDate.month();
42 |
43 | return [moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000)];
44 | }
45 |
46 | if (type === 'year') {
47 | const year = now.getFullYear();
48 |
49 | return [moment(`${year}-01-01 00:00:00`), moment(`${year}-12-31 23:59:59`)];
50 | }
51 | }
52 |
53 | export function getPlainNode(nodeList, parentPath = '') {
54 | const arr = [];
55 | nodeList.forEach((node) => {
56 | const item = node;
57 | item.path = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/');
58 | item.exact = true;
59 | if (item.children && !item.component) {
60 | arr.push(...getPlainNode(item.children, item.path));
61 | } else {
62 | if (item.children && item.component) {
63 | item.exact = false;
64 | }
65 | arr.push(item);
66 | }
67 | });
68 | return arr;
69 | }
70 |
71 | export function digitUppercase(n) {
72 | const fraction = ['角', '分'];
73 | const digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
74 | const unit = [
75 | ['元', '万', '亿'],
76 | ['', '拾', '佰', '仟'],
77 | ];
78 | let num = Math.abs(n);
79 | let s = '';
80 | fraction.forEach((item, index) => {
81 | s += (digit[Math.floor(num * 10 * (10 ** index)) % 10] + item).replace(/零./, '');
82 | });
83 | s = s || '整';
84 | num = Math.floor(num);
85 | for (let i = 0; i < unit[0].length && num > 0; i += 1) {
86 | let p = '';
87 | for (let j = 0; j < unit[1].length && num > 0; j += 1) {
88 | p = digit[num % 10] + unit[1][j] + p;
89 | num = Math.floor(num / 10);
90 | }
91 | s = p.replace(/(零.)*零$/, '').replace(/^$/, '零') + unit[0][i] + s;
92 | }
93 |
94 | return s.replace(/(零.)*零元/, '元').replace(/(零.)+/g, '零').replace(/^整$/, '零元整');
95 | }
96 |
97 | // 弹窗未完全关闭禁止再次提交
98 | export function messageError(payload) {
99 | return new Promise((resolve) => {
100 | message.error(payload, () => {
101 | resolve(false);
102 | });
103 | });
104 | }
105 |
--------------------------------------------------------------------------------
/mock/profile.js:
--------------------------------------------------------------------------------
1 | const basicGoods = [
2 | {
3 | id: '1234561',
4 | name: '矿泉水 550ml',
5 | barcode: '12421432143214321',
6 | price: '2.00',
7 | num: '1',
8 | amount: '2.00',
9 | },
10 | {
11 | id: '1234562',
12 | name: '凉茶 300ml',
13 | barcode: '12421432143214322',
14 | price: '3.00',
15 | num: '2',
16 | amount: '6.00',
17 | },
18 | {
19 | id: '1234563',
20 | name: '好吃的薯片',
21 | barcode: '12421432143214323',
22 | price: '7.00',
23 | num: '4',
24 | amount: '28.00',
25 | },
26 | {
27 | id: '1234564',
28 | name: '特别好吃的蛋卷',
29 | barcode: '12421432143214324',
30 | price: '8.50',
31 | num: '3',
32 | amount: '25.50',
33 | },
34 | ];
35 |
36 | const basicProgress = [
37 | {
38 | key: '1',
39 | time: '2017-10-01 14:10',
40 | rate: '联系客户',
41 | status: 'processing',
42 | operator: '取货员 ID1234',
43 | cost: '5mins',
44 | },
45 | {
46 | key: '2',
47 | time: '2017-10-01 14:05',
48 | rate: '取货员出发',
49 | status: 'success',
50 | operator: '取货员 ID1234',
51 | cost: '1h',
52 | },
53 | {
54 | key: '3',
55 | time: '2017-10-01 13:05',
56 | rate: '取货员接单',
57 | status: 'success',
58 | operator: '取货员 ID1234',
59 | cost: '5mins',
60 | },
61 | {
62 | key: '4',
63 | time: '2017-10-01 13:00',
64 | rate: '申请审批通过',
65 | status: 'success',
66 | operator: '系统',
67 | cost: '1h',
68 | },
69 | {
70 | key: '5',
71 | time: '2017-10-01 12:00',
72 | rate: '发起退货申请',
73 | status: 'success',
74 | operator: '用户',
75 | cost: '5mins',
76 | },
77 | ];
78 |
79 | const advancedOperation1 = [
80 | {
81 | key: 'op1',
82 | type: '订购关系生效',
83 | name: '曲丽丽',
84 | status: 'agree',
85 | updatedAt: '2017-10-03 19:23:12',
86 | memo: '-',
87 | },
88 | {
89 | key: 'op2',
90 | type: '财务复审',
91 | name: '付小小',
92 | status: 'reject',
93 | updatedAt: '2017-10-03 19:23:12',
94 | memo: '不通过原因',
95 | },
96 | {
97 | key: 'op3',
98 | type: '部门初审',
99 | name: '周毛毛',
100 | status: 'agree',
101 | updatedAt: '2017-10-03 19:23:12',
102 | memo: '-',
103 | },
104 | {
105 | key: 'op4',
106 | type: '提交订单',
107 | name: '林东东',
108 | status: 'agree',
109 | updatedAt: '2017-10-03 19:23:12',
110 | memo: '很棒',
111 | },
112 | {
113 | key: 'op5',
114 | type: '创建订单',
115 | name: '汗牙牙',
116 | status: 'agree',
117 | updatedAt: '2017-10-03 19:23:12',
118 | memo: '-',
119 | },
120 | ];
121 |
122 | const advancedOperation2 = [
123 | {
124 | key: 'op1',
125 | type: '订购关系生效',
126 | name: '曲丽丽',
127 | status: 'agree',
128 | updatedAt: '2017-10-03 19:23:12',
129 | memo: '-',
130 | },
131 | ];
132 |
133 | const advancedOperation3 = [
134 | {
135 | key: 'op1',
136 | type: '创建订单',
137 | name: '汗牙牙',
138 | status: 'agree',
139 | updatedAt: '2017-10-03 19:23:12',
140 | memo: '-',
141 | },
142 | ];
143 |
144 | export const getProfileBasicData = {
145 | basicGoods,
146 | basicProgress,
147 | };
148 |
149 | export const getProfileAdvancedData = {
150 | advancedOperation1,
151 | advancedOperation2,
152 | advancedOperation3,
153 | };
154 |
155 | export default {
156 | getProfileBasicData,
157 | getProfileAdvancedData,
158 | };
159 |
--------------------------------------------------------------------------------
/src/routes/User/Login.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'dva';
3 | import { routerRedux } from 'dva/router';
4 | import { Form, Input, Button, Icon } from 'antd';
5 | import Store from 'store';
6 | import Config from '../../common/config';
7 | import styles from './Login.less';
8 |
9 | const FormItem = Form.Item;
10 |
11 | @connect(state => ({
12 | login: state.login,
13 | messageStatus: state.global.messageStatus,
14 | }))
15 | @Form.create()
16 |
17 | export default class Login extends Component {
18 | state = {
19 | type: 'account',
20 | }
21 | componentWillReceiveProps(nextProps) {
22 | // 登录成功
23 | if (nextProps.login.status === 'ok') {
24 | const userInfo = nextProps.login.info;
25 | // 模拟登录成功用户Token,2个小时超时哦
26 | Store.set(Config.USER_TOKEN, (new Date()).getTime());
27 | Store.set(Config.USER_ID, userInfo[0].id); // 存储登录信息
28 |
29 | this.props.dispatch(routerRedux.push('/'));
30 | }
31 |
32 | // 登录失败
33 | if (nextProps.login.status === 'error') {
34 | this.props.dispatch({
35 | type: 'global/changeMessage',
36 | payload: '账户或密码错误',
37 | });
38 | }
39 | }
40 |
41 | handleSubmit = (e) => {
42 | e.preventDefault();
43 | if (this.props.messageStatus) return; // 弹窗未完全关闭禁止再次提交
44 | const { type } = this.state;
45 | this.props.form.validateFields({ force: true },
46 | (err, values) => {
47 | if (!err) {
48 | this.props.dispatch({
49 | type: `login/${type}Submit`,
50 | payload: values,
51 | });
52 | }
53 | }
54 | );
55 | }
56 |
57 | render() {
58 | const { form, login } = this.props;
59 | const { getFieldDecorator } = form;
60 | const { type } = this.state;
61 | return (
62 |
63 |
64 |

65 |
Ant Design
66 |
67 |
103 |
104 | );
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-antd-dva",
3 | "version": "0.0.1",
4 | "description": "基于react + ant-design + dva + Mock 企业级后台管理系统最佳实践",
5 | "private": true,
6 | "scripts": {
7 | "precommit": "npm run lint-staged",
8 | "start": "cross-env PORT=8888 roadhog dev",
9 | "start:no-proxy": "cross-env PORT=8888 NO_PROXY=true roadhog dev",
10 | "build": "roadhog build",
11 | "site": "roadhog-api-doc static && gh-pages -d dist",
12 | "analyze": "roadhog build --analyze",
13 | "lint:style": "stylelint \"src/**/*.less\" --syntax less",
14 | "lint": "eslint --ext .js src mock tests && npm run lint:style",
15 | "lint:fix": "eslint --fix --ext .js src mock tests && npm run lint:style",
16 | "lint-staged": "lint-staged",
17 | "lint-staged:js": "eslint --ext .js",
18 | "test": "jest",
19 | "test:all": "node ./tests/run-tests.js"
20 | },
21 | "dependencies": {
22 | "@babel/polyfill": "^7.0.0-beta.36",
23 | "antd": "^3.0.0-beta.1",
24 | "babel-runtime": "^6.9.2",
25 | "classnames": "^2.2.5",
26 | "core-js": "^2.5.1",
27 | "dva": "^2.1.0",
28 | "g-cloud": "^1.0.2-beta",
29 | "g2": "^2.3.13",
30 | "g2-plugin-slider": "^1.2.1",
31 | "lodash": "^4.17.4",
32 | "lodash-decorators": "^4.4.1",
33 | "lodash.clonedeep": "^4.5.0",
34 | "moment": "^2.19.1",
35 | "numeral": "^2.0.6",
36 | "prop-types": "^15.5.10",
37 | "qs": "^6.5.0",
38 | "react": "^16.0.0",
39 | "react-container-query": "^0.9.1",
40 | "react-document-title": "^2.0.3",
41 | "react-dom": "^16.0.0",
42 | "react-fittext": "^1.0.0",
43 | "store": "^2.0.12"
44 | },
45 | "devDependencies": {
46 | "babel-eslint": "^8.1.2",
47 | "babel-jest": "^22.0.4",
48 | "babel-plugin-dva-hmr": "^0.4.1",
49 | "babel-plugin-import": "^1.6.3",
50 | "babel-plugin-transform-class-properties": "^6.24.1",
51 | "babel-plugin-transform-decorators-legacy": "^1.3.4",
52 | "babel-plugin-transform-runtime": "^6.23.0",
53 | "babel-preset-env": "^1.6.1",
54 | "babel-preset-react": "^6.24.1",
55 | "cross-env": "^5.1.1",
56 | "cross-port-killer": "^1.0.1",
57 | "enzyme": "^3.1.0",
58 | "enzyme-adapter-react-16": "^1.0.2",
59 | "eslint": "^4.14.0",
60 | "eslint-config-airbnb": "^16.0.0",
61 | "eslint-plugin-babel": "^4.0.0",
62 | "eslint-plugin-compat": "^2.1.0",
63 | "eslint-plugin-import": "^2.8.0",
64 | "eslint-plugin-jsx-a11y": "^6.0.3",
65 | "eslint-plugin-markdown": "^1.0.0-beta.6",
66 | "eslint-plugin-react": "^7.0.1",
67 | "gh-pages": "^1.0.0",
68 | "husky": "^0.14.3",
69 | "jest": "^22.0.4",
70 | "jsdom": "^11.5.1",
71 | "lint-staged": "^6.0.0",
72 | "mockjs": "^1.0.1-beta3",
73 | "pro-download": "^1.0.1",
74 | "react-test-renderer": "^16.2.0",
75 | "redbox-react": "^1.5.0",
76 | "roadhog": "^2.0.5",
77 | "roadhog-api-doc": "^0.3.3",
78 | "rollbar": "^2.3.4",
79 | "stylelint": "^8.4.0",
80 | "stylelint-config-standard": "^18.0.0"
81 | },
82 | "optionalDependencies": {
83 | "nightmare": "^2.10.0"
84 | },
85 | "babel": {
86 | "presets": [
87 | "env",
88 | "react"
89 | ],
90 | "plugins": [
91 | "transform-decorators-legacy",
92 | "transform-class-properties"
93 | ]
94 | },
95 | "jest": {
96 | "setupFiles": [
97 | "/tests/setupTests.js"
98 | ],
99 | "testMatch": [
100 | "**/?(*.)(spec|test|e2e).js?(x)"
101 | ],
102 | "setupTestFrameworkScriptFile": "/tests/jasmine.js",
103 | "moduleFileExtensions": [
104 | "js",
105 | "jsx"
106 | ],
107 | "moduleNameMapper": {
108 | "\\.(css|less)$": "/tests/styleMock.js"
109 | }
110 | },
111 | "lint-staged": {
112 | "**/*.{js,jsx}": "lint-staged:js",
113 | "**/*.less": "stylelint --syntax less"
114 | },
115 | "browserslist": [
116 | "> 1%",
117 | "last 2 versions",
118 | "not ie <= 10"
119 | ]
120 | }
121 |
--------------------------------------------------------------------------------
/mock/chart.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 |
3 | // mock data
4 | const visitData = [];
5 | const beginDay = new Date().getTime();
6 |
7 | const fakeY = [7, 5, 4, 2, 4, 7, 5, 6, 5, 9, 6, 3, 1, 5, 3, 6, 5];
8 | for (let i = 0; i < fakeY.length; i += 1) {
9 | visitData.push({
10 | x: moment(new Date(beginDay + (1000 * 60 * 60 * 24 * i))).format('YYYY-MM-DD'),
11 | y: fakeY[i],
12 | });
13 | }
14 |
15 | const visitData2 = [];
16 | const fakeY2 = [1, 6, 4, 8, 3, 7, 2];
17 | for (let i = 0; i < fakeY2.length; i += 1) {
18 | visitData2.push({
19 | x: moment(new Date(beginDay + (1000 * 60 * 60 * 24 * i))).format('YYYY-MM-DD'),
20 | y: fakeY2[i],
21 | });
22 | }
23 |
24 | const salesData = [];
25 | for (let i = 0; i < 12; i += 1) {
26 | salesData.push({
27 | x: `${i + 1}月`,
28 | y: Math.floor(Math.random() * 1000) + 200,
29 | });
30 | }
31 | const searchData = [];
32 | for (let i = 0; i < 50; i += 1) {
33 | searchData.push({
34 | index: i + 1,
35 | keyword: `搜索关键词-${i}`,
36 | count: Math.floor(Math.random() * 1000),
37 | range: Math.floor(Math.random() * 100),
38 | status: Math.floor((Math.random() * 10) % 2),
39 | });
40 | }
41 | const salesTypeData = [
42 | {
43 | x: '家用电器',
44 | y: 4544,
45 | },
46 | {
47 | x: '食用酒水',
48 | y: 3321,
49 | },
50 | {
51 | x: '个护健康',
52 | y: 3113,
53 | },
54 | {
55 | x: '服饰箱包',
56 | y: 2341,
57 | },
58 | {
59 | x: '母婴产品',
60 | y: 1231,
61 | },
62 | {
63 | x: '其他',
64 | y: 1231,
65 | },
66 | ];
67 |
68 | const salesTypeDataOnline = [
69 | {
70 | x: '家用电器',
71 | y: 244,
72 | },
73 | {
74 | x: '食用酒水',
75 | y: 321,
76 | },
77 | {
78 | x: '个护健康',
79 | y: 311,
80 | },
81 | {
82 | x: '服饰箱包',
83 | y: 41,
84 | },
85 | {
86 | x: '母婴产品',
87 | y: 121,
88 | },
89 | {
90 | x: '其他',
91 | y: 111,
92 | },
93 | ];
94 |
95 | const salesTypeDataOffline = [
96 | {
97 | x: '家用电器',
98 | y: 99,
99 | },
100 | {
101 | x: '个护健康',
102 | y: 188,
103 | },
104 | {
105 | x: '服饰箱包',
106 | y: 344,
107 | },
108 | {
109 | x: '母婴产品',
110 | y: 255,
111 | },
112 | {
113 | x: '其他',
114 | y: 65,
115 | },
116 | ];
117 |
118 | const offlineData = [];
119 | for (let i = 0; i < 10; i += 1) {
120 | offlineData.push({
121 | name: `门店${i}`,
122 | cvr: Math.ceil(Math.random() * 9) / 10,
123 | });
124 | }
125 | const offlineChartData = [];
126 | for (let i = 0; i < 20; i += 1) {
127 | offlineChartData.push({
128 | x: (new Date().getTime()) + (1000 * 60 * 30 * i),
129 | y1: Math.floor(Math.random() * 100) + 10,
130 | y2: Math.floor(Math.random() * 100) + 10,
131 | });
132 | }
133 |
134 | const radarOriginData = [
135 | {
136 | name: '个人',
137 | ref: 10,
138 | koubei: 8,
139 | output: 4,
140 | contribute: 5,
141 | hot: 7,
142 | },
143 | {
144 | name: '团队',
145 | ref: 3,
146 | koubei: 9,
147 | output: 6,
148 | contribute: 3,
149 | hot: 1,
150 | },
151 | {
152 | name: '部门',
153 | ref: 4,
154 | koubei: 1,
155 | output: 6,
156 | contribute: 5,
157 | hot: 7,
158 | },
159 | ];
160 |
161 | //
162 | const radarData = [];
163 | const radarTitleMap = {
164 | ref: '引用',
165 | koubei: '口碑',
166 | output: '产量',
167 | contribute: '贡献',
168 | hot: '热度',
169 | };
170 | radarOriginData.forEach((item) => {
171 | Object.keys(item).forEach((key) => {
172 | if (key !== 'name') {
173 | radarData.push({
174 | name: item.name,
175 | label: radarTitleMap[key],
176 | value: item[key],
177 | });
178 | }
179 | });
180 | });
181 |
182 | export const getFakeChartData = {
183 | visitData,
184 | visitData2,
185 | salesData,
186 | searchData,
187 | offlineData,
188 | offlineChartData,
189 | salesTypeData,
190 | salesTypeDataOnline,
191 | salesTypeDataOffline,
192 | radarData,
193 | };
194 |
195 | export default {
196 | getFakeChartData,
197 | };
198 |
--------------------------------------------------------------------------------
/mock/rule.js:
--------------------------------------------------------------------------------
1 | import { getUrlParams } from './utils';
2 |
3 | // mock tableListDataSource
4 | let tableListDataSource = [];
5 | for (let i = 0; i < 46; i += 1) {
6 | tableListDataSource.push({
7 | key: i,
8 | disabled: ((i % 6) === 0),
9 | href: 'https://ant.design',
10 | avatar: ['https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png'][i % 2],
11 | no: `TradeCode ${i}`,
12 | title: `一个任务名称 ${i}`,
13 | owner: '曲丽丽',
14 | description: '这是一段描述',
15 | callNo: Math.floor(Math.random() * 1000),
16 | status: Math.floor(Math.random() * 10) % 4,
17 | updatedAt: new Date(`2017-07-${Math.floor(i / 2) + 1}`),
18 | createdAt: new Date(`2017-07-${Math.floor(i / 2) + 1}`),
19 | progress: Math.ceil(Math.random() * 100),
20 | });
21 | }
22 |
23 | export function getRule(req, res, u) {
24 | let url = u;
25 | if (!url || Object.prototype.toString.call(url) !== '[object String]') {
26 | url = req.url; // eslint-disable-line
27 | }
28 |
29 | const params = getUrlParams(url);
30 |
31 | let dataSource = [...tableListDataSource];
32 |
33 | if (params.sorter) {
34 | const s = params.sorter.split('_');
35 | dataSource = dataSource.sort((prev, next) => {
36 | if (s[1] === 'descend') {
37 | return next[s[0]] - prev[s[0]];
38 | }
39 | return prev[s[0]] - next[s[0]];
40 | });
41 | }
42 |
43 | if (params.status) {
44 | const status = params.status.split(',');
45 | let filterDataSource = [];
46 | status.forEach((s) => {
47 | filterDataSource = filterDataSource.concat(
48 | [...dataSource].filter(data => parseInt(data.status, 10) === parseInt(s[0], 10))
49 | );
50 | });
51 | dataSource = filterDataSource;
52 | }
53 |
54 | if (params.no) {
55 | dataSource = dataSource.filter(data => data.no.indexOf(params.no) > -1);
56 | }
57 |
58 | let pageSize = 10;
59 | if (params.pageSize) {
60 | pageSize = params.pageSize * 1;
61 | }
62 |
63 | const result = {
64 | list: dataSource,
65 | pagination: {
66 | total: dataSource.length,
67 | pageSize,
68 | current: parseInt(params.currentPage, 10) || 1,
69 | },
70 | };
71 |
72 | if (res && res.json) {
73 | res.json(result);
74 | } else {
75 | return result;
76 | }
77 | }
78 |
79 | export function postRule(req, res, u, b) {
80 | let url = u;
81 | if (!url || Object.prototype.toString.call(url) !== '[object String]') {
82 | url = req.url; // eslint-disable-line
83 | }
84 |
85 | const body = (b && b.body) || req.body;
86 | const { method, no, description } = body;
87 |
88 | switch (method) {
89 | /* eslint no-case-declarations:0 */
90 | case 'delete':
91 | tableListDataSource = tableListDataSource.filter(item => no.indexOf(item.no) === -1);
92 | break;
93 | case 'post':
94 | const i = Math.ceil(Math.random() * 10000);
95 | tableListDataSource.unshift({
96 | key: i,
97 | href: 'https://ant.design',
98 | avatar: ['https://gw.alipayobjects.com/zos/rmsportal/eeHMaZBwmTvLdIwMfBpg.png', 'https://gw.alipayobjects.com/zos/rmsportal/udxAbMEhpwthVVcjLXik.png'][i % 2],
99 | no: `TradeCode ${i}`,
100 | title: `一个任务名称 ${i}`,
101 | owner: '曲丽丽',
102 | description,
103 | callNo: Math.floor(Math.random() * 1000),
104 | status: Math.floor(Math.random() * 10) % 2,
105 | updatedAt: new Date(),
106 | createdAt: new Date(),
107 | progress: Math.ceil(Math.random() * 100),
108 | });
109 | break;
110 | default:
111 | break;
112 | }
113 |
114 | const result = {
115 | list: tableListDataSource,
116 | pagination: {
117 | total: tableListDataSource.length,
118 | },
119 | };
120 |
121 | if (res && res.json) {
122 | res.json(result);
123 | } else {
124 | return result;
125 | }
126 | }
127 |
128 | export default {
129 | getRule,
130 | postRule,
131 | };
132 |
--------------------------------------------------------------------------------
/src/layouts/BasicLayout.less:
--------------------------------------------------------------------------------
1 | @import "~antd/lib/style/themes/default.less";
2 |
3 | .basic-layout {
4 | height: 100vh;
5 | .basic-header {
6 | position: fixed;
7 | top: 0;
8 | left: 0;
9 | background-color: #09c;
10 | height: 50px;
11 | line-height: 50px;
12 | color: #fff;
13 | width: 100%;
14 | z-index: 10;
15 | padding: 0;
16 | .header-home {
17 | position: relative;
18 | display: block;
19 | width: 50px;
20 | background: #0087b4;
21 | font-size: 28px;
22 | color: #fff;
23 | text-align: center;
24 | height: 50px;
25 | line-height: 50px;
26 | overflow: hidden;
27 | float: left;
28 | &:before {
29 | content: '';
30 | position: absolute;
31 | width: 28px;
32 | height: 28px;
33 | left: 11px;
34 | top: 11px;
35 | display: inline-block;
36 | background: url(https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg) no-repeat center center;
37 | background-size: cover;
38 | }
39 | }
40 | .header-home-link {
41 | display: inline-block;
42 | height: 50px;
43 | box-sizing: border-box;
44 | padding: 0 20px;
45 | color: #fff;
46 | font-size: 14px;
47 | line-height: 50px;
48 | border-right: 1px solid #008fbf;
49 | &:focus {
50 | text-decoration: none;
51 | }
52 | }
53 | .header-right {
54 | float: right;
55 | line-height: 50px;
56 | height: 50px;
57 | display: block;
58 | z-index: 2;
59 | background: #09c;
60 | color: #fff;
61 | font-size: 14px;
62 | text-overflow: ellipsis;
63 | white-space: nowrap;
64 | overflow: hidden;
65 | cursor: pointer;
66 | .user-name {
67 | width: 100px;
68 | padding: 0 10px;
69 | border-left: 1px solid #008fbf;
70 | text-align: center;
71 | }
72 | .avatar {
73 | background: rgba(255, 255, 255, .85);
74 | vertical-align: middle;
75 | margin-right: 5px;
76 | }
77 | }
78 | }
79 | .basic-sider {
80 | min-height: 100vh;
81 | box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
82 | flex: 0 0 180px !important;
83 | max-width: 180px !important;
84 | min-width: 180px !important;
85 | width: 180px !important;
86 | margin-top: 50px;
87 | overflow: hidden;
88 | :global {
89 | .ant-layout-sider-trigger {
90 | width: 180px !important;
91 | }
92 | }
93 | &.collapsed {
94 | flex: 0 0 80px !important;
95 | max-width: 80px !important;
96 | min-width: 80px !important;
97 | width: 80px !important;
98 | :global {
99 | .ant-layout-sider-trigger {
100 | width: 80px !important;
101 | }
102 | }
103 | }
104 | .basic-menu {
105 | padding-bottom: 80px;
106 | width: 200px;
107 | overflow-x: hidden;
108 | overflow-y: scroll;
109 | &.collapsed {
110 | width: 100px;
111 | :global {
112 | .ant-menu-submenu {
113 | width: 100px;
114 | }
115 | }
116 | }
117 | .basic-menu-item {
118 | padding-left: 68px !important;
119 | }
120 | :global {
121 | .ant-menu-submenu {
122 | width: 200px;
123 | }
124 | }
125 | }
126 | }
127 | .basic-content {
128 | width: 100%;
129 | margin-top: 50px;
130 | padding: 12px 20px 40px 20px;
131 | overflow-y: scroll;
132 | min-height: 100%;
133 | &::-webkit-scrollbar-thumb {
134 | background-color: transparent;
135 | }
136 |
137 | &::-webkit-scrollbar {
138 | width: 8px;
139 | height: 8px;
140 | }
141 | }
142 | .basic-footer {
143 | position: fixed;
144 | height: 30px;
145 | left: 0;
146 | line-height: 30px;
147 | padding: 0;
148 | bottom: 0;
149 | width: 100%;
150 | text-align: center;
151 | }
152 | }
153 |
154 | :global {
155 | .ant-menu-dark {
156 | .ant-menu-sub {
157 | margin-left: -20px;
158 | }
159 | }
160 | .ant-menu-submenu-arrow {
161 | margin-right: 20px;
162 | }
163 | .ant-layout-sider-children {
164 | width: 200px;
165 | overflow-y: scroll;
166 | }
167 | .ant-dropdown {
168 | .anticon {
169 | margin-right: 5px;
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-antd-dva
2 |
3 | 基于react + ant-design + dva + Mock 企业级后台管理系统最佳实践
4 |
5 | - 预览:http://dva.sosout.com/
6 |
7 | ## 特性
8 |
9 | - :gem: **优雅美观**:基于 Ant Design 体系精心设计
10 | - :rocket: **最新技术栈**:使用 React/dva/antd 等前端前沿技术开发
11 | - :1234: **Mock 数据**:实用的本地数据调试方案
12 |
13 | ## 模板
14 | - [x] roadhog版本升级2.0
15 | - [x] 项目搭建
16 | - [x] 登录
17 | - [x] 基础布局
18 | - [ ] 分析页
19 | ## 使用
20 |
21 | ```bash
22 | $ git clone https://github.com/sosout/react-antd-dva.git
23 | $ cd react-antd-dva
24 | $ yarn install
25 | $ yarn start # 访问 http://localhost:8888
26 | ```
27 |
28 | ## 兼容性
29 |
30 | 现代浏览器及 IE11。
31 |
32 |
33 | ## dva用法
34 |
35 | ### dynamic
36 |
37 | 解决组件动态加载问题的 util 方法,基于 react-async-component 实现。比如:
38 |
39 | ```javascript
40 | import dynamic from 'dva/dynamic';
41 |
42 | const UserPageComponent = dynamic({
43 | app,
44 | models: () => [
45 | import('./models/users'),
46 | ],
47 | component: () => import('./routes/UserPage'),
48 | });
49 | ```
50 |
51 | ## react-router 4.0
52 |
53 | ### exact(boolean类型)
54 |
55 | 如果为 true, 则仅在位置完全匹配时才应用。
56 |
57 | ```javascript
58 | path location.pathname exact matches?
59 | /one /one/two true no
60 | /one /one/two false yes
61 | ```
62 |
63 | ## React PureComponent
64 |
65 | ### 为什么使用?
66 |
67 | React15.3中新加了一个 PureComponent 类,顾名思义, pure 是纯的意思, PureComponent 也就是纯组件,取代其前身 PureRenderMixin , PureComponent 是优化 React 应用程序最重要的方法之一,易于实施,只要把继承类从 Component 换成 PureComponent 即可,可以减少不必要的 render 操作的次数,从而提高性能,而且可以少写 shouldComponentUpdate 函数,节省了点代码。
68 |
69 | ### 原理
70 |
71 | 当组件更新时,如果组件的 props 和 state 都没发生改变, render 方法就不会触发,省去 Virtual DOM 的生成和比对过程,达到提升性能的目的。具体就是 React 自动帮我们做了一层浅比较:
72 |
73 | ```javascript
74 | if (this._compositeType === CompositeTypes.PureClass) {
75 | shouldUpdate = !shallowEqual(prevProps, nextProps)
76 | || !shallowEqual(inst.state, nextState);
77 | }
78 | ````
79 |
80 | 而 shallowEqual 又做了什么呢?会比较 Object.keys(state | props) 的长度是否一致,每一个 key 是否两者都有,并且是否是一个引用,也就是只比较了第一层的值,确实很浅,所以深层的嵌套数据是对比不出来的。
81 |
82 | ### 使用指南
83 |
84 | #### 易变数据不能使用一个引用
85 |
86 | 示例:
87 |
88 | ```javascript
89 | class App extends PureComponent {
90 | state = {
91 | items: [1, 2, 3]
92 | }
93 | handleClick = () => {
94 | const { items } = this.state;
95 | items.pop();
96 | this.setState({ items });
97 | }
98 | render() {
99 | return (
100 |
101 | {this.state.items.map(i => - {i}
)}
102 |
103 |
104 |
)
105 | }
106 | }
107 | ```
108 |
109 | 会发现,无论怎么点 delete 按钮, li 都不会变少,因为 items 用的是一个引用, shallowEqual 的结果为 true 。改正:
110 |
111 | ```javascript
112 | handleClick = () => {
113 | const { items } = this.state;
114 | items.pop();
115 | this.setState({ items: [].concat(items) });
116 | }
117 | ```
118 |
119 | 这样每次改变都会产生一个新的数组,也就可以 render 了。这里有一个矛盾的地方,如果没有 items.pop(); 操作,每次 items 数据并没有变,但还是 render 了,这不就很操蛋么?呵呵,数据都不变,你 setState 干嘛?
120 |
121 | #### 不变数据使用一个引用
122 |
123 | ##### 子组件数据
124 |
125 | 上面易变数据不能使用一个引用的案例中有一个点击删除操作,如果我们删除的代码这么写:
126 |
127 | ```javascript
128 | handleClick = () => {
129 | const { items } = this.state;
130 | items.splice(items.length - 1, 1);
131 | this.setState({ items });
132 | }
133 | ```
134 |
135 | items 的引用也是改变的,但如果 items 里面是引用类型数据:
136 |
137 | ```javascript
138 | items: [{a: 1}, {a: 2}, {a: 3}]
139 | ```
140 |
141 | 这个时候
142 |
143 | ```javascript
144 | state.items[0] === nextState.items[0] // false
145 | ```
146 |
147 | 子组件里还是re-render了。这样就需要我们保证不变的子组件数据的引用不能改变。这个时候可以使用immutable-js函数库。
148 |
149 | ##### 函数属性
150 |
151 | 我们在给组件传一个函数的时候,有时候总喜欢:
152 |
153 | ```javascript
154 | // 1
155 | this.props.update(e.target.value)} />
156 | // 2
157 | update(e) {
158 | this.props.update(e.target.value)
159 | }
160 | render() {
161 | return
162 | }
163 | ```
164 |
165 | 由于每次 render 操作 MyInput 组件的 onChange 属性都会返回一个新的函数,由于引用不一样,所以父组件的 render 也会导致 MyInput 组件的 render ,即使没有任何改动,所以需要尽量避免这样的写法,最好这样写:
166 |
167 | ```javascript
168 | // 1,2
169 | update = (e) => {
170 | this.props.update(e.target.value)
171 | }
172 | render() {
173 | return
174 | }
175 | ```
176 |
177 | ##### 空对象、空数组或固定对象
178 |
179 | 有时候后台返回的数据中,数组长度为0或者对象没有属性会直接给一个 null ,这时候我们需要做一些容错:
180 |
181 | ```javascript
182 | class App extends PureComponent {
183 | state = {
184 | items: [{ name: 'test1' }, null, { name: 'test3' }]
185 | }
186 | store = (id, value) => {
187 | const { items } = this.state;
188 | items[id] = assign({}, items[id], { name: value });
189 | this.setState({ items: [].concat(items) });
190 | }
191 | render() {
192 | return (
193 |
194 | {this.state.items.map((i, k) =>
195 | )
196 | }
197 |
198 |
)
199 | }
200 | }
201 | ```
202 |
203 | 当某一个子组件调用 store 函数改变了自己的那条属性,触发 render 操作,如果数据是 null 的话 data 属性每次都是一个 {},{} ==== {} 是 false 的,这样无端的让这几个子组件重新 render 了。{ color: 'red' }也是一样。
204 |
205 | 最好设置一个 defaultValue 为 {},如下:
206 |
207 | ```javascript
208 | static defaultValue = {}
209 | const style = { color: 'red' };
210 |
211 | ```
212 |
213 | ### 复杂状态与简单状态不要共用一个组件
214 |
215 | 这点可能和 PureComponent 没多少关系,但做的不好可能会浪费很多性能,比如一个页面上面一部分是一个复杂的列表,下面是一个输入框,抽象代码:
216 |
217 | ```javascript
218 | change = (e) => {
219 | this.setState({ value: e.target.value });
220 | }
221 | render() {
222 | return (
223 |
224 | {this.state.items.map((i, k) => - {...}
)}
225 |
226 |
227 |
)
228 | }
229 | ```
230 |
231 | 表单和列表其实是没有什么关联的,表单的值也可能经常变动,但它的会给列表也带来必然的 diff 操作,这是没必要的,最好是给列表抽出成一个单独的 PureComponent 组件,这样 state.items 不变的话,列表就不会重新 render 了。
232 |
233 | ### 与 shouldComponentUpdate 共存
234 |
235 | 如果 PureComponent 里有 shouldComponentUpdate 函数的话,直接使用 shouldComponentUpdate 的结果作为是否更新的依据,没有 shouldComponentUpdate 函数的话,才会去判断是不是 PureComponent ,是的话再去做 shallowEqual 浅比较。
236 |
237 | ```javascript
238 | // 这个变量用来控制组件是否需要更新
239 | var shouldUpdate = true;
240 | // inst 是组件实例
241 | if (inst.shouldComponentUpdate) {
242 | shouldUpdate = inst.shouldComponentUpdate(nextProps, nextState, nextContext);
243 | } else {
244 | if (this._compositeType === CompositeType.PureClass) {
245 | shouldUpdate = !shallowEqual(prevProps, nextProps) ||
246 | !shallowEqual(inst.state, nextState);
247 | }
248 | }
249 | ```
250 |
251 | ### 老版本兼容写法
252 |
253 | ```javascript
254 | import React { PureComponent, Component } from 'react';
255 | class Foo extends (PureComponent || Component) {
256 | //...
257 | }
258 | ```
259 |
260 | 这样在老版本的 React 里也不会挂掉。
261 |
262 | ### 总结
263 |
264 | PureComponent 真正起作用的,只是在一些纯展示组件上,复杂组件用了也没关系,反正 shallowEqual 那一关就过不了,不过记得 props 和 state 不能使用同一个引用哦。
265 |
--------------------------------------------------------------------------------
/mock/api.js:
--------------------------------------------------------------------------------
1 | import { getUrlParams } from './utils';
2 |
3 | const titles = [
4 | 'Alipay',
5 | 'Angular',
6 | 'Ant Design',
7 | 'Ant Design Pro',
8 | 'Bootstrap',
9 | 'React',
10 | 'Vue',
11 | 'Webpack',
12 | ];
13 | const avatars = [
14 | 'https://gw.alipayobjects.com/zos/rmsportal/WdGqmHpayyMjiEhcKoVE.png', // Alipay
15 | 'https://gw.alipayobjects.com/zos/rmsportal/zOsKZmFRdUtvpqCImOVY.png', // Angular
16 | 'https://gw.alipayobjects.com/zos/rmsportal/dURIMkkrRFpPgTuzkwnB.png', // Ant Design
17 | 'https://gw.alipayobjects.com/zos/rmsportal/sfjbOqnsXXJgNCjCzDBL.png', // Ant Design Pro
18 | 'https://gw.alipayobjects.com/zos/rmsportal/siCrBXXhmvTQGWPNLBow.png', // Bootstrap
19 | 'https://gw.alipayobjects.com/zos/rmsportal/kZzEzemZyKLKFsojXItE.png', // React
20 | 'https://gw.alipayobjects.com/zos/rmsportal/ComBAopevLwENQdKWiIn.png', // Vue
21 | 'https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png', // Webpack
22 | ];
23 | const covers = [
24 | 'https://gw.alipayobjects.com/zos/rmsportal/uMfMFlvUuceEyPpotzlq.png',
25 | 'https://gw.alipayobjects.com/zos/rmsportal/iZBVOIhGJiAnhplqjvZW.png',
26 | 'https://gw.alipayobjects.com/zos/rmsportal/uVZonEtjWwmUZPBQfycs.png',
27 | 'https://gw.alipayobjects.com/zos/rmsportal/gLaIAoVWTtLbBWZNYEMg.png',
28 | ];
29 | const desc = [
30 | '那是一种内在的东西, 他们到达不了,也无法触及的',
31 | '希望是一个好东西,也许是最好的,好东西是不会消亡的',
32 | '生命就像一盒巧克力,结果往往出人意料',
33 | '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆',
34 | '那时候我只会想自己想要什么,从不想自己拥有什么',
35 | ];
36 |
37 | const user = [
38 | '付小小',
39 | '曲丽丽',
40 | '林东东',
41 | '周星星',
42 | '吴加好',
43 | '朱偏右',
44 | '鱼酱',
45 | '乐哥',
46 | '谭小仪',
47 | '仲尼',
48 | ];
49 |
50 | export function fakeList(count) {
51 | const list = [];
52 | for (let i = 0; i < count; i += 1) {
53 | list.push({
54 | id: `fake-list-${i}`,
55 | owner: user[i % 10],
56 | title: titles[i % 8],
57 | avatar: avatars[i % 8],
58 | cover: parseInt(i / 4, 10) % 2 === 0 ? covers[i % 4] : covers[3 - (i % 4)],
59 | status: ['active', 'exception', 'normal'][i % 3],
60 | percent: Math.ceil(Math.random() * 50) + 50,
61 | logo: avatars[i % 8],
62 | href: 'https://ant.design',
63 | updatedAt: new Date(new Date().getTime() - (1000 * 60 * 60 * 2 * i)),
64 | createdAt: new Date(new Date().getTime() - (1000 * 60 * 60 * 2 * i)),
65 | subDescription: desc[i % 5],
66 | description: '在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,这些类似的组件会被抽离成一套标准规范。',
67 | activeUser: Math.ceil(Math.random() * 100000) + 100000,
68 | newUser: Math.ceil(Math.random() * 1000) + 1000,
69 | star: Math.ceil(Math.random() * 100) + 100,
70 | like: Math.ceil(Math.random() * 100) + 100,
71 | message: Math.ceil(Math.random() * 10) + 10,
72 | content: '段落示意:蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。蚂蚁金服设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。',
73 | members: [
74 | {
75 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ZiESqWwCXBRQoaPONSJe.png',
76 | name: '曲丽丽',
77 | },
78 | {
79 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/tBOxZPlITHqwlGjsJWaF.png',
80 | name: '王昭君',
81 | },
82 | {
83 | avatar: 'https://gw.alipayobjects.com/zos/rmsportal/sBxjgqiuHMGRkIjqlQCd.png',
84 | name: '董娜娜',
85 | },
86 | ],
87 | });
88 | }
89 |
90 | return list;
91 | }
92 |
93 | export function getFakeList(req, res, u) {
94 | let url = u;
95 | if (!url || Object.prototype.toString.call(url) !== '[object String]') {
96 | url = req.url; // eslint-disable-line
97 | }
98 |
99 | const params = getUrlParams(url);
100 |
101 | const count = (params.count * 1) || 20;
102 |
103 | const result = fakeList(count);
104 |
105 | if (res && res.json) {
106 | res.json(result);
107 | } else {
108 | return result;
109 | }
110 | }
111 |
112 | export const getNotice = [
113 | {
114 | id: 'xxx1',
115 | title: titles[0],
116 | logo: avatars[0],
117 | description: '那是一种内在的东西,他们到达不了,也无法触及的',
118 | updatedAt: new Date(),
119 | member: '科学搬砖组',
120 | href: '',
121 | memberLink: '',
122 | },
123 | {
124 | id: 'xxx2',
125 | title: titles[1],
126 | logo: avatars[1],
127 | description: '希望是一个好东西,也许是最好的,好东西是不会消亡的',
128 | updatedAt: new Date('2017-07-24'),
129 | member: '全组都是吴彦祖',
130 | href: '',
131 | memberLink: '',
132 | },
133 | {
134 | id: 'xxx3',
135 | title: titles[2],
136 | logo: avatars[2],
137 | description: '城镇中有那么多的酒馆,她却偏偏走进了我的酒馆',
138 | updatedAt: new Date(),
139 | member: '中二少女团',
140 | href: '',
141 | memberLink: '',
142 | },
143 | {
144 | id: 'xxx4',
145 | title: titles[3],
146 | logo: avatars[3],
147 | description: '那时候我只会想自己想要什么,从不想自己拥有什么',
148 | updatedAt: new Date('2017-07-23'),
149 | member: '程序员日常',
150 | href: '',
151 | memberLink: '',
152 | },
153 | {
154 | id: 'xxx5',
155 | title: titles[4],
156 | logo: avatars[4],
157 | description: '凛冬将至',
158 | updatedAt: new Date('2017-07-23'),
159 | member: '高逼格设计天团',
160 | href: '',
161 | memberLink: '',
162 | },
163 | {
164 | id: 'xxx6',
165 | title: titles[5],
166 | logo: avatars[5],
167 | description: '生命就像一盒巧克力,结果往往出人意料',
168 | updatedAt: new Date('2017-07-23'),
169 | member: '骗你来学计算机',
170 | href: '',
171 | memberLink: '',
172 | },
173 | ];
174 |
175 | export const getActivities = [
176 | {
177 | id: 'trend-1',
178 | updatedAt: new Date(),
179 | user: {
180 | name: '林东东',
181 | avatar: avatars[0],
182 | },
183 | group: {
184 | name: '高逼格设计天团',
185 | link: 'http://github.com/',
186 | },
187 | project: {
188 | name: '六月迭代',
189 | link: 'http://github.com/',
190 | },
191 | template: '在 @{group} 新建项目 @{project}',
192 | },
193 | {
194 | id: 'trend-2',
195 | updatedAt: new Date(),
196 | user: {
197 | name: '付小小',
198 | avatar: avatars[1],
199 | },
200 | group: {
201 | name: '高逼格设计天团',
202 | link: 'http://github.com/',
203 | },
204 | project: {
205 | name: '六月迭代',
206 | link: 'http://github.com/',
207 | },
208 | template: '在 @{group} 新建项目 @{project}',
209 | },
210 | {
211 | id: 'trend-3',
212 | updatedAt: new Date(),
213 | user: {
214 | name: '曲丽丽',
215 | avatar: avatars[2],
216 | },
217 | group: {
218 | name: '中二少女团',
219 | link: 'http://github.com/',
220 | },
221 | project: {
222 | name: '六月迭代',
223 | link: 'http://github.com/',
224 | },
225 | template: '在 @{group} 新建项目 @{project}',
226 | },
227 | {
228 | id: 'trend-4',
229 | updatedAt: new Date(),
230 | user: {
231 | name: '周星星',
232 | avatar: avatars[3],
233 | },
234 | project: {
235 | name: '5 月日常迭代',
236 | link: 'http://github.com/',
237 | },
238 | template: '将 @{project} 更新至已发布状态',
239 | },
240 | {
241 | id: 'trend-5',
242 | updatedAt: new Date(),
243 | user: {
244 | name: '朱偏右',
245 | avatar: avatars[4],
246 | },
247 | project: {
248 | name: '工程效能',
249 | link: 'http://github.com/',
250 | },
251 | comment: {
252 | name: '留言',
253 | link: 'http://github.com/',
254 | },
255 | template: '在 @{project} 发布了 @{comment}',
256 | },
257 | {
258 | id: 'trend-6',
259 | updatedAt: new Date(),
260 | user: {
261 | name: '乐哥',
262 | avatar: avatars[5],
263 | },
264 | group: {
265 | name: '程序员日常',
266 | link: 'http://github.com/',
267 | },
268 | project: {
269 | name: '品牌迭代',
270 | link: 'http://github.com/',
271 | },
272 | template: '在 @{group} 新建项目 @{project}',
273 | },
274 | ];
275 |
276 |
277 | export default {
278 | getNotice,
279 | getActivities,
280 | getFakeList,
281 | };
282 |
--------------------------------------------------------------------------------
/src/layouts/BasicLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Layout, Menu, Icon, Tag, Dropdown, Avatar } from 'antd';
4 | import DocumentTitle from 'react-document-title';
5 | import { connect } from 'dva';
6 | import { Link, Route, Redirect, Switch } from 'dva/router';
7 | import moment from 'moment';
8 | import groupBy from 'lodash/groupBy';
9 | import { ContainerQuery } from 'react-container-query';
10 | import classNames from 'classnames';
11 | import Debounce from 'lodash-decorators/debounce';
12 | import Store from 'store';
13 | import Config from '../common/config';
14 | import styles from './BasicLayout.less';
15 |
16 | const { SubMenu } = Menu;
17 | const { Header, Content, Footer, Sider } = Layout;
18 |
19 | const query = {
20 | 'screen-xs': {
21 | maxWidth: 575,
22 | },
23 | 'screen-sm': {
24 | minWidth: 576,
25 | maxWidth: 767,
26 | },
27 | 'screen-md': {
28 | minWidth: 768,
29 | maxWidth: 991,
30 | },
31 | 'screen-lg': {
32 | minWidth: 992,
33 | maxWidth: 1199,
34 | },
35 | 'screen-xl': {
36 | minWidth: 1200,
37 | },
38 | };
39 |
40 | class BasicLayout extends React.PureComponent {
41 | static childContextTypes = {
42 | location: PropTypes.object,
43 | breadcrumbNameMap: PropTypes.object,
44 | }
45 | constructor(props) {
46 | super(props);
47 | // 把一级 Layout 的 children 作为菜单项
48 | this.menus = props.navData.reduce((arr, current) => arr.concat(current.children), []);
49 | this.state = {
50 | openKeys: this.getDefaultCollapsedSubMenus(props),
51 | };
52 | }
53 | getChildContext() {
54 | const { location, navData, getRouteData } = this.props;
55 | const routeData = getRouteData('BasicLayout');
56 | const firstMenuData = navData.reduce((arr, current) => arr.concat(current.children), []);
57 | const menuData = this.getMenuData(firstMenuData, '');
58 | const breadcrumbNameMap = {};
59 |
60 | routeData.concat(menuData).forEach((item) => {
61 | breadcrumbNameMap[item.path] = item.name;
62 | });
63 | return { location, breadcrumbNameMap };
64 | }
65 | componentDidMount() {
66 | // 获取用户信息
67 | this.props.dispatch({
68 | type: 'user/fetchCurrent',
69 | payload: { id: Store.get(Config.USER_ID) },
70 | });
71 | }
72 | componentWillUnmount() {
73 | this.triggerResizeEvent.cancel();
74 | }
75 | onCollapse = (collapsed) => {
76 | this.props.dispatch({
77 | type: 'global/changeLayoutCollapsed',
78 | payload: collapsed,
79 | });
80 | }
81 | onMenuClick = ({ key }) => {
82 | if (key === 'logout') {
83 | this.props.dispatch({
84 | type: 'login/logout',
85 | });
86 | }
87 | }
88 | getMenuData = (data, parentPath) => {
89 | let arr = [];
90 | data.forEach((item) => {
91 | if (item.children) {
92 | arr.push({ path: `${parentPath}/${item.path}`, name: item.name });
93 | arr = arr.concat(this.getMenuData(item.children, `${parentPath}/${item.path}`));
94 | }
95 | });
96 | return arr;
97 | }
98 | getDefaultCollapsedSubMenus(props) {
99 | const currentMenuSelectedKeys = [...this.getCurrentMenuSelectedKeys(props)];
100 | currentMenuSelectedKeys.splice(-1, 1);
101 | if (currentMenuSelectedKeys.length === 0) {
102 | return ['dashboard'];
103 | }
104 | return currentMenuSelectedKeys;
105 | }
106 | getCurrentMenuSelectedKeys(props) {
107 | const { location: { pathname } } = props || this.props;
108 | const keys = pathname.split('/').slice(1);
109 | if (keys.length === 1 && keys[0] === '') {
110 | return [this.menus[0].key];
111 | }
112 | return keys;
113 | }
114 | getNavMenuItems(menusData, parentPath = '') {
115 | if (!menusData) {
116 | return [];
117 | }
118 | return menusData.map((item) => {
119 | if (!item.name) {
120 | return null;
121 | }
122 | let itemPath;
123 | if (item.path.indexOf('http') === 0) {
124 | itemPath = item.path;
125 | } else {
126 | itemPath = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/');
127 | }
128 | if (item.children && item.children.some(child => child.name)) {
129 | return (
130 |
134 |
135 | {item.name}
136 |
137 | ) : item.name
138 | }
139 | key={item.key || item.path}
140 | >
141 | {this.getNavMenuItems(item.children, itemPath)}
142 |
143 | );
144 | }
145 | const icon = item.icon && ;
146 | return (
147 |
148 | {
149 | /^https?:\/\//.test(itemPath) ? (
150 |
151 | {icon}{item.name}
152 |
153 | ) : (
154 |
159 | {icon}{item.name}
160 |
161 | )
162 | }
163 |
164 | );
165 | });
166 | }
167 | getPageTitle() { // 获取页面标题
168 | const { location, getRouteData } = this.props;
169 | const { pathname } = location;
170 | let title = 'React Antd Dva';
171 | getRouteData('BasicLayout').forEach((item) => {
172 | if (item.path === pathname) {
173 | title = `${item.name} - React Antd Dva`;
174 | }
175 | });
176 | return title;
177 | }
178 | getNoticeData() {
179 | const { notices = [] } = this.props;
180 | if (notices.length === 0) {
181 | return {};
182 | }
183 | const newNotices = notices.map((notice) => {
184 | const newNotice = { ...notice };
185 | if (newNotice.datetime) {
186 | newNotice.datetime = moment(notice.datetime).fromNow();
187 | }
188 | // transform id to item key
189 | if (newNotice.id) {
190 | newNotice.key = newNotice.id;
191 | }
192 | if (newNotice.extra && newNotice.status) {
193 | const color = ({
194 | todo: '',
195 | processing: 'blue',
196 | urgent: 'red',
197 | doing: 'gold',
198 | })[newNotice.status];
199 | newNotice.extra = {newNotice.extra};
200 | }
201 | return newNotice;
202 | });
203 | return groupBy(newNotices, 'type');
204 | }
205 | handleOpenChange = (openKeys) => {
206 | const lastOpenKey = openKeys[openKeys.length - 1];
207 | const isMainMenu = this.menus.some(
208 | item => lastOpenKey && (item.key === lastOpenKey || item.path === lastOpenKey)
209 | );
210 | this.setState({
211 | openKeys: isMainMenu ? [lastOpenKey] : [...openKeys],
212 | });
213 | }
214 | toggle = () => {
215 | const { collapsed } = this.props;
216 | this.props.dispatch({
217 | type: 'global/changeLayoutCollapsed',
218 | payload: !collapsed,
219 | });
220 | this.triggerResizeEvent();
221 | }
222 | @Debounce(600)
223 | triggerResizeEvent() { // eslint-disable-line
224 | const event = document.createEvent('HTMLEvents');
225 | event.initEvent('resize', true, false);
226 | window.dispatchEvent(event);
227 | }
228 | render() {
229 | const { getRouteData, collapsed, currentUser } = this.props;
230 | const menuProps = collapsed ? {} : {
231 | openKeys: this.state.openKeys,
232 | };
233 | const menu = (
234 |
240 | );
241 | const layout = (
242 |
243 |
244 |
245 |
246 | 管理控制台
247 |
248 |
249 |
250 |
251 |
252 | {currentUser.user_name}
253 |
254 |
255 |
256 |
257 |
258 |
265 |
275 |
276 |
277 |
278 |
279 | {
280 | getRouteData('BasicLayout').map(item =>
281 | (
282 |
288 | )
289 | )
290 | }
291 |
292 |
293 |
294 |
297 |
298 |
299 |
300 | );
301 | return (
302 |
303 |
304 | {params => {layout}
}
305 |
306 |
307 | );
308 | }
309 | }
310 |
311 | export default connect(state => ({
312 | currentUser: state.user.currentUser,
313 | collapsed: state.global.collapsed,
314 | fetchingNotices: state.global.fetchingNotices,
315 | notices: state.global.notices,
316 | }))(BasicLayout);
317 |
--------------------------------------------------------------------------------