Browse Source

1.init project;
2.user login and logout;
3.custom theme.

zhanghe 6 years ago
commit
95ec669783
84 changed files with 4043 additions and 0 deletions
  1. 16 0
      .editorconfig
  2. 67 0
      .eslintrc
  3. 3 0
      .ga
  4. 16 0
      .gitignore
  5. 24 0
      .roadhogrc
  6. 12 0
      .roadhogrc.mock.js
  7. 25 0
      .stylelintrc
  8. 104 0
      package.json
  9. BIN
      public/favicon.png
  10. 43 0
      src/assets/logo.svg
  11. 137 0
      src/common/menu.js
  12. 56 0
      src/common/router.js
  13. 21 0
      src/components/Exception/demo/403.md
  14. 14 0
      src/components/Exception/demo/404.md
  15. 14 0
      src/components/Exception/demo/500.md
  16. 33 0
      src/components/Exception/index.js
  17. 78 0
      src/components/Exception/index.less
  18. 19 0
      src/components/Exception/index.md
  19. 19 0
      src/components/Exception/typeConfig.js
  20. 33 0
      src/components/GlobalFooter/demo/basic.md
  21. 14 0
      src/components/GlobalFooter/index.d.ts
  22. 29 0
      src/components/GlobalFooter/index.js
  23. 51 0
      src/components/GlobalFooter/index.less
  24. 17 0
      src/components/GlobalFooter/index.md
  25. 170 0
      src/components/GlobalHeader/index.js
  26. 112 0
      src/components/GlobalHeader/index.less
  27. 34 0
      src/components/HeaderSearch/demo/basic.md
  28. 13 0
      src/components/HeaderSearch/index.d.ts
  29. 85 0
      src/components/HeaderSearch/index.js
  30. 32 0
      src/components/HeaderSearch/index.less
  31. 20 0
      src/components/HeaderSearch/index.md
  32. 55 0
      src/components/NoticeIcon/NoticeList.js
  33. 78 0
      src/components/NoticeIcon/NoticeList.less
  34. 12 0
      src/components/NoticeIcon/demo/basic.md
  35. 176 0
      src/components/NoticeIcon/demo/popover.md
  36. 42 0
      src/components/NoticeIcon/index.d.ts
  37. 100 0
      src/components/NoticeIcon/index.js
  38. 30 0
      src/components/NoticeIcon/index.less
  39. 43 0
      src/components/NoticeIcon/index.md
  40. 75 0
      src/components/PageHeader/demo/image.md
  41. 32 0
      src/components/PageHeader/demo/simple.md
  42. 102 0
      src/components/PageHeader/demo/standard.md
  43. 68 0
      src/components/PageHeader/demo/structure.md
  44. 17 0
      src/components/PageHeader/index.d.ts
  45. 163 0
      src/components/PageHeader/index.js
  46. 138 0
      src/components/PageHeader/index.less
  47. 29 0
      src/components/PageHeader/index.md
  48. 162 0
      src/components/SiderMenu/SiderMenu.js
  49. 40 0
      src/components/SiderMenu/index.js
  50. 36 0
      src/components/SiderMenu/index.less
  51. 26 0
      src/g2.js
  52. 14 0
      src/index.ejs
  53. 41 0
      src/index.js
  54. 14 0
      src/index.less
  55. 165 0
      src/layouts/BasicLayout.js
  56. 12 0
      src/layouts/PageHeaderLayout.js
  57. 11 0
      src/layouts/PageHeaderLayout.less
  58. 70 0
      src/layouts/UserLayout.js
  59. 71 0
      src/layouts/UserLayout.less
  60. 76 0
      src/models/global.js
  61. 37 0
      src/models/login.js
  62. 64 0
      src/models/user.js
  63. 13 0
      src/rollbar.js
  64. 30 0
      src/router.js
  65. 7 0
      src/routes/Exception/403.js
  66. 7 0
      src/routes/Exception/404.js
  67. 7 0
      src/routes/Exception/500.js
  68. 94 0
      src/routes/Login/index.js
  69. 110 0
      src/routes/Login/index.less
  70. 21 0
      src/services/login.js
  71. 7 0
      src/services/notice.js
  72. 11 0
      src/services/user.js
  73. 5 0
      src/theme.js
  74. 8 0
      src/utils/api.js
  75. 5 0
      src/utils/config.js
  76. 32 0
      src/utils/helper.js
  77. 13 0
      src/utils/map.js
  78. 126 0
      src/utils/request.js
  79. 134 0
      src/utils/utils.js
  80. 50 0
      src/utils/utils.less
  81. 1 0
      tests/jasmine.js
  82. 35 0
      tests/run-tests.js
  83. 16 0
      tests/setupTests.js
  84. 1 0
      tests/styleMock.js

+ 16 - 0
.editorconfig

@@ -0,0 +1,16 @@
+# http://editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab

+ 67 - 0
.eslintrc

@@ -0,0 +1,67 @@
+{
+  "parser": "babel-eslint",
+  "extends": "airbnb",
+  "plugins": ["compat"],
+  "env": {
+    "browser": true,
+    "node": true,
+    "es6": true,
+    "mocha": true,
+    "jest": true,
+    "jasmine": true
+  },
+  "rules": {
+    "generator-star-spacing": [0],
+    "consistent-return": [0],
+    "react/forbid-prop-types": [0],
+    "react/jsx-filename-extension": [1, { "extensions": [".js"] }],
+    "global-require": [1],
+    "import/prefer-default-export": [0],
+    "react/jsx-no-bind": [0],
+    "react/prop-types": [0],
+    "react/prefer-stateless-function": [0],
+    "react/jsx-wrap-multilines": ["error", {
+      "declaration": "parens-new-line",
+      "assignment": "parens-new-line",
+      "return": "parens-new-line",
+      "arrow": "parens-new-line",
+      "condition": "parens-new-line",
+      "logical": "parens-new-line",
+      "prop": "ignore"
+    }],
+    "no-else-return": [0],
+    "no-restricted-syntax": [0],
+    "import/no-extraneous-dependencies": [0],
+    "no-use-before-define": [0],
+    "jsx-a11y/no-static-element-interactions": [0],
+    "jsx-a11y/no-noninteractive-element-interactions": [0],
+    "jsx-a11y/click-events-have-key-events": [0],
+    "jsx-a11y/anchor-is-valid": [0],
+    "no-nested-ternary": [0],
+    "arrow-body-style": [0],
+    "import/extensions": [0],
+    "no-bitwise": [0],
+    "no-cond-assign": [0],
+    "import/no-unresolved": [0],
+    "comma-dangle": ["error", {
+      "arrays": "always-multiline",
+      "objects": "always-multiline",
+      "imports": "always-multiline",
+      "exports": "always-multiline",
+      "functions": "ignore"
+    }],
+    "object-curly-newline": [0],
+    "function-paren-newline": [0],
+    "no-restricted-globals": [0],
+    "require-yield": [1],
+    "compat/compat": "error"
+  },
+  "parserOptions": {
+    "ecmaFeatures": {
+      "experimentalObjectRestSpread": true
+    }
+  },
+  "settings": {
+    "polyfills": ["fetch", "promises"]
+  }
+}

+ 3 - 0
.ga

@@ -0,0 +1,3 @@
+{
+    "code":"UA-72788897-6"
+} 

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+# roadhog-api-doc ignore
+/src/utils/request-temp.js
+_roadhog-api-doc
+
+# production
+/dist
+
+# misc
+.DS_Store
+npm-debug.log*
+
+/coverage

+ 24 - 0
.roadhogrc

@@ -0,0 +1,24 @@
+{
+  "entry": "src/index.js",
+  "extraBabelPlugins": [
+    "transform-runtime",
+    "transform-decorators-legacy",
+    "transform-class-properties",
+    ["import", { "libraryName": "antd", "libraryDirectory": "es", "style": true }]
+  ],
+  "env": {
+    "development": {
+      "extraBabelPlugins": [
+        "dva-hmr"
+      ]
+    }
+  },
+  "externals": {
+    "g2": "G2",
+    "g-cloud": "Cloud",
+    "g2-plugin-slider": "G2.Plugin.slider"
+  },
+  "ignoreMomentLocale": true,
+  "theme": "./src/theme.js",
+  "hash": true
+}

+ 12 - 0
.roadhogrc.mock.js

@@ -0,0 +1,12 @@
+import mockjs from 'mockjs';
+import { format, delay } from 'roadhog-api-doc';
+
+// mock数据
+const proxy = {
+};
+
+// 是否禁用代理
+const noProxy = process.env.NO_PROXY === 'true';
+
+// 根据是否禁用代理来选择是mock数据还是真实接口
+export default noProxy ? {} : delay(proxy, 1000);

+ 25 - 0
.stylelintrc

@@ -0,0 +1,25 @@
+{
+  "extends": "stylelint-config-standard",
+  "rules": {
+    "selector-pseudo-class-no-unknown": null,
+    "shorthand-property-no-redundant-values": null,
+    "at-rule-empty-line-before": null,
+    "at-rule-name-space-after": null,
+    "comment-empty-line-before": null,
+    "declaration-bang-space-before": null,
+    "declaration-empty-line-before": null,
+    "function-comma-newline-after": null,
+    "function-name-case": null,
+    "function-parentheses-newline-inside": null,
+    "function-max-empty-lines": null,
+    "function-whitespace-after": null,
+    "number-leading-zero": null,
+    "number-no-trailing-zeros": null,
+    "rule-empty-line-before": null,
+    "selector-combinator-space-after": null,
+    "selector-list-comma-newline-after": null,
+    "selector-pseudo-element-colon-notation": null,
+    "unit-no-unknown": null,
+    "value-list-max-empty-lines": null
+  }
+}

+ 104 - 0
package.json

@@ -0,0 +1,104 @@
+{
+  "name": "ant-design-pro",
+  "version": "0.3.1",
+  "description": "An out-of-box UI solution for enterprise applications",
+  "private": true,
+  "scripts": {
+    "precommit": "npm run lint-staged",
+    "start": "roadhog server",
+    "start:no-proxy": "cross-env NO_PROXY=true roadhog server",
+    "build": "roadhog build",
+    "site": "roadhog-api-doc static && gh-pages -d dist",
+    "analyze": "roadhog build --analyze",
+    "lint:style": "stylelint \"src/**/*.less\" --syntax less",
+    "lint": "eslint --ext .js src mock tests && npm run lint:style",
+    "lint:fix": "eslint --fix --ext .js src mock tests && npm run lint:style",
+    "lint-staged": "lint-staged",
+    "lint-staged:js": "eslint --ext .js",
+    "test": "jest",
+    "test:all": "node ./tests/run-tests.js"
+  },
+  "dependencies": {
+    "antd": "^3.0.0",
+    "babel-polyfill": "^6.26.0",
+    "babel-runtime": "^6.9.2",
+    "classnames": "^2.2.5",
+    "core-js": "^2.5.1",
+    "dva": "^2.1.0",
+    "enquire-js": "^0.1.1",
+    "g-cloud": "^1.0.2-beta",
+    "g2": "^2.3.13",
+    "g2-plugin-slider": "^1.2.1",
+    "lodash": "^4.17.4",
+    "lodash-decorators": "^4.4.1",
+    "lodash.clonedeep": "^4.5.0",
+    "moment": "^2.19.1",
+    "numeral": "^2.0.6",
+    "prop-types": "^15.5.10",
+    "qs": "^6.5.0",
+    "rc-drawer-menu": "^0.5.0",
+    "react": "^16.0.0",
+    "react-container-query": "^0.9.1",
+    "react-document-title": "^2.0.3",
+    "react-dom": "^16.0.0",
+    "react-fittext": "^1.0.0"
+  },
+  "devDependencies": {
+    "babel-eslint": "^8.0.1",
+    "babel-jest": "^21.0.0",
+    "babel-plugin-dva-hmr": "^0.3.2",
+    "babel-plugin-import": "^1.2.1",
+    "babel-plugin-transform-class-properties": "^6.24.1",
+    "babel-plugin-transform-decorators-legacy": "^1.3.4",
+    "babel-plugin-transform-runtime": "^6.9.0",
+    "babel-preset-env": "^1.6.1",
+    "babel-preset-react": "^6.24.1",
+    "cross-env": "^5.1.1",
+    "cross-port-killer": "^1.0.1",
+    "enzyme": "^3.1.0",
+    "enzyme-adapter-react-16": "^1.0.2",
+    "eslint": "^4.8.0",
+    "eslint-config-airbnb": "^16.0.0",
+    "eslint-plugin-babel": "^4.0.0",
+    "eslint-plugin-compat": "^2.1.0",
+    "eslint-plugin-import": "^2.2.0",
+    "eslint-plugin-jsx-a11y": "^6.0.0",
+    "eslint-plugin-markdown": "^1.0.0-beta.6",
+    "eslint-plugin-react": "^7.0.1",
+    "gh-pages": "^1.0.0",
+    "husky": "^0.14.3",
+    "jest": "^21.0.1",
+    "lint-staged": "^4.3.0",
+    "mockjs": "^1.0.1-beta3",
+    "prettier": "^1.9.0",
+    "pro-download": "^1.0.0",
+    "react-test-renderer": "^16.0.0",
+    "redbox-react": "^1.3.2",
+    "roadhog": "^1.3.1",
+    "roadhog-api-doc": "^0.3.3",
+    "rollbar": "^2.3.1",
+    "stylelint": "^8.1.0",
+    "stylelint-config-standard": "^17.0.0"
+  },
+  "optionalDependencies": {
+    "nightmare": "^2.10.0"
+  },
+  "babel": {
+    "presets": ["env", "react"],
+    "plugins": ["transform-decorators-legacy", "transform-class-properties"]
+  },
+  "jest": {
+    "setupFiles": ["<rootDir>/tests/setupTests.js"],
+    "testMatch": ["**/?(*.)(spec|test|e2e).js?(x)"],
+    "setupTestFrameworkScriptFile": "<rootDir>/tests/jasmine.js",
+    "moduleFileExtensions": ["js", "jsx"],
+    "moduleNameMapper": {
+      "\\.(css|less)$": "<rootDir>/tests/styleMock.js"
+    }
+  },
+  "lint-staged": {
+    "**/*.{js,jsx}": "lint-staged:js",
+    "**/*.less": "stylelint --syntax less"
+  },
+  "browserslist": ["> 1%", "last 2 versions", "not ie <= 10"]
+}

BIN
public/favicon.png


File diff suppressed because it is too large
+ 43 - 0
src/assets/logo.svg


+ 137 - 0
src/common/menu.js

@@ -0,0 +1,137 @@
+const menuData = [
+  {
+    name: '销售统计',
+    icon: 'area-chart',
+    path: 'sales',
+    children: [{
+      name: '销售概览',
+      path: 'view',
+    },{
+      name: '销售详情',
+      path: 'detail',
+    }]
+  },{
+    name: '交易管理',
+    icon: 'trademark',
+    path: 'trade',
+    children: [{
+      name: '订单管理',
+      path: 'order',
+    }],
+  },{
+    name: '资源管理',
+    icon: 'folder',
+    path: 'resource',
+    children: [{
+      name: '图库管理',
+      path: 'image',
+    },{
+      name: '视频管理',
+      path: 'video',
+    }]
+  },{
+    name: '标签管理',
+    icon: 'tags',
+    path: 'tag',
+    children: [{
+      name: '标签组',
+      path: 'tag-group',
+    },{
+      name: '标签',
+      path: 'tag-item',
+    }]
+  },{
+    name: '产品管理',
+    icon: 'appstore',
+    path: 'product',
+    children: [{
+      name: '课件管理',
+      path: 'ware',
+    },{
+      name: '课管理',
+      path: 'lesson',
+    },{
+      name: '课程管理',
+      path: 'course',
+    },{
+      name: '周边配套',
+      path: 'support',
+    }]
+  },{
+    name: '商品管理',
+    icon: 'shop',
+    path: 'goods',
+    children: [{
+      name: '商品管理',
+      path: 'item',
+    },{
+      name: '商品包管理',
+      path: 'combo',
+    }]
+  },{
+    name: '厂商管理',
+    icon: 'team',
+    path: 'merchant',
+    children: [{
+      name: '供应商管理',
+      path: 'cp',
+    },{
+      name: '渠道方管理',
+      path: 'project',
+    }]
+  },{
+    name: '终端管理',
+    icon: 'desktop',
+    path: 'terminal',
+    children: [{
+      name: '终端用户',
+      path: 'terminal-user',
+    },{
+      name: '校区管理',
+      path: 'campus',
+    }],
+  },{
+    name: '行为统计',
+    icon: 'scan',
+    path: 'action',
+    children: [{
+      name: '使用记录',
+      path: 'usage',
+    }],
+  },{
+    name: '账户管理',
+    icon: 'user-add',
+    path: 'user',
+    children: [{
+      name: '领教方账户管理',
+      path: 'lj',
+    },{
+      name: '渠道方账户管理',
+      path: 'project',
+    },{
+      name: '供应商账户管理',
+      path: 'cp',
+    }]
+  }
+];
+
+function formatter(data, parentPath = '') {
+  const list = [];
+  data.forEach((item) => {
+    if (item.children) {
+      list.push({
+        ...item,
+        path: `${parentPath}${item.path}`,
+        children: formatter(item.children, `${parentPath}${item.path}/`),
+      });
+    } else {
+      list.push({
+        ...item,
+        path: `${parentPath}${item.path}`,
+      });
+    }
+  });
+  return list;
+}
+
+export const getMenuData = () => formatter(menuData);

+ 56 - 0
src/common/router.js

@@ -0,0 +1,56 @@
+import React from 'react';
+import dynamic from 'dva/dynamic';
+import { getMenuData } from './menu';
+
+// wrapper of dynamic
+const dynamicWrapper = (app, models, component) => dynamic({
+  app,
+  // eslint-disable-next-line no-underscore-dangle
+  models: () => models.filter(m => !app._models.some(({ namespace }) => namespace === m)).map(m => import(`../models/${m}.js`)),
+  // add routerData prop
+  component: () => {
+    const p = component();
+    return new Promise((resolve, reject) => {
+      p.then((Comp) => {
+        resolve(props => <Comp {...props} routerData={getRouterData(app)} />);
+      }).catch(err => reject(err));
+    });
+  },
+});
+
+function getFlatMenuData(menus) {
+  let keys = {};
+  menus.forEach((item) => {
+    if (item.children) {
+      keys[item.path] = item.name;
+      keys = { ...keys, ...getFlatMenuData(item.children) };
+    } else {
+      keys[item.path] = item.name;
+    }
+  });
+  return keys;
+}
+
+export const getRouterData = (app) => {
+  const routerData = {
+    '/': {
+      component: dynamicWrapper(app, ['user', 'login'], () => import('../layouts/BasicLayout')),
+    },
+    '/user': {
+      component: dynamicWrapper(app, [], () => import('../layouts/UserLayout')),
+    },
+    '/user/login': {
+      component: dynamicWrapper(app, ['login'], () => import('../routes/Login')),
+    },
+  };
+  // Get name from ./menu.js or just set it in the router data.
+  const menuData = getFlatMenuData(getMenuData());
+  const routerDataWithName = {};
+  Object.keys(routerData).forEach((item) => {
+    routerDataWithName[item] = {
+      ...routerData[item],
+      name: routerData[item].name || menuData[item.replace(/^\//, '')],
+    };
+  });
+  return routerDataWithName;
+};

+ 21 - 0
src/components/Exception/demo/403.md

@@ -0,0 +1,21 @@
+---
+order: 2
+title: 403
+---
+
+403 页面,配合自定义操作。
+
+````jsx
+import Exception from 'ant-design-pro/lib/Exception';
+import { Button } from 'antd';
+
+const actions = (
+  <div>
+    <Button type="primary">回到首页</Button>
+    <Button>查看详情</Button>
+  </div>
+);
+ReactDOM.render(
+  <Exception type="403" actions={actions} />
+, mountNode);
+````

+ 14 - 0
src/components/Exception/demo/404.md

@@ -0,0 +1,14 @@
+---
+order: 0
+title: 404
+---
+
+404 页面。
+
+````jsx
+import Exception from 'ant-design-pro/lib/Exception';
+
+ReactDOM.render(
+  <Exception type="404" />
+, mountNode);
+````

+ 14 - 0
src/components/Exception/demo/500.md

@@ -0,0 +1,14 @@
+---
+order: 1
+title: 500
+---
+
+500 页面。
+
+````jsx
+import Exception from 'ant-design-pro/lib/Exception';
+
+ReactDOM.render(
+  <Exception type="500" />
+, mountNode);
+````

+ 33 - 0
src/components/Exception/index.js

@@ -0,0 +1,33 @@
+import React, { createElement } from 'react';
+import classNames from 'classnames';
+import { Button } from 'antd';
+import config from './typeConfig';
+import styles from './index.less';
+
+export default ({ className, linkElement = 'a', type, title, desc, img, actions, ...rest }) => {
+  const pageType = type in config ? type : '404';
+  const clsString = classNames(styles.exception, className);
+  return (
+    <div className={clsString} {...rest}>
+      <div className={styles.imgBlock}>
+        <div
+          className={styles.imgEle}
+          style={{ backgroundImage: `url(${img || config[pageType].img})` }}
+        />
+      </div>
+      <div className={styles.content}>
+        <h1>{title || config[pageType].title}</h1>
+        <div className={styles.desc}>{desc || config[pageType].desc}</div>
+        <div className={styles.actions}>
+          {
+            actions ||
+              createElement(linkElement, {
+                to: '/',
+                href: '/',
+              }, <Button type="primary">返回首页</Button>)
+          }
+        </div>
+      </div>
+    </div>
+  );
+};

+ 78 - 0
src/components/Exception/index.less

@@ -0,0 +1,78 @@
+@import "~antd/lib/style/themes/default.less";
+@import "~antd/lib/style/mixins/clearfix.less";
+
+.exception {
+  display: flex;
+  align-items: center;
+  height: 100%;
+
+  .imgBlock {
+    flex: 0 0 62.5%;
+    width: 62.5%;
+    padding-right: 152px;
+    .clearfix();
+  }
+
+  .imgEle {
+    height: 360px;
+    width: 100%;
+    max-width: 430px;
+    float: right;
+    background-repeat: no-repeat;
+    background-position: 50% 50%;
+    background-size: 100% 100%;
+  }
+
+  .content {
+    flex: auto;
+
+    h1 {
+      color: #434e59;
+      font-size: 72px;
+      font-weight: 600;
+      line-height: 72px;
+      margin-bottom: 24px;
+    }
+
+    .desc {
+      color: @text-color-secondary;
+      font-size: 20px;
+      line-height: 28px;
+      margin-bottom: 16px;
+    }
+
+    .actions {
+      button:not(:last-child) {
+        margin-right: 8px;
+      }
+    }
+  }
+}
+
+@media screen and (max-width: @screen-xl) {
+  .exception {
+    .imgBlock {
+      padding-right: 88px;
+    }
+  }
+}
+
+@media screen and (max-width: @screen-sm) {
+  .exception {
+    display: block;
+    text-align: center;
+    .imgBlock {
+      padding-right: 0;
+      margin: 0 auto 24px;
+    }
+  }
+}
+
+@media screen and (max-width: @screen-xs) {
+  .exception {
+    .imgBlock {
+      margin-bottom: -24px;
+      overflow: hidden;
+    }
+  }
+}

+ 19 - 0
src/components/Exception/index.md

@@ -0,0 +1,19 @@
+---
+title: Exception
+subtitle: 异常
+cols: 1
+order: 5
+---
+
+异常页用于对页面特定的异常状态进行反馈。通常,它包含对错误状态的阐述,并向用户提供建议或操作,避免用户感到迷失和困惑。
+
+## API
+
+| 参数         | 说明                                      | 类型         | 默认值 |
+|-------------|------------------------------------------|-------------|-------|
+| type        | 页面类型,若配置,则自带对应类型默认的 `title`,`desc`,`img`,此默认设置可以被 `title`,`desc`,`img` 覆盖 | Enum {'403', '404', '500'} | - |
+| title       | 标题     | ReactNode  | -    |
+| desc        | 补充描述    | ReactNode  | -    |
+| img         | 背景图片地址     | string  | -    |
+| actions     | 建议操作,配置此属性时默认的『返回首页』按钮不生效    | ReactNode  | -    |
+| linkElement | 定义链接的元素,默认为 `a` | string\|ReactElement | - |

+ 19 - 0
src/components/Exception/typeConfig.js

@@ -0,0 +1,19 @@
+const config = {
+  403: {
+    img: 'https://gw.alipayobjects.com/zos/rmsportal/wZcnGqRDyhPOEYFcZDnb.svg',
+    title: '403',
+    desc: '抱歉,你无权访问该页面',
+  },
+  404: {
+    img: 'https://gw.alipayobjects.com/zos/rmsportal/KpnpchXsobRgLElEozzI.svg',
+    title: '404',
+    desc: '抱歉,你访问的页面不存在',
+  },
+  500: {
+    img: 'https://gw.alipayobjects.com/zos/rmsportal/RVRUAYdCGeYNBWoKiIwB.svg',
+    title: '500',
+    desc: '抱歉,服务器出错了',
+  },
+};
+
+export default config;

+ 33 - 0
src/components/GlobalFooter/demo/basic.md

@@ -0,0 +1,33 @@
+---
+order: 0
+title: 演示
+iframe: 400
+---
+
+基本页脚。
+
+````jsx
+import GlobalFooter from 'ant-design-pro/lib/GlobalFooter';
+import { Icon } from 'antd';
+
+const links = [{
+  title: '帮助',
+  href: '',
+}, {
+  title: '隐私',
+  href: '',
+}, {
+  title: '条款',
+  href: '',
+  blankTarget: true,
+}];
+
+const copyright = <div>Copyright <Icon type="copyright" /> 2017 蚂蚁金服体验技术部出品</div>;
+
+ReactDOM.render(
+  <div style={{ background: '#f5f5f5', overflow: 'hidden' }}>
+    <div style={{ height: 280 }} />
+    <GlobalFooter links={links} copyright={copyright} />
+  </div>
+, mountNode);
+````

+ 14 - 0
src/components/GlobalFooter/index.d.ts

@@ -0,0 +1,14 @@
+import * as React from "react";
+export interface GlobalFooterProps {
+  links: Array<{
+    title: React.ReactNode;
+    href: string;
+    blankTarget?: boolean;
+  }>;
+  copyright: React.ReactNode;
+}
+
+export default class GlobalFooter extends React.Component<
+  GlobalFooterProps,
+  any
+> {}

+ 29 - 0
src/components/GlobalFooter/index.js

@@ -0,0 +1,29 @@
+import React from 'react';
+import classNames from 'classnames';
+import styles from './index.less';
+
+export default ({ className, links, copyright, colorInverse }) => {
+  const clsString = classNames(styles.globalFooter, className);
+  const copyrightStyle = (colorInverse || false) ? styles.copyrightInverse : styles.copyright;
+  const linkStyle = (colorInverse || false) ? styles.linksInverse : styles.links;
+  return (
+    <div className={clsString}>
+      {
+        links && (
+          <div className={linkStyle}>
+            {links.map(link => (
+              <a
+                key={link.title}
+                target={link.blankTarget ? '_blank' : '_self'}
+                href={link.href}
+              >
+                {link.title}
+              </a>
+            ))}
+          </div>
+        )
+      }
+      {copyright && <div className={copyrightStyle}>{copyright}</div>}
+    </div>
+  );
+};

+ 51 - 0
src/components/GlobalFooter/index.less

@@ -0,0 +1,51 @@
+@import "~antd/lib/style/themes/default.less";
+
+.globalFooter {
+  padding: 0 16px;
+  margin: 48px 0 24px 0;
+  text-align: center;
+
+  .links {
+    margin-bottom: 8px;
+
+    a {
+      color: @text-color-secondary;
+      transition: all .3s;
+
+      &:not(:last-child) {
+        margin-right: 40px;
+      }
+
+      &:hover {
+        color: @text-color;
+      }
+    }
+  }
+
+  .linksInverse {
+    margin-bottom: 8px;
+
+    a {
+      color: #fff;
+      transition: all .3s;
+
+      &:not(:last-child) {
+        margin-right: 40px;
+      }
+
+      &:hover {
+        color: #fff;
+      }
+    }
+  }
+
+  .copyright {
+    color: @text-color-secondary;
+    font-size: @font-size-base;
+  }
+
+  .copyrightInverse {
+    color: #fff;
+    font-size: @font-size-base;
+  }
+}

+ 17 - 0
src/components/GlobalFooter/index.md

@@ -0,0 +1,17 @@
+---
+title:
+  en-US: GlobalFooter
+  zh-CN: GlobalFooter
+subtitle: 全局页脚
+cols: 1
+order: 7
+---
+
+页脚属于全局导航的一部分,作为对顶部导航的补充,通过传递数据控制展示内容。
+
+## API
+
+参数 | 说明 | 类型 | 默认值
+----|------|-----|------
+links | 链接数据 | array<{ title: ReactNode, href: string, blankTarget?: boolean }> | -
+copyright | 版权信息 | ReactNode | -

+ 170 - 0
src/components/GlobalHeader/index.js

@@ -0,0 +1,170 @@
+import React, { PureComponent } from 'react';
+import { Layout, Menu, Icon, Spin, Tag, Dropdown, Avatar, message, Divider } from 'antd';
+import moment from 'moment';
+import groupBy from 'lodash/groupBy';
+import Debounce from 'lodash-decorators/debounce';
+import { Link } from 'dva/router';
+import NoticeIcon from '../../components/NoticeIcon';
+import HeaderSearch from '../../components/HeaderSearch';
+import logo from '../../assets/logo.svg';
+import styles from './index.less';
+import { getLocalUser } from '../../utils/helper';
+
+const { Header } = Layout;
+
+export default class GlobalHeader extends PureComponent {
+  componentDidMount() {
+    /*
+    this.props.dispatch({
+      type: 'user/fetchCurrent',
+    });
+    */
+  }
+  componentWillUnmount() {
+    this.triggerResizeEvent.cancel();
+  }
+  getNoticeData() {
+    const { notices = [] } = this.props;
+    if (notices.length === 0) {
+      return {};
+    }
+    const newNotices = notices.map((notice) => {
+      const newNotice = { ...notice };
+      if (newNotice.datetime) {
+        newNotice.datetime = moment(notice.datetime).fromNow();
+      }
+      // transform id to item key
+      if (newNotice.id) {
+        newNotice.key = newNotice.id;
+      }
+      if (newNotice.extra && newNotice.status) {
+        const color = ({
+          todo: '',
+          processing: 'blue',
+          urgent: 'red',
+          doing: 'gold',
+        })[newNotice.status];
+        newNotice.extra = <Tag color={color} style={{ marginRight: 0 }}>{newNotice.extra}</Tag>;
+      }
+      return newNotice;
+    });
+    return groupBy(newNotices, 'type');
+  }
+  handleNoticeClear = (type) => {
+    // this.props.dispatch({
+    //   type: 'global/clearNotices',
+    //   payload: type,
+    // });
+  }
+  handleNoticeVisibleChange = (visible) => {
+    // if (visible) {
+    //   this.props.dispatch({
+    //     type: 'global/fetchNotices',
+    //   });
+    // }
+  }
+  handleMenuClick = ({ key }) => {
+    if (key === 'logout') {
+      this.props.dispatch({
+        type: 'login/logout',
+      });
+    }
+  }
+  toggle = () => {
+    const { collapsed } = this.props;
+    this.props.dispatch({
+      type: 'global/changeLayoutCollapsed',
+      payload: !collapsed,
+    });
+    this.triggerResizeEvent();
+  }
+  @Debounce(600)
+  triggerResizeEvent() { // eslint-disable-line
+    const event = document.createEvent('HTMLEvents');
+    event.initEvent('resize', true, false);
+    window.dispatchEvent(event);
+  }
+  render() {
+    const {
+      collapsed, fetchingNotices, isMobile,
+    } = this.props;
+    const currentUser = getLocalUser();
+    const menu = (
+      <Menu className={styles.menu} selectedKeys={[]} onClick={this.handleMenuClick}>
+        <Menu.Item disabled><Icon type="user" />个人中心</Menu.Item>
+        <Menu.Item disabled><Icon type="setting" />账号设置</Menu.Item>
+        <Menu.Divider />
+        <Menu.Item key="logout"><Icon type="logout" />退出登录</Menu.Item>
+      </Menu>
+    );
+    const noticeData = this.getNoticeData();
+    return (
+      <Header className={styles.header}>
+        {isMobile && (
+          [(
+            <Link to="/" className={styles.logo} key="logo">
+              <img src={logo} alt="logo" width="32" />
+            </Link>),
+            <Divider type="vertical" key="line" />,
+          ]
+        )}
+        <Icon
+          className={styles.trigger}
+          type={collapsed ? 'menu-unfold' : 'menu-fold'}
+          onClick={this.toggle}
+        />
+        <div className={styles.right}>
+          <HeaderSearch
+            className={`${styles.action} ${styles.search}`}
+            placeholder="敬请期待"
+            dataSource={[]}
+            onSearch={(value) => {
+              console.log('input', value); // eslint-disable-line
+            }}
+            onPressEnter={(value) => {
+              console.log('enter', value); // eslint-disable-line
+            }}
+          />
+          <NoticeIcon
+            className={styles.action}
+            count={currentUser.notifyCount}
+            onItemClick={(item, tabProps) => {
+              console.log(item, tabProps); // eslint-disable-line
+            }}
+            onClear={this.handleNoticeClear}
+            onPopupVisibleChange={this.handleNoticeVisibleChange}
+            loading={fetchingNotices}
+            popupAlign={{ offset: [20, -16] }}
+          >
+            <NoticeIcon.Tab
+              list={noticeData['通知']}
+              title="通知"
+              emptyText="你已查看所有通知"
+              emptyImage="https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg"
+            />
+            <NoticeIcon.Tab
+              list={noticeData['消息']}
+              title="消息"
+              emptyText="您已读完所有消息"
+              emptyImage="https://gw.alipayobjects.com/zos/rmsportal/sAuJeJzSKbUmHfBQRzmZ.svg"
+            />
+            <NoticeIcon.Tab
+              list={noticeData['待办']}
+              title="待办"
+              emptyText="你已完成所有待办"
+              emptyImage="https://gw.alipayobjects.com/zos/rmsportal/HsIsxMZiWKrNUavQUXqx.svg"
+            />
+          </NoticeIcon>
+          {currentUser.username ? (
+            <Dropdown overlay={menu}>
+              <span className={`${styles.action} ${styles.account}`}>
+                <Avatar size="small" style={{ backgroundColor: '#ffbf00' }} className={styles.avatar} icon="user" src={currentUser.avatar} />
+                <span className={styles.name}>{currentUser.username}</span>
+              </span>
+            </Dropdown>
+          ) : <Spin size="small" style={{ marginLeft: 8 }} />}
+        </div>
+      </Header>
+    );
+  }
+}

+ 112 - 0
src/components/GlobalHeader/index.less

@@ -0,0 +1,112 @@
+@import "~antd/lib/style/themes/default.less";
+
+.header {
+  padding: 0 12px 0 0;
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
+  position: relative;
+}
+
+:global {
+  .ant-layout {
+    overflow-x: hidden;
+  }
+}
+
+.logo {
+  height: 64px;
+  line-height: 58px;
+  vertical-align: top;
+  display: inline-block;
+  padding: 0 0 0 24px;
+  cursor: pointer;
+  font-size: 20px;
+  img {
+    display: inline-block;
+    vertical-align: middle;
+  }
+}
+
+.menu {
+  :global(.anticon) {
+    margin-right: 8px;
+  }
+  :global(.ant-dropdown-menu-item) {
+    width: 100px;
+  }
+}
+
+i.trigger {
+  font-size: 20px;
+  line-height: 64px;
+  cursor: pointer;
+  transition: all .3s, padding 0s;
+  padding: 0 24px;
+  &:hover {
+    background: @primary-1;
+  }
+}
+
+.right {
+  float: right;
+  height: 100%;
+  .action {
+    cursor: pointer;
+    padding: 0 12px;
+    display: inline-block;
+    transition: all .3s;
+    height: 100%;
+    > i {
+      font-size: 16px;
+      vertical-align: middle;
+    }
+    &:global(.ant-popover-open),
+    &:hover {
+      background: @primary-1;
+    }
+  }
+  .search {
+    padding: 0;
+    margin: 0 12px;
+    &:hover {
+      background: transparent;
+    }
+  }
+  .account {
+    .avatar {
+      margin: 20px 8px 20px 0;
+      color: @primary-color;
+      background: rgba(255, 255, 255, .85);
+      vertical-align: middle;
+    }
+  }
+}
+
+@media only screen and (max-width: @screen-md) {
+  .header {
+    :global(.ant-divider-vertical) {
+      vertical-align: unset;
+    }
+    .name {
+      display: none;
+    }
+    i.trigger {
+      padding: 0 12px;
+    }
+    .logo {
+      padding-right: 12px;
+      position: relative;
+    }
+    .right {
+      position: absolute;
+      right: 12px;
+      top: 0;
+      background: #fff;
+      .account {
+        .avatar {
+          margin-right: 0;
+        }
+      }
+    }
+  }
+}

+ 34 - 0
src/components/HeaderSearch/demo/basic.md

@@ -0,0 +1,34 @@
+---
+order: 0
+title: 全局搜索
+---
+
+通常放置在导航工具条右侧。(点击搜索图标预览效果)
+
+````jsx
+import HeaderSearch from 'ant-design-pro/lib/HeaderSearch';
+
+ReactDOM.render(
+  <div
+    style={{
+      textAlign: 'right',
+      height: '64px',
+      lineHeight: '64px',
+      boxShadow: '0 1px 4px rgba(0,21,41,.12)',
+      padding: '0 32px',
+      width: '400px',
+    }}
+  >
+    <HeaderSearch
+      placeholder="站内搜索"
+      dataSource={['搜索提示一', '搜索提示二', '搜索提示三']}
+      onSearch={(value) => {
+        console.log('input', value); // eslint-disable-line
+      }}
+      onPressEnter={(value) => {
+        console.log('enter', value); // eslint-disable-line
+      }}
+    />
+  </div>
+, mountNode);
+````

+ 13 - 0
src/components/HeaderSearch/index.d.ts

@@ -0,0 +1,13 @@
+import * as React from "react";
+export interface HeaderSearchProps {
+  placeholder?: string;
+  dataSource?: Array<string>;
+  onSearch?: (value: string) => void;
+  onChange?: (value: string) => void;
+  onPressEnter?: (value: string) => void;
+}
+
+export default class HeaderSearch extends React.Component<
+  HeaderSearchProps,
+  any
+> {}

+ 85 - 0
src/components/HeaderSearch/index.js

@@ -0,0 +1,85 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { Input, Icon, AutoComplete } from 'antd';
+import classNames from 'classnames';
+import styles from './index.less';
+
+export default class HeaderSearch extends PureComponent {
+  static defaultProps = {
+    defaultActiveFirstOption: false,
+    onPressEnter: () => {},
+    onSearch: () => {},
+    className: '',
+    placeholder: '',
+    dataSource: [],
+  };
+  static propTypes = {
+    className: PropTypes.string,
+    placeholder: PropTypes.string,
+    onSearch: PropTypes.func,
+    onPressEnter: PropTypes.func,
+    defaultActiveFirstOption: PropTypes.bool,
+    dataSource: PropTypes.array,
+  };
+  state = {
+    searchMode: false,
+    value: '',
+  };
+  componentWillUnmount() {
+    clearTimeout(this.timeout);
+  }
+  onKeyDown = (e) => {
+    if (e.key === 'Enter') {
+      this.timeout = setTimeout(() => {
+        this.props.onPressEnter(this.state.value); // Fix duplicate onPressEnter
+      }, 0);
+    }
+  }
+  onChange = (value) => {
+    this.setState({ value });
+    if (this.props.onChange) {
+      this.props.onChange();
+    }
+  }
+  enterSearchMode = () => {
+    this.setState({ searchMode: true }, () => {
+      if (this.state.searchMode) {
+        this.input.focus();
+      }
+    });
+  }
+  leaveSearchMode = () => {
+    this.setState({
+      searchMode: false,
+      value: '',
+    });
+  }
+  render() {
+    const { className, placeholder, ...restProps } = this.props;
+    const inputClass = classNames(styles.input, {
+      [styles.show]: this.state.searchMode,
+    });
+    return (
+      <span
+        className={classNames(className, styles.headerSearch)}
+        onClick={this.enterSearchMode}
+      >
+        <Icon type="search" />
+        <AutoComplete
+          {...restProps}
+          className={inputClass}
+          value={this.state.value}
+          onChange={this.onChange}
+        >
+          <Input
+            placeholder={placeholder}
+            disabled={true}
+            ref={(node) => { this.input = node; }}
+            onKeyDown={this.onKeyDown}
+            onBlur={this.leaveSearchMode}
+          />
+        </AutoComplete>
+      </span>
+    );
+  }
+}

+ 32 - 0
src/components/HeaderSearch/index.less

@@ -0,0 +1,32 @@
+@import "~antd/lib/style/themes/default.less";
+
+.headerSearch {
+  :global(.anticon-search) {
+    cursor: pointer;
+    font-size: 16px;
+  }
+  .input {
+    transition: width .3s, margin-left .3s;
+    width: 0;
+    background: transparent;
+    border-radius: 0;
+    :global(.ant-select-selection) {
+      background: transparent;
+    }
+    input {
+      border: 0;
+      padding-left: 0;
+      padding-right: 0;
+      box-shadow: none !important;
+    }
+    &,
+    &:hover,
+    &:focus {
+      border-bottom: 1px solid @border-color-base;
+    }
+    &.show {
+      width: 210px;
+      margin-left: 8px;
+    }
+  }
+}

+ 20 - 0
src/components/HeaderSearch/index.md

@@ -0,0 +1,20 @@
+---
+title:
+  en-US: HeaderSearch
+  zh-CN: HeaderSearch
+subtitle: 顶部搜索框
+cols: 1
+order: 8
+---
+
+通常作为全局搜索的入口,放置在导航工具条右侧。
+
+## API
+
+参数 | 说明 | 类型 | 默认值
+----|------|-----|------
+placeholder | 占位文字 | string | -
+dataSource | 当前提示内容列表 | string[] | -
+onSearch | 选择某项或按下回车时的回调 | function(value) | -
+onChange | 输入搜索字符的回调 | function(value) | -
+onPressEnter | 按下回车时的回调 | function(value) | -

+ 55 - 0
src/components/NoticeIcon/NoticeList.js

@@ -0,0 +1,55 @@
+import React from 'react';
+import { Avatar, List } from 'antd';
+import classNames from 'classnames';
+import styles from './NoticeList.less';
+
+export default function NoticeList({
+  data = [], onClick, onClear, title, locale, emptyText, emptyImage,
+}) {
+  if (data.length === 0) {
+    return (
+      <div className={styles.notFound}>
+        {emptyImage ? (
+          <img src={emptyImage} alt="not found" />
+        ) : null}
+        <div>{emptyText || locale.emptyText}</div>
+      </div>
+    );
+  }
+  return (
+    <div>
+      <List className={styles.list}>
+        {data.map((item, i) => {
+          const itemCls = classNames(styles.item, {
+            [styles.read]: item.read,
+          });
+          return (
+            <List.Item className={itemCls} key={item.key || i} onClick={() => onClick(item)}>
+              <List.Item.Meta
+                className={styles.meta}
+                avatar={item.avatar ? <Avatar className={styles.avatar} src={item.avatar} /> : null}
+                title={
+                  <div className={styles.title}>
+                    {item.title}
+                    <div className={styles.extra}>{item.extra}</div>
+                  </div>
+                }
+                description={
+                  <div>
+                    <div className={styles.description} title={item.description}>
+                      {item.description}
+                    </div>
+                    <div className={styles.datetime}>{item.datetime}</div>
+                  </div>
+                }
+              />
+            </List.Item>
+          );
+        })}
+      </List>
+      <div className={styles.clear} onClick={onClear}>
+        {locale.clear}{title}
+      </div>
+    </div>
+  );
+}

+ 78 - 0
src/components/NoticeIcon/NoticeList.less

@@ -0,0 +1,78 @@
+@import "~antd/lib/style/themes/default.less";
+
+.list {
+  max-height: 400px;
+  overflow: auto;
+  .item {
+    transition: all .3s;
+    overflow: hidden;
+    cursor: pointer;
+    padding-left: 24px;
+    padding-right: 24px;
+
+    .meta {
+      width: 100%;
+    }
+
+    .avatar {
+      background: #fff;
+      margin-top: 4px;
+    }
+
+    &.read {
+      opacity: .4;
+    }
+    &:last-child {
+      border-bottom: 0;
+    }
+    &:hover {
+      background: @primary-1;
+    }
+    .title {
+      font-weight: normal;
+      margin-bottom: 8px;
+    }
+    .description {
+      font-size: 12px;
+      line-height: @line-height-base;
+    }
+    .datetime {
+      font-size: 12px;
+      margin-top: 4px;
+      line-height: @line-height-base;
+    }
+    .extra {
+      float: right;
+      color: @text-color-secondary;
+      font-weight: normal;
+      margin-right: 0;
+      margin-top: -1.5px;
+    }
+  }
+}
+
+.notFound {
+  text-align: center;
+  padding: 73px 0 88px 0;
+  color: @text-color-secondary;
+  img {
+    display: inline-block;
+    margin-bottom: 16px;
+    height: 76px;
+  }
+}
+
+.clear {
+  height: 46px;
+  line-height: 46px;
+  text-align: center;
+  color: @text-color;
+  border-radius: 0 0 @border-radius-base @border-radius-base;
+  border-top: 1px solid @border-color-split;
+  transition: all .3s;
+  cursor: pointer;
+
+  &:hover {
+    color: @heading-color;
+  }
+}

+ 12 - 0
src/components/NoticeIcon/demo/basic.md

@@ -0,0 +1,12 @@
+---
+order: 1
+title: 通知图标
+---
+
+通常用在导航工具栏上。
+
+````jsx
+import NoticeIcon from 'ant-design-pro/lib/NoticeIcon';
+
+ReactDOM.render(<NoticeIcon count={5} />, mountNode);
+````

+ 176 - 0
src/components/NoticeIcon/demo/popover.md

@@ -0,0 +1,176 @@
+---
+order: 2
+title: 带浮层卡片
+---
+
+点击展开通知卡片,展现多种类型的通知,通常放在导航工具栏。
+
+````jsx
+import NoticeIcon from 'ant-design-pro/lib/NoticeIcon';
+import moment from 'moment';
+import groupBy from 'lodash/groupBy';
+import { Tag } from 'antd';
+
+const data = [{
+  id: '000000001',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
+  title: '你收到了 14 份新周报',
+  datetime: '2017-08-09',
+  type: '通知',
+}, {
+  id: '000000002',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png',
+  title: '你推荐的 曲妮妮 已通过第三轮面试',
+  datetime: '2017-08-08',
+  type: '通知',
+}, {
+  id: '000000003',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png',
+  title: '这种模板可以区分多种通知类型',
+  datetime: '2017-08-07',
+  read: true,
+  type: '通知',
+}, {
+  id: '000000004',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png',
+  title: '左侧图标用于区分不同的类型',
+  datetime: '2017-08-07',
+  type: '通知',
+}, {
+  id: '000000005',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
+  title: '内容不要超过两行字,超出时自动截断',
+  datetime: '2017-08-07',
+  type: '通知',
+}, {
+  id: '000000006',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+  title: '曲丽丽 评论了你',
+  description: '描述信息描述信息描述信息',
+  datetime: '2017-08-07',
+  type: '消息',
+}, {
+  id: '000000007',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+  title: '朱偏右 回复了你',
+  description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
+  datetime: '2017-08-07',
+  type: '消息',
+}, {
+  id: '000000008',
+  avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
+  title: '标题',
+  description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
+  datetime: '2017-08-07',
+  type: '消息',
+}, {
+  id: '000000009',
+  title: '任务名称',
+  description: '任务需要在 2017-01-12 20:00 前启动',
+  extra: '未开始',
+  status: 'todo',
+  type: '待办',
+}, {
+  id: '000000010',
+  title: '第三方紧急代码变更',
+  description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
+  extra: '马上到期',
+  status: 'urgent',
+  type: '待办',
+}, {
+  id: '000000011',
+  title: '信息安全考试',
+  description: '指派竹尔于 2017-01-09 前完成更新并发布',
+  extra: '已耗时 8 天',
+  status: 'doing',
+  type: '待办',
+}, {
+  id: '000000012',
+  title: 'ABCD 版本发布',
+  description: '冠霖提交于 2017-01-06,需在 2017-01-07 前完成代码变更任务',
+  extra: '进行中',
+  status: 'processing',
+  type: '待办',
+}];
+
+function onItemClick(item, tabProps) {
+  console.log(item, tabProps);
+}
+
+function onClear(tabTitle) {
+  console.log(tabTitle);
+}
+
+function getNoticeData(notices) {
+  if (notices.length === 0) {
+    return {};
+  }
+  const newNotices = notices.map((notice) => {
+    const newNotice = { ...notice };
+    if (newNotice.datetime) {
+      newNotice.datetime = moment(notice.datetime).fromNow();
+    }
+    // transform id to item key
+    if (newNotice.id) {
+      newNotice.key = newNotice.id;
+    }
+    if (newNotice.extra && newNotice.status) {
+      const color = ({
+        todo: '',
+        processing: 'blue',
+        urgent: 'red',
+        doing: 'gold',
+      })[newNotice.status];
+      newNotice.extra = <Tag color={color} style={{ marginRight: 0 }}>{newNotice.extra}</Tag>;
+    }
+    return newNotice;
+  });
+  return groupBy(newNotices, 'type');
+}
+
+const noticeData = getNoticeData(data);
+
+ReactDOM.render(
+  <div
+    style={{
+      textAlign: 'right',
+      height: '64px',
+      lineHeight: '64px',
+      boxShadow: '0 1px 4px rgba(0,21,41,.12)',
+      padding: '0 32px',
+      width: '400px',
+    }}
+  >
+    <NoticeIcon
+      className="notice-icon"
+      count={5}
+      onItemClick={onItemClick}
+      onClear={onClear}
+      popupAlign={{ offset: [20, -16] }}
+    >
+      <NoticeIcon.Tab
+        list={noticeData['通知']}
+        title="通知"
+        emptyText="你已查看所有通知"
+        emptyImage="https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg"
+      />
+      <NoticeIcon.Tab
+        list={noticeData['消息']}
+        title="消息"
+        emptyText="您已读完所有消息"
+        emptyImage="https://gw.alipayobjects.com/zos/rmsportal/sAuJeJzSKbUmHfBQRzmZ.svg"
+      />
+      <NoticeIcon.Tab
+        list={noticeData['待办']}
+        title="待办"
+        emptyText="你已完成所有待办"
+        emptyImage="https://gw.alipayobjects.com/zos/rmsportal/HsIsxMZiWKrNUavQUXqx.svg"
+      />
+    </NoticeIcon>
+  </div>
+, mountNode);
+````
+
+```css
+
+```

+ 42 - 0
src/components/NoticeIcon/index.d.ts

@@ -0,0 +1,42 @@
+import * as React from "react";
+export interface NoticeIconData {
+  avatar: string;
+  title: React.ReactNode;
+  description: React.ReactNode;
+  datetime: React.ReactNode;
+  extra: React.ReactNode;
+}
+
+export interface NoticeIconProps {
+  count?: number;
+  className?: string;
+  loading?: boolean;
+  onClear?: (tableTile: string) => void;
+  onItemClick?: (item: NoticeIconData, tabProps: NoticeIconProps) => void;
+  onTabChange?: (tableTile: string) => void;
+  popupAlign?: {
+    points?: [string, string];
+    offset?: [number, number];
+    targetOffset?: [number, number];
+    overflow?: any;
+    useCssRight?: boolean;
+    useCssBottom?: boolean;
+    useCssTransform?: boolean;
+  };
+  onPopupVisibleChange?: (visible: boolean) => void;
+  popupVisible?: boolean;
+  locale?: { emptyText: string; clear: string };
+}
+
+export interface NoticeIconTabProps {
+  list: Array<NoticeIconData>;
+  title: string;
+  emptyText?: React.ReactNode;
+  emptyImage?: string;
+}
+
+export class NoticeIconTab extends React.Component<NoticeIconTabProps, any> {}
+
+export default class NoticeIcon extends React.Component<NoticeIconProps, any> {
+  static Tab: typeof NoticeIconTab;
+}

+ 100 - 0
src/components/NoticeIcon/index.js

@@ -0,0 +1,100 @@
+import React, { PureComponent } from 'react';
+import { Popover, Icon, Tabs, Badge, Spin } from 'antd';
+import classNames from 'classnames';
+import List from './NoticeList';
+import styles from './index.less';
+
+const { TabPane } = Tabs;
+
+export default class NoticeIcon extends PureComponent {
+  static defaultProps = {
+    onItemClick: () => {},
+    onPopupVisibleChange: () => {},
+    onTabChange: () => {},
+    onClear: () => {},
+    loading: false,
+    locale: {
+      emptyText: '暂无数据',
+      clear: '清空',
+    },
+    emptyImage: 'https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg',
+  };
+  static Tab = TabPane;
+  constructor(props) {
+    super(props);
+    this.state = {};
+    if (props.children && props.children[0]) {
+      this.state.tabType = props.children[0].props.title;
+    }
+  }
+  onItemClick = (item, tabProps) => {
+    const { onItemClick } = this.props;
+    onItemClick(item, tabProps);
+  }
+  onTabChange = (tabType) => {
+    this.setState({ tabType });
+    this.props.onTabChange(tabType);
+  }
+  getNotificationBox() {
+    const { children, loading, locale } = this.props;
+    if (!children) {
+      return null;
+    }
+    const panes = React.Children.map(children, (child) => {
+      const title = child.props.list && child.props.list.length > 0
+        ? `${child.props.title} (${child.props.list.length})` : child.props.title;
+      return (
+        <TabPane tab={title} key={child.props.title}>
+          <List
+            {...child.props}
+            data={child.props.list}
+            onClick={item => this.onItemClick(item, child.props)}
+            onClear={() => this.props.onClear(child.props.title)}
+            title={child.props.title}
+            locale={locale}
+          />
+        </TabPane>
+      );
+    });
+    return (
+      <Spin spinning={loading} delay={0}>
+        <Tabs className={styles.tabs} onChange={this.onTabChange}>
+          {panes}
+        </Tabs>
+      </Spin>
+    );
+  }
+  render() {
+    const { className, count, popupAlign, onPopupVisibleChange } = this.props;
+    const noticeButtonClass = classNames(className, styles.noticeButton);
+    const notificationBox = this.getNotificationBox();
+    const trigger = (
+      <span className={noticeButtonClass}>
+        <Badge count={count} className={styles.badge}>
+          <Icon type="bell" className={styles.icon} />
+        </Badge>
+      </span>
+    );
+    if (!notificationBox) {
+      return trigger;
+    }
+    const popoverProps = {};
+    if ('popupVisible' in this.props) {
+      popoverProps.visible = this.props.popupVisible;
+    }
+    return (
+      <Popover
+        placement="bottomRight"
+        content={notificationBox}
+        popupClassName={styles.popover}
+        trigger="click"
+        arrowPointAtCenter
+        popupAlign={popupAlign}
+        onVisibleChange={onPopupVisibleChange}
+        {...popoverProps}
+      >
+        {trigger}
+      </Popover>
+    );
+  }
+}

+ 30 - 0
src/components/NoticeIcon/index.less

@@ -0,0 +1,30 @@
+@import "~antd/lib/style/themes/default.less";
+
+.popover {
+  width: 336px;
+  :global(.ant-popover-inner-content) {
+    padding: 0;
+  }
+}
+
+.noticeButton {
+  cursor: pointer;
+  display: inline-block;
+  transition: all .3s;
+}
+
+.icon {
+  font-size: 16px;
+  padding: 4px;
+}
+
+.tabs {
+  :global {
+    .ant-tabs-nav-scroll {
+      text-align: center;
+    }
+    .ant-tabs-bar {
+      margin-bottom: 4px;
+    }
+  }
+}

+ 43 - 0
src/components/NoticeIcon/index.md

@@ -0,0 +1,43 @@
+---
+title:
+  en-US: NoticeIcon
+  zh-CN: NoticeIcon
+subtitle: 通知菜单
+cols: 1
+order: 9
+---
+
+用在导航工具栏上,作为整个产品统一的通知中心。
+
+## API
+
+参数 | 说明 | 类型 | 默认值
+----|------|-----|------
+count | 图标上的消息总数 | number | -
+loading | 弹出卡片加载状态 | boolean | false
+onClear | 点击清空按钮的回调 | function(tabTitle) | -
+onItemClick | 点击列表项的回调 | function(item, tabProps) | -
+onTabChange | 切换页签的回调 | function(tabTitle) | -
+popupAlign | 弹出卡片的位置配置 | Object [alignConfig](https://github.com/yiminghe/dom-align#alignconfig-object-details) | -
+onPopupVisibleChange | 弹出卡片显隐的回调 | function(visible) | -
+popupVisible | 控制弹层显隐 | boolean | -
+locale | 默认文案 | Object | `{ emptyText: '暂无数据', clear: '清空' }`
+
+### NoticeIcon.Tab
+
+参数 | 说明 | 类型 | 默认值
+----|------|-----|------
+title | 消息分类的页签标题 | string | -
+list | 列表数据,格式参照下表 | Array | `[]`
+emptyText | 针对每个 Tab 定制空数据文案 | ReactNode | -
+emptyImage | 针对每个 Tab 定制空数据图片 | string | -
+
+### Tab data
+
+参数 | 说明 | 类型 | 默认值
+----|------|-----|------
+avatar | 头像图片链接 | string | -
+title | 标题 | ReactNode | -
+description | 描述信息 | ReactNode | -
+datetime | 时间戳 | ReactNode | -
+extra | 额外信息,在列表项右上角 | ReactNode | -

+ 75 - 0
src/components/PageHeader/demo/image.md

@@ -0,0 +1,75 @@
+---
+order: 2
+title: With Image
+---
+
+带图片的页头。
+
+````jsx
+import PageHeader from 'ant-design-pro/lib/PageHeader';
+
+const content = (
+  <div>
+    <p>段落示意:蚂蚁金服务设计平台 ant.design,用最小的工作量,无缝接入蚂蚁金服生态,提供跨越设计与开发的体验解决方案。</p>
+    <div className="link">
+      <a>
+        <img alt="" src="https://gw.alipayobjects.com/zos/rmsportal/MjEImQtenlyueSmVEfUD.svg" /> 快速开始
+      </a>
+      <a>
+        <img alt="" src="https://gw.alipayobjects.com/zos/rmsportal/NbuDUAuBlIApFuDvWiND.svg" /> 产品简介
+      </a>
+      <a>
+        <img alt="" src="https://gw.alipayobjects.com/zos/rmsportal/ohOEPSYdDTNnyMbGuyLb.svg" /> 产品文档
+      </a>
+    </div>
+  </div>
+);
+
+const extra = (
+  <div className="imgContainer">
+    <img style={{ width: '100%' }} alt="" src="https://gw.alipayobjects.com/zos/rmsportal/RzwpdLnhmvDJToTdfDPe.png" />
+  </div>
+);
+
+const breadcrumbList = [{
+  title: '一级菜单',
+  href: '/',
+}, {
+  title: '二级菜单',
+  href: '/',
+}, {
+  title: '三级菜单',
+}];
+
+ReactDOM.render(
+  <div>
+    <PageHeader
+      title="这是一个标题"
+      content={content}
+      extraContent={extra}
+      breadcrumbList={breadcrumbList}
+    />
+  </div>
+, mountNode);
+````
+
+<style>
+#scaffold-src-components-PageHeader-demo-image .code-box-demo {
+  background: #f2f4f5;
+}
+#scaffold-src-components-PageHeader-demo-image .imgContainer {
+  margin-top: -60px;
+  text-align: center;
+  width: 195px;
+}
+#scaffold-src-components-PageHeader-demo-image .link {
+	margin-top: 16px;
+}
+#scaffold-src-components-PageHeader-demo-image .link a {
+  margin-right: 32px;
+}
+#scaffold-src-components-PageHeader-demo-image .link img {
+  vertical-align: middle;
+  margin-right: 8px;
+}
+</style>

+ 32 - 0
src/components/PageHeader/demo/simple.md

@@ -0,0 +1,32 @@
+---
+order: 3
+title: Simple
+---
+
+简单的页头。
+
+````jsx
+import PageHeader from 'ant-design-pro/lib/PageHeader';
+
+const breadcrumbList = [{
+  title: '一级菜单',
+  href: '/',
+}, {
+  title: '二级菜单',
+  href: '/',
+}, {
+  title: '三级菜单',
+}];
+
+ReactDOM.render(
+  <div>
+    <PageHeader title="页面标题" breadcrumbList={breadcrumbList} />
+  </div>
+, mountNode);
+````
+
+<style>
+#scaffold-src-components-PageHeader-demo-simple .code-box-demo {
+  background: #f2f4f5;
+}
+</style>

+ 102 - 0
src/components/PageHeader/demo/standard.md

@@ -0,0 +1,102 @@
+---
+order: 1
+title: Standard
+---
+
+标准页头。
+
+````jsx
+import PageHeader from 'ant-design-pro/lib/PageHeader';
+import DescriptionList from 'ant-design-pro/lib/DescriptionList';
+import { Button, Menu, Dropdown, Icon, Row, Col } from 'antd';
+
+const { Description } = DescriptionList;
+const ButtonGroup = Button.Group;
+
+const description = (
+  <DescriptionList size="small" col="2">
+    <Description term="创建人">曲丽丽</Description>
+    <Description term="订购产品">XX 服务</Description>
+    <Description term="创建时间">2017-07-07</Description>
+    <Description term="关联单据"><a href="">12421</a></Description>
+  </DescriptionList>
+);
+
+const menu = (
+  <Menu>
+    <Menu.Item key="1">选项一</Menu.Item>
+    <Menu.Item key="2">选项二</Menu.Item>
+    <Menu.Item key="3">选项三</Menu.Item>
+  </Menu>
+);
+
+const action = (
+  <div>
+    <ButtonGroup>
+      <Button>操作</Button>
+      <Button>操作</Button>
+      <Dropdown overlay={menu} placement="bottomRight">
+        <Button><Icon type="ellipsis" /></Button>
+      </Dropdown>
+    </ButtonGroup>
+    <Button type="primary">主操作</Button>
+  </div>
+);
+
+const extra = (
+  <Row>
+    <Col sm={24} md={12}>
+      <div style={{ color: 'rgba(0, 0, 0, 0.43)' }}>状态</div>
+      <div style={{ color: 'rgba(0, 0, 0, 0.85)', fontSize: 20 }}>待审批</div>
+    </Col>
+    <Col sm={24} md={12}>
+      <div style={{ color: 'rgba(0, 0, 0, 0.43)' }}>订单金额</div>
+      <div style={{ color: 'rgba(0, 0, 0, 0.85)', fontSize: 20 }}>¥ 568.08</div>
+    </Col>
+  </Row>
+);
+
+const breadcrumbList = [{
+  title: '一级菜单',
+  href: '/',
+}, {
+  title: '二级菜单',
+  href: '/',
+}, {
+  title: '三级菜单',
+}];
+
+const tabList = [{
+  key: 'detail',
+  tab: '详情',
+}, {
+  key: 'rule',
+  tab: '规则',
+}];
+
+function onTabChange(key) {
+  console.log(key);
+}
+
+ReactDOM.render(
+  <div>
+    <PageHeader
+      title="单号:234231029431"
+      logo={<img alt="" src="https://gw.alipayobjects.com/zos/rmsportal/nxkuOJlFJuAUhzlMTCEe.png" />}
+      action={action}
+      content={description}
+      extraContent={extra}
+      breadcrumbList={breadcrumbList}
+      tabList={tabList}
+      tabActiveKey="detail"
+      onTabChange={onTabChange}
+    />
+  </div>
+, mountNode);
+````
+
+<style>
+#scaffold-src-components-PageHeader-demo-standard .code-box-demo {
+  background: #f2f4f5;
+}
+</style>

+ 68 - 0
src/components/PageHeader/demo/structure.md

@@ -0,0 +1,68 @@
+---
+order: 0
+title: Structure
+---
+
+基本结构,具备响应式布局功能,主要断点为 768px 和 576px,拖动窗口改变大小试试看。
+
+````jsx
+import PageHeader from 'ant-design-pro/lib/PageHeader';
+
+const breadcrumbList = [{
+  title: '面包屑',
+}];
+
+const tabList = [{
+  key: '1',
+  tab: '页签一',
+}, {
+  key: '2',
+  tab: '页签二',
+}, {
+  key: '3',
+  tab: '页签三',
+}];
+
+ReactDOM.render(
+  <div>
+    <PageHeader
+      className="tabs"
+      title={<div className="title">Title</div>}
+      logo={<div className="logo">logo</div>}
+      action={<div className="action">action</div>}
+      content={<div className="content">content</div>}
+      extraContent={<div className="extraContent">extraContent</div>}
+      breadcrumbList={breadcrumbList}
+      tabList={tabList}
+      tabActiveKey="1"
+    />
+  </div>
+, mountNode);
+````
+
+<style>
+#scaffold-src-components-PageHeader-demo-structure .code-box-demo {
+  background: #f2f4f5;
+}
+#scaffold-src-components-PageHeader-demo-structure .logo {
+  background: #3ba0e9;
+  color: #fff;
+  height: 100%;
+}
+#scaffold-src-components-PageHeader-demo-structure .title {
+  background: rgba(16, 142, 233, 1);
+  color: #fff;
+}
+#scaffold-src-components-PageHeader-demo-structure .action {
+  background: #7dbcea;
+  color: #fff;
+}
+#scaffold-src-components-PageHeader-demo-structure .content {
+  background: #7dbcea;
+  color: #fff;
+}
+#scaffold-src-components-PageHeader-demo-structure .extraContent {
+  background: #7dbcea;
+  color: #fff;
+}
+</style>

+ 17 - 0
src/components/PageHeader/index.d.ts

@@ -0,0 +1,17 @@
+import * as React from "react";
+export interface PageHeaderProps {
+  title?: React.ReactNode | string;
+  logo?: React.ReactNode | string;
+  action?: React.ReactNode | string;
+  content?: React.ReactNode;
+  extraContent?: React.ReactNode;
+  routes?: Array<any>;
+  params?: any;
+  breadcrumbList?: Array<{ title: React.ReactNode; href?: string }>;
+  tabList?: Array<{ key: string; tab: React.ReactNode }>;
+  tabActiveKey?: string;
+  onTabChange?: (key: string) => void;
+  linkElement?: React.ReactNode;
+}
+
+export default class PageHeader extends React.Component<PageHeaderProps, any> {}

+ 163 - 0
src/components/PageHeader/index.js

@@ -0,0 +1,163 @@
+import React, { PureComponent, createElement } from 'react';
+import PropTypes from 'prop-types';
+import { Breadcrumb, Tabs } from 'antd';
+import classNames from 'classnames';
+import styles from './index.less';
+
+const { TabPane } = Tabs;
+
+function getBreadcrumb(breadcrumbNameMap, url) {
+  if (breadcrumbNameMap[url]) {
+    return breadcrumbNameMap[url];
+  }
+  const urlWithoutSplash = url.replace(/\/$/, '');
+  if (breadcrumbNameMap[urlWithoutSplash]) {
+    return breadcrumbNameMap[urlWithoutSplash];
+  }
+  let breadcrumb = {};
+  Object.keys(breadcrumbNameMap).forEach((item) => {
+    const itemRegExpStr = `^${item.replace(/:[\w-]+/g, '[\\w-]+')}$`;
+    const itemRegExp = new RegExp(itemRegExpStr);
+    if (itemRegExp.test(url)) {
+      breadcrumb = breadcrumbNameMap[item];
+    }
+  });
+  return breadcrumb;
+}
+
+export default class PageHeader extends PureComponent {
+  static contextTypes = {
+    routes: PropTypes.array,
+    params: PropTypes.object,
+    location: PropTypes.object,
+    breadcrumbNameMap: PropTypes.object,
+  };
+  onChange = (key) => {
+    if (this.props.onTabChange) {
+      this.props.onTabChange(key);
+    }
+  };
+  getBreadcrumbProps = () => {
+    return {
+      routes: this.props.routes || this.context.routes,
+      params: this.props.params || this.context.params,
+      location: this.props.location || this.context.location,
+      breadcrumbNameMap: this.props.breadcrumbNameMap || this.context.breadcrumbNameMap,
+    };
+  };
+  itemRender = (route, params, routes, paths) => {
+    const { linkElement = 'a' } = this.props;
+    const last = routes.indexOf(route) === routes.length - 1;
+    return (last || !route.component)
+      ? <span>{route.breadcrumbName}</span>
+      : createElement(linkElement, {
+        href: paths.join('/') || '/',
+        to: paths.join('/') || '/',
+      }, route.breadcrumbName);
+  }
+  render() {
+    const { routes, params, location, breadcrumbNameMap } = this.getBreadcrumbProps();
+    const {
+      title, logo, action, content, extraContent,
+      breadcrumbList, tabList, className, linkElement = 'a',
+      activeTabKey,
+    } = this.props;
+    const clsString = classNames(styles.pageHeader, className);
+    let breadcrumb;
+    if (routes && params) {
+      breadcrumb = (
+        <Breadcrumb
+          className={styles.breadcrumb}
+          routes={routes.filter(route => route.breadcrumbName)}
+          params={params}
+          itemRender={this.itemRender}
+        />
+      );
+    } else if (location && location.pathname && (!breadcrumbList)) {
+      const pathSnippets = location.pathname.split('/').filter(i => i);
+      const extraBreadcrumbItems = pathSnippets.map((_, index) => {
+        const url = `/${pathSnippets.slice(0, index + 1).join('/')}`;
+        const currentBreadcrumb = getBreadcrumb(breadcrumbNameMap, url);
+        const isLinkable = (index !== pathSnippets.length - 1) && currentBreadcrumb.component;
+        return currentBreadcrumb.name && !currentBreadcrumb.hideInBreadcrumb ? (
+          <Breadcrumb.Item key={url}>
+            {createElement(
+              isLinkable ? linkElement : 'span',
+              { [linkElement === 'a' ? 'href' : 'to']: url },
+              currentBreadcrumb.name,
+            )}
+          </Breadcrumb.Item>
+        ) : null;
+      });
+      const breadcrumbItems = [(
+        <Breadcrumb.Item key="home">
+          {createElement(linkElement, {
+            [linkElement === 'a' ? 'href' : 'to']: '/',
+          }, '首页')}
+        </Breadcrumb.Item>
+      )].concat(extraBreadcrumbItems);
+      breadcrumb = (
+        <Breadcrumb className={styles.breadcrumb}>
+          {breadcrumbItems}
+        </Breadcrumb>
+      );
+    } else if (breadcrumbList && breadcrumbList.length) {
+      breadcrumb = (
+        <Breadcrumb className={styles.breadcrumb}>
+          {
+            breadcrumbList.map(item => (
+              <Breadcrumb.Item key={item.title}>
+                {item.href ? (
+                  createElement(linkElement, {
+                    [linkElement === 'a' ? 'href' : 'to']: item.href,
+                  }, item.title)
+                ) : item.title}
+              </Breadcrumb.Item>)
+            )
+          }
+        </Breadcrumb>
+      );
+    } else {
+      breadcrumb = null;
+    }
+
+    let tabDefaultValue;
+    if (activeTabKey !== undefined && tabList) {
+      tabDefaultValue = tabList.filter(item => item.default)[0] || tabList[0];
+    }
+
+    return (
+      <div className={clsString}>
+        {breadcrumb}
+        <div className={styles.detail}>
+          {logo && <div className={styles.logo}>{logo}</div>}
+          <div className={styles.main}>
+            <div className={styles.row}>
+              {title && <h1 className={styles.title}>{title}</h1>}
+              {action && <div className={styles.action}>{action}</div>}
+            </div>
+            <div className={styles.row}>
+              {content && <div className={styles.content}>{content}</div>}
+              {extraContent && <div className={styles.extraContent}>{extraContent}</div>}
+            </div>
+          </div>
+        </div>
+        {
+          tabList &&
+          tabList.length && (
+            <Tabs
+              className={styles.tabs}
+              defaultActiveKey={(tabDefaultValue && tabDefaultValue.key)}
+              activeKey={activeTabKey}
+              onChange={this.onChange}
+            >
+              {
+                tabList.map(item => <TabPane tab={item.tab} key={item.key} />)
+              }
+            </Tabs>
+          )
+        }
+      </div>
+    );
+  }
+}

+ 138 - 0
src/components/PageHeader/index.less

@@ -0,0 +1,138 @@
+@import "~antd/lib/style/themes/default.less";
+
+.pageHeader {
+  background: @component-background;
+  padding: 16px 32px 0 32px;
+  border-bottom: @border-width-base @border-style-base @border-color-split;
+
+  .detail {
+    display: flex;
+  }
+
+  .row {
+    display: flex;
+  }
+
+  .breadcrumb {
+    margin-bottom: 16px;
+  }
+
+  .tabs {
+    margin: 0 0 -17px -8px;
+
+    :global {
+      .ant-tabs-bar {
+        border-bottom: @border-width-base @border-style-base @border-color-split;
+      }
+    }
+  }
+
+  .logo {
+    flex: 0 1 auto;
+    margin-right: 16px;
+    padding-top: 1px;
+    > img {
+      width: 28px;
+      height: 28px;
+      border-radius: @border-radius-base;
+      display: block;
+    }
+  }
+
+  .title {
+    font-size: 20px;
+    font-weight: 500;
+    color: @heading-color;
+  }
+
+  .action {
+    margin-left: 56px;
+    min-width: 266px;
+
+    :global {
+      .ant-btn-group:not(:last-child),
+      .ant-btn:not(:last-child) {
+        margin-right: 8px;
+      }
+
+      .ant-btn-group > .ant-btn {
+        margin-right: 0;
+      }
+    }
+  }
+
+  .title, .action, .content, .extraContent, .main {
+    flex: auto;
+  }
+
+  .title, .action {
+    margin-bottom: 16px;
+  }
+
+  .logo, .content, .extraContent {
+    margin-bottom: 16px;
+  }
+
+  .action, .extraContent {
+    text-align: right;
+  }
+
+  .extraContent {
+    margin-left: 88px;
+    min-width: 242px;
+  }
+}
+
+@media screen and (max-width: @screen-xl) {
+  .pageHeader {
+    .extraContent {
+      margin-left: 44px;
+    }
+  }
+}
+
+@media screen and (max-width: @screen-lg) {
+  .pageHeader {
+    .extraContent {
+      margin-left: 20px;
+    }
+  }
+}
+
+@media screen and (max-width: @screen-md) {
+  .pageHeader {
+    .row {
+      display: block;
+    }
+
+    .action, .extraContent {
+      margin-left: 0;
+      text-align: left;
+    }
+  }
+}
+
+@media screen and (max-width: @screen-sm) {
+  .pageHeader {
+    .detail {
+      display: block;
+    }
+  }
+}
+
+@media screen and (max-width: @screen-xs) {
+  .pageHeader {
+    .action {
+      :global {
+        .ant-btn-group, .ant-btn {
+          display: block;
+          margin-bottom: 8px;
+        }
+        .ant-btn-group > .ant-btn {
+          display: inline-block;
+          margin-bottom: 0;
+        }
+      }
+    }
+  }
+}

+ 29 - 0
src/components/PageHeader/index.md

@@ -0,0 +1,29 @@
+---
+title:
+  en-US: PageHeader
+  zh-CN: PageHeader
+subtitle: 页头
+cols: 1
+order: 11
+---
+
+页头用来声明页面的主题,包含了用户所关注的最重要的信息,使用户可以快速理解当前页面是什么以及它的功能。
+
+## API
+
+| 参数      | 说明                                      | 类型         | 默认值 |
+|----------|------------------------------------------|-------------|-------|
+| title | title 区域 | ReactNode | - |
+| logo | logo区域 | ReactNode | - |
+| action | 操作区,位于 title 行的行尾 | ReactNode | - |
+| content | 内容区 | ReactNode | - |
+| extraContent | 额外内容区,位于content的右侧 | ReactNode | - |
+| routes | 面包屑相关属性,router 的路由栈信息 | object[] | - |
+| params | 面包屑相关属性,路由的参数 | object | - |
+| breadcrumbList | 面包屑数据,配置了 `routes` `params` 时此属性无效 | array<{title: ReactNode, href?: string}> | - |
+| tabList | tab 标题列表 | array<{key: string, tab: ReactNode}> | -  |
+| tabActiveKey | 当前高亮的 tab 项 | string | -  |
+| onTabChange | 切换面板的回调 | (key) => void | -  |
+| linkElement | 定义链接的元素,默认为 `a`,可传入 react-router 的 Link | string\|ReactElement | - |
+
+> 面包屑的配置方式有两种,一是结合 `react-router`,通过配置 `routes` 及 `params` 实现,类似 [面包屑 Demo](https://ant.design/components/breadcrumb-cn/#components-breadcrumb-demo-router);二是直接配置 `breadcrumbList`。 你也可以将 `routes` 及 `params` 放到 context 中,`PageHeader` 组件会自动获取。

+ 162 - 0
src/components/SiderMenu/SiderMenu.js

@@ -0,0 +1,162 @@
+import React, { PureComponent } from 'react';
+import { Layout, Menu, Icon } from 'antd';
+import { Link } from 'dva/router';
+import logo from '../../assets/logo.svg';
+import styles from './index.less';
+import { getMenuData } from '../../common/menu';
+
+const { Sider } = Layout;
+const { SubMenu } = Menu;
+
+export default class SiderMenu extends PureComponent {
+  constructor(props) {
+    super(props);
+    this.menus = getMenuData();
+    this.state = {
+      openKeys: this.getDefaultCollapsedSubMenus(props),
+    };
+  }
+  getDefaultCollapsedSubMenus(props) {
+    const { location: { pathname } } = props || this.props;
+    const snippets = pathname.split('/').slice(1, -1);
+    const currentPathSnippets = snippets.map((item, index) => {
+      const arr = snippets.filter((_, i) => i <= index);
+      return arr.join('/');
+    });
+    let currentMenuSelectedKeys = [];
+    currentPathSnippets.forEach((item) => {
+      currentMenuSelectedKeys = currentMenuSelectedKeys.concat(this.getSelectedMenuKeys(item));
+    });
+    if (currentMenuSelectedKeys.length === 0) {
+      return ['dashboard'];
+    }
+    return currentMenuSelectedKeys;
+  }
+  getFlatMenuKeys(menus) {
+    let keys = [];
+    menus.forEach((item) => {
+      if (item.children) {
+        keys.push(item.path);
+        keys = keys.concat(this.getFlatMenuKeys(item.children));
+      } else {
+        keys.push(item.path);
+      }
+    });
+    return keys;
+  }
+  getSelectedMenuKeys = (path) => {
+    const flatMenuKeys = this.getFlatMenuKeys(this.menus);
+
+    if (flatMenuKeys.indexOf(path.replace(/^\//, '')) > -1) {
+      return [path.replace(/^\//, '')];
+    }
+    if (flatMenuKeys.indexOf(path.replace(/^\//, '').replace(/\/$/, '')) > -1) {
+      return [path.replace(/^\//, '').replace(/\/$/, '')];
+    }
+    return flatMenuKeys.filter((item) => {
+      const itemRegExpStr = `^${item.replace(/:[\w-]+/g, '[\\w-]+')}$`;
+      const itemRegExp = new RegExp(itemRegExpStr);
+      return itemRegExp.test(path.replace(/^\//, ''));
+    });
+  }
+  getNavMenuItems(menusData) {
+    if (!menusData) {
+      return [];
+    }
+    return menusData.map((item) => {
+      if (!item.name) {
+        return null;
+      }
+      let itemPath;
+      if (item.path && item.path.indexOf('http') === 0) {
+        itemPath = item.path;
+      } else {
+        itemPath = `/${item.path || ''}`.replace(/\/+/g, '/');
+      }
+      if (item.children && item.children.some(child => child.name)) {
+        return item.hideInMenu ? null :
+          (
+            <SubMenu
+              title={
+                item.icon ? (
+                  <span>
+                    <Icon type={item.icon} />
+                    <span>{item.name}</span>
+                  </span>
+                ) : item.name
+              }
+              key={item.key || item.path}
+            >
+              {this.getNavMenuItems(item.children)}
+            </SubMenu>
+          );
+      }
+      const icon = item.icon && <Icon type={item.icon} />;
+      return item.hideInMenu ? null :
+        (
+          <Menu.Item key={item.key || item.path}>
+            {
+              /^https?:\/\//.test(itemPath) ? (
+                <a href={itemPath} target={item.target}>
+                  {icon}<span>{item.name}</span>
+                </a>
+              ) : (
+                <Link
+                  to={itemPath}
+                  target={item.target}
+                  replace={itemPath === this.props.location.pathname}
+                  onClick={this.props.isMobile && (() => { this.props.onCollapse(true); })}
+                >
+                  {icon}<span>{item.name}</span>
+                </Link>
+              )
+            }
+          </Menu.Item>
+        );
+    });
+  }
+  handleOpenChange = (openKeys) => {
+    const lastOpenKey = openKeys[openKeys.length - 1];
+    const isMainMenu = this.menus.some(
+      item => lastOpenKey && (item.key === lastOpenKey || item.path === lastOpenKey)
+    );
+    this.setState({
+      openKeys: isMainMenu ? [lastOpenKey] : [...openKeys],
+    });
+  }
+  render() {
+    const { collapsed, location: { pathname }, onCollapse } = this.props;
+    // Don't show popup menu when it is been collapsed
+    const menuProps = collapsed ? {} : {
+      openKeys: this.state.openKeys,
+    };
+    return (
+      <Sider
+        trigger={null}
+        collapsible
+        collapsed={collapsed}
+        breakpoint="md"
+        onCollapse={onCollapse}
+        width={230}
+        className={styles.sider}
+      >
+        <div className={styles.logo}>
+          <Link to="/">
+            <img src={logo} alt="logo" />
+            <h1>领教管理平台</h1>
+          </Link>
+        </div>
+        <Menu
+          theme="dark"
+          mode="inline"
+          {...menuProps}
+          onOpenChange={this.handleOpenChange}
+          selectedKeys={this.getSelectedMenuKeys(pathname)}
+          style={{ padding: '16px 0', width: '100%' }}
+        >
+          {this.getNavMenuItems(this.menus)}
+        </Menu>
+      </Sider>
+    );
+  }
+}

+ 40 - 0
src/components/SiderMenu/index.js

@@ -0,0 +1,40 @@
+import 'rc-drawer-menu/assets/index.css';
+import React, { PureComponent } from 'react';
+import DrawerMenu from 'rc-drawer-menu';
+import SiderMenu from './SiderMenu';
+
+export default class Index extends PureComponent {
+  onCollapse = (collapsed) => {
+    this.props.dispatch({
+      type: 'global/changeLayoutCollapsed',
+      payload: collapsed,
+    });
+  }
+
+  render() {
+    const { collapsed, isMobile } = this.props;
+    return isMobile ? (
+      <DrawerMenu
+        parent={null}
+        level={null}
+        iconChild={null}
+        open={!collapsed}
+        onMaskClick={() => { this.onCollapse(true); }}
+        width="230px"
+      >
+        <SiderMenu
+          {...this.props}
+          isMobile={isMobile}
+          onCollapse={this.onCollapse}
+          collapsed={isMobile ? false : collapsed}
+        />
+      </DrawerMenu>
+    ) : (
+      <SiderMenu
+        {...this.props}
+        isMobile={isMobile}
+        onCollapse={this.onCollapse}
+      />
+    );
+  }
+}

+ 36 - 0
src/components/SiderMenu/index.less

@@ -0,0 +1,36 @@
+@import "~antd/lib/style/themes/default.less";
+@ease-in-out-circ: cubic-bezier(.78, .14, .15, .86);
+.logo {
+  height: 64px;
+  position: relative;
+  line-height: 64px;
+  padding-left: (@menu-collapsed-width - 32px) / 2;
+  transition: all .3s;
+  background: #002140;
+  overflow: hidden;
+  img {
+    display: inline-block;
+    vertical-align: middle;
+    height: 32px;
+  }
+  h1 {
+    color: #fff;
+    display: inline-block;
+    vertical-align: middle;
+    font-size: 20px;
+    margin: 0 0 0 12px;
+    font-family: 'Myriad Pro', 'Helvetica Neue', Arial, Helvetica, sans-serif;
+    font-weight: 600;
+  }
+}
+.sider {
+  min-height: 100vh;
+  box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
+  position: relative;
+  z-index: 10;
+}
+:global {
+  .drawer .drawer-content {
+    background: #001529;
+  }
+}

+ 26 - 0
src/g2.js

@@ -0,0 +1,26 @@
+// 全局 G2 设置
+import G2 from 'g2';
+
+G2.track(false);
+
+const colors = [
+  '#8543E0', '#F04864', '#FACC14', '#1890FF', '#13C2C2', '#2FC25B', '#fa8c16', '#a0d911',
+];
+
+const config = {
+  ...G2.Theme,
+  defaultColor: '#1089ff',
+  colors: {
+    default: colors,
+    intervalStack: colors,
+  },
+  tooltip: {
+    background: {
+      radius: 4,
+      fill: '#000',
+      fillOpacity: 0.75,
+    },
+  },
+};
+
+G2.Global.setTheme(config);

+ 14 - 0
src/index.ejs

@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>领教科技后台管理系统</title>
+  <link rel="icon" href="/favicon.png" type="image/x-icon">
+</head>
+<body>
+  <div id="root"></div>
+  <script src="https://gw.alipayobjects.com/as/g/??datavis/g2/2.3.12/index.js,datavis/g-cloud/1.0.2/index.js,datavis/g2-plugin-slider/1.2.1/slider.js"></script>
+</body>
+</html>

+ 41 - 0
src/index.js

@@ -0,0 +1,41 @@
+import 'babel-polyfill';
+import { message, notification } from 'antd';
+import dva from 'dva';
+import { routerRedux } from 'dva/router';
+import 'moment/locale/zh-cn';
+import './g2';
+// import './rollbar';
+import browserHistory from 'history/createBrowserHistory';
+import './index.less';
+
+const expiredErrorHandler = (err) => {
+  if (err.response && err.response.code === 10004) {
+    message.error('登录失效,请重新登录!');
+    app._store.dispatch(routerRedux.push(router.login));
+  }
+  else {
+    console.log('[ERROR]:', err);
+    notification.error({
+      message: '未知错误',
+      description: '发生未预知错误,请查看日志或联系管理员!',
+    });
+  }
+}
+
+// 1. Initialize
+const app = dva({
+  history: browserHistory(),
+  onError: expiredErrorHandler,
+});
+
+// 2. Plugins
+// app.use({});
+
+// 3. Register global model
+app.model(require('./models/global'));
+
+// 4. Router
+app.router(require('./router'));
+
+// 5. Start
+app.start('#root');

+ 14 - 0
src/index.less

@@ -0,0 +1,14 @@
+html, body, :global(#root) {
+  height: 100%;
+}
+
+body {
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.globalSpin {
+  width: 100%;
+  margin: 40px 0 !important;
+}

+ 165 - 0
src/layouts/BasicLayout.js

@@ -0,0 +1,165 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Layout, Icon } from 'antd';
+import DocumentTitle from 'react-document-title';
+import { connect } from 'dva';
+import { Route, Redirect, Switch } from 'dva/router';
+import { ContainerQuery } from 'react-container-query';
+import classNames from 'classnames';
+import { enquireScreen } from 'enquire-js';
+import GlobalHeader from '../components/GlobalHeader';
+import GlobalFooter from '../components/GlobalFooter';
+import SiderMenu from '../components/SiderMenu';
+import NotFound from '../routes/Exception/404';
+import { getRoutes } from '../utils/utils';
+import { getMenuData } from '../common/menu';
+
+
+/**
+ * 根据菜单取得重定向地址.
+ */
+const redirectData = [];
+const getRedirect = (item) => {
+  if (item && item.children) {
+    if (item.children[0] && item.children[0].path) {
+      redirectData.push({
+        from: `/${item.path}`,
+        to: `/${item.children[0].path}`,
+      });
+      item.children.forEach((children) => {
+        getRedirect(children);
+      });
+    }
+  }
+};
+getMenuData().forEach(getRedirect);
+
+const { Content } = Layout;
+const query = {
+  'screen-xs': {
+    maxWidth: 575,
+  },
+  'screen-sm': {
+    minWidth: 576,
+    maxWidth: 767,
+  },
+  'screen-md': {
+    minWidth: 768,
+    maxWidth: 991,
+  },
+  'screen-lg': {
+    minWidth: 992,
+    maxWidth: 1199,
+  },
+  'screen-xl': {
+    minWidth: 1200,
+  },
+};
+
+let isMobile;
+enquireScreen((b) => {
+  isMobile = b;
+});
+
+class BasicLayout extends React.PureComponent {
+  static childContextTypes = {
+    location: PropTypes.object,
+    breadcrumbNameMap: PropTypes.object,
+  }
+
+  state = {
+    isMobile,
+  };
+  getChildContext() {
+    const { location, routerData } = this.props;
+    return {
+      location,
+      breadcrumbNameMap: routerData,
+    };
+  }
+  componentDidMount() {
+    enquireScreen((b) => {
+      this.setState({
+        isMobile: !!b,
+      });
+    });
+  }
+  getPageTitle() {
+    const { routerData, location } = this.props;
+    const { pathname } = location;
+    let title = '领教信息';
+    if (routerData[pathname] && routerData[pathname].name) {
+      title = `${routerData[pathname].name} - 领教信息`;
+    }
+    return title;
+  }
+  render() {
+    const {
+      currentUser, collapsed, fetchingNotices, notices, routerData, match, location, dispatch,
+    } = this.props;
+    const layout = (
+      <Layout>
+        <SiderMenu
+          collapsed={collapsed}
+          location={location}
+          dispatch={dispatch}
+          isMobile={this.state.isMobile}
+        />
+        <Layout>
+          <GlobalHeader
+            currentUser={currentUser}
+            fetchingNotices={fetchingNotices}
+            notices={notices}
+            collapsed={collapsed}
+            dispatch={dispatch}
+            isMobile={this.state.isMobile}
+          />
+          <Content style={{ margin: '24px 24px 0', height: '100%' }}>
+            <div style={{ minHeight: 'calc(100vh - 260px)' }}>
+              <Switch>
+                {
+                  redirectData.map(item =>
+                    <Redirect key={item.from} exact from={item.from} to={item.to} />
+                  )
+                }
+                {
+                  getRoutes(match.path, routerData).map(item => (
+                    <Route
+                      key={item.key}
+                      path={item.path}
+                      component={item.component}
+                      exact={item.exact}
+                    />
+                  ))
+                }
+                <Redirect exact from="/" to="/dashboard" />
+                <Route render={NotFound} />
+              </Switch>
+            </div>
+            <GlobalFooter
+              copyright={
+                <div>
+                  Copyright <Icon type="copyright" /> 2017-2020 领教信息科技有限公司
+                </div>
+              }
+            />
+          </Content>
+        </Layout>
+      </Layout>
+    );
+
+    return (
+      <DocumentTitle title={this.getPageTitle()}>
+        <ContainerQuery query={query}>
+          {params => <div className={classNames(params)}>{layout}</div>}
+        </ContainerQuery>
+      </DocumentTitle>
+    );
+  }
+}
+
+export default connect(state => ({
+  collapsed: state.global.collapsed,
+  fetchingNotices: state.global.fetchingNotices,
+  notices: state.global.notices,
+}))(BasicLayout);

+ 12 - 0
src/layouts/PageHeaderLayout.js

@@ -0,0 +1,12 @@
+import React from 'react';
+import { Link } from 'dva/router';
+import PageHeader from '../components/PageHeader';
+import styles from './PageHeaderLayout.less';
+
+export default ({ children, wrapperClassName, top, ...restProps }) => (
+  <div style={{ margin: '-24px -24px 0' }} className={wrapperClassName}>
+    {top}
+    <PageHeader {...restProps} linkElement={Link} />
+    {children ? <div className={styles.content}>{children}</div> : null}
+  </div>
+);

+ 11 - 0
src/layouts/PageHeaderLayout.less

@@ -0,0 +1,11 @@
+@import "~antd/lib/style/themes/default.less";
+
+.content {
+  margin: 24px 24px 0;
+}
+
+@media screen and (max-width: @screen-sm) {
+  .content {
+    margin: 24px 0 0;
+  }
+}

+ 70 - 0
src/layouts/UserLayout.js

@@ -0,0 +1,70 @@
+import React from 'react';
+import { Link, Route } from 'dva/router';
+import DocumentTitle from 'react-document-title';
+import { Icon } from 'antd';
+import GlobalFooter from '../components/GlobalFooter';
+import styles from './UserLayout.less';
+import logo from '../assets/logo.svg';
+import { getRoutes } from '../utils/utils';
+
+const links = [{
+  title: '帮助',
+  href: '',
+}, {
+  title: '隐私',
+  href: '',
+}, {
+  title: '条款',
+  href: '',
+}];
+
+const copyright = <div>Copyright <Icon type="copyright" /> 2017-2020 领教信息科技有限公司出品</div>;
+
+class UserLayout extends React.PureComponent {
+  getPageTitle() {
+    const { routerData, location } = this.props;
+    const { pathname } = location;
+    let title = '领教科技';
+    if (routerData[pathname] && routerData[pathname].name) {
+      title = `${routerData[pathname].name} - 领教科技`;
+    }
+    return title;
+  }
+  render() {
+    const { routerData, match } = this.props;
+    return (
+      <DocumentTitle title={this.getPageTitle()}>
+        <div className={styles.outer}>
+          <div className={styles.inner}>
+            <div className={styles.container}>
+              <div className={styles.top}>
+                <div className={styles.header}>
+                  <Link to="/">
+                    <img alt="logo" className={styles.logo} src={logo} />
+                    <span className={styles.title}>CMS Browser</span>
+                  </Link>
+                </div>
+                <div className={styles.desc}>CMSBrowser - 领教信息科技有限公司后台管理系统</div>
+              </div>
+              {
+                getRoutes(match.path, routerData).map(item =>
+                  (
+                    <Route
+                      key={item.key}
+                      path={item.path}
+                      component={item.component}
+                      exact={item.exact}
+                    />
+                  )
+                )
+              }
+              <GlobalFooter colorInverse={true} className={styles.footer} links={links} copyright={copyright} />
+            </div>
+          </div>
+        </div>
+      </DocumentTitle>
+    );
+  }
+}
+
+export default UserLayout;

+ 71 - 0
src/layouts/UserLayout.less

@@ -0,0 +1,71 @@
+@import "~antd/lib/style/themes/default.less";
+
+@inverse-color: #fff;
+
+.outer {
+  width: 100%;
+  height: 100%;
+  background-image: url('http://scaffold.ant.design/angular-material-dashboard/login_bg.4e2f642c39de98d0e3c1.jpg');
+
+  .inner {
+    width: 100%;
+    height: 100%;
+    z-index: 2;
+    background-color: rgba(0, 0, 0, .7);
+
+    .container {
+      width: 100%;
+      min-height: 100%;
+      background-repeat: no-repeat;
+      background-position: center;
+      background-size: 100%;
+      padding: 110px 0 144px 0;
+      position: relative;
+    }
+
+    .top {
+      text-align: center;
+    }
+
+    .header {
+      height: 44px;
+      line-height: 44px;
+      a {
+        text-decoration: none;
+      }
+    }
+
+    .logo {
+      height: 44px;
+      vertical-align: top;
+      margin-right: 16px;
+    }
+
+    .title {
+      font-size: 33px;
+      color: @inverse-color;
+      font-family: 'Myriad Pro', 'Helvetica Neue', Arial, Helvetica, sans-serif;
+      font-weight: 600;
+      position: relative;
+      top: 2px;
+    }
+
+    .desc {
+      font-size: @font-size-base;
+      color: @inverse-color;
+      margin-top: 12px;
+      margin-bottom: 40px;
+    }
+
+    .footer {
+      position: absolute;
+      width: 100%;
+      bottom: 0;
+      
+      .copyright {
+        color: @inverse-color;
+      }
+    }
+
+  }
+}

+ 76 - 0
src/models/global.js

@@ -0,0 +1,76 @@
+import { queryNotices } from '../services/notice';
+
+export default {
+  namespace: 'global',
+
+  state: {
+    collapsed: false,
+    notices: [],
+    fetchingNotices: false,
+  },
+
+  effects: {
+    *fetchNotices(_, { call, put }) {
+      yield put({
+        type: 'changeNoticeLoading',
+        payload: true,
+      });
+      const data = yield call(queryNotices);
+      yield put({
+        type: 'saveNotices',
+        payload: data,
+      });
+    },
+    *clearNotices({ payload }, { put, select }) {
+      const count = yield select(state => state.global.notices.length);
+      yield put({
+        type: 'user/changeNotifyCount',
+        payload: count,
+      });
+
+      yield put({
+        type: 'saveClearedNotices',
+        payload,
+      });
+    },
+  },
+
+  reducers: {
+    changeLayoutCollapsed(state, { payload }) {
+      return {
+        ...state,
+        collapsed: payload,
+      };
+    },
+    saveNotices(state, { payload }) {
+      return {
+        ...state,
+        notices: payload,
+        fetchingNotices: false,
+      };
+    },
+    saveClearedNotices(state, { payload }) {
+      return {
+        ...state,
+        notices: state.notices.filter(item => item.type !== payload),
+      };
+    },
+    changeNoticeLoading(state, { payload }) {
+      return {
+        ...state,
+        fetchingNotices: payload,
+      };
+    },
+  },
+
+  subscriptions: {
+    setup({ history }) {
+      // Subscribe history(url) change, trigger `load` action if pathname is `/`
+      return history.listen(({ pathname, search }) => {
+        if (typeof window.ga !== 'undefined') {
+          window.ga('send', 'pageview', pathname + search);
+        }
+      });
+    },
+  },
+};

+ 37 - 0
src/models/login.js

@@ -0,0 +1,37 @@
+import { notification } from 'antd';
+import { routerRedux } from 'dva/router';
+import { login, logout } from '../services/login';
+import { routers } from '../utils/map';
+import { addLocalUser } from '../utils/helper';
+
+export default {
+  namespace: 'login',
+  state: {
+    loading: false,
+  },
+  effects: {
+    *login({ payload }, { put, call }) {
+      yield put({ type: 'save', payload: { loading: true } });
+      const { res: { data, success } } = yield call(login, { ...payload });
+      if (success) {
+        addLocalUser(data);
+        yield put(routerRedux.push(routers.home));
+      }
+      yield put({ type: 'save', payload: { loading: false } });
+    },
+    *logout(_, { put, call }) {
+      const { res: { success } } = yield call(logout);
+      if (success) {
+        yield put(routerRedux.push(routers.login));
+      }
+    }
+  },
+  reducers: {
+    save(state, action) {
+      return {
+        ...state,
+        ...action.payload,
+      }
+    },
+  },
+}

+ 64 - 0
src/models/user.js

@@ -0,0 +1,64 @@
+import { query as queryUsers, queryCurrent } from '../services/user';
+
+export default {
+  namespace: 'user',
+  state: {
+    list: [],
+    loading: false,
+    currentUser: {},
+  },
+  effects: {
+    *fetch(_, { call, put }) {
+      yield put({
+        type: 'changeLoading',
+        payload: true,
+      });
+      const response = yield call(queryUsers);
+      yield put({
+        type: 'save',
+        payload: response,
+      });
+      yield put({
+        type: 'changeLoading',
+        payload: false,
+      });
+    },
+    *fetchCurrent(_, { call, put }) {
+      const response = yield call(queryCurrent);
+      yield put({
+        type: 'saveCurrentUser',
+        payload: response,
+      });
+    },
+  },
+
+  reducers: {
+    save(state, action) {
+      return {
+        ...state,
+        list: action.payload,
+      };
+    },
+    changeLoading(state, action) {
+      return {
+        ...state,
+        loading: action.payload,
+      };
+    },
+    saveCurrentUser(state, action) {
+      return {
+        ...state,
+        currentUser: action.payload,
+      };
+    },
+    changeNotifyCount(state, action) {
+      return {
+        ...state,
+        currentUser: {
+          ...state.currentUser,
+          notifyCount: action.payload,
+        },
+      };
+    },
+  },
+};

+ 13 - 0
src/rollbar.js

@@ -0,0 +1,13 @@
+import Rollbar from 'rollbar';
+
+// Track error by https://sentry.io/ 前端错误日志收集,暂不启用
+if (location.host === 'preview.pro.ant.design') {
+  Rollbar.init({
+    accessToken: '033ca6d7c0eb4cc1831cf470c2649971',
+    captureUncaught: true,
+    captureUnhandledRejections: true,
+    payload: {
+      environment: 'production',
+    },
+  });
+}

+ 30 - 0
src/router.js

@@ -0,0 +1,30 @@
+import React from 'react';
+import { Router, Route, Switch } from 'dva/router';
+import { LocaleProvider, Spin } from 'antd';
+import zhCN from 'antd/lib/locale-provider/zh_CN';
+import dynamic from 'dva/dynamic';
+import { getRouterData } from './common/router';
+
+import styles from './index.less';
+
+dynamic.setDefaultLoadingComponent(() => {
+  return <Spin size="large" className={styles.globalSpin} />;
+});
+
+function RouterConfig({ history, app }) {
+  const routerData = getRouterData(app);
+  const UserLayout = routerData['/user'].component;
+  const BasicLayout = routerData['/'].component;
+  return (
+    <LocaleProvider locale={zhCN}>
+      <Router history={history}>
+        <Switch>
+          <Route path="/user" render={props => <UserLayout {...props} />} />
+          <Route path="/" render={props => <BasicLayout {...props} />} />
+        </Switch>
+      </Router>
+    </LocaleProvider>
+  );
+}
+
+export default RouterConfig;

+ 7 - 0
src/routes/Exception/403.js

@@ -0,0 +1,7 @@
+import React from 'react';
+import { Link } from 'dva/router';
+import Exception from '../../components/Exception';
+
+export default () => (
+  <Exception type="403" style={{ minHeight: 500, height: '80%' }} linkElement={Link} />
+);

+ 7 - 0
src/routes/Exception/404.js

@@ -0,0 +1,7 @@
+import React from 'react';
+import { Link } from 'dva/router';
+import Exception from '../../components/Exception';
+
+export default () => (
+  <Exception type="404" style={{ minHeight: 500, height: '80%' }} linkElement={Link} />
+);

+ 7 - 0
src/routes/Exception/500.js

@@ -0,0 +1,7 @@
+import React from 'react';
+import { Link } from 'dva/router';
+import Exception from '../../components/Exception';
+
+export default () => (
+  <Exception type="500" style={{ minHeight: 500, height: '80%' }} linkElement={Link} />
+);

+ 94 - 0
src/routes/Login/index.js

@@ -0,0 +1,94 @@
+import React, { Component } from 'react';
+import { connect } from 'dva';
+import { routerRedux, Link } from 'dva/router';
+import { Form, Input, Tabs, Button, Icon, Checkbox, Row, Col, Alert } from 'antd';
+import styles from './index.less';
+import { actions, routers } from '../../utils/map';
+
+const FormItem = Form.Item;
+const { TabPane } = Tabs;
+
+@connect(state => ({
+  login: state.login,
+}))
+@Form.create()
+export default class Login extends Component {
+  state = {
+    count: 0,
+    type: 'account',
+  }
+
+  _actionDispatcher(type, payload = {}) {
+    const { dispatch } = this.props;
+    Object.keys(payload).map(key => {payload[key] ? null : delete payload[key]});
+    dispatch({ type, payload });
+  }
+
+  handleSubmit = (e) => {
+    e.preventDefault();
+    this.props.form.validateFields(err => {
+      if (err) return;
+      const values = this.props.form.getFieldsValue(['username', 'password']);
+      this._actionDispatcher(actions.userLogin, { ...values });
+    });
+  }
+
+  renderMessage = (message) => {
+    return (
+      <Alert
+        style={{ marginBottom: 24 }}
+        message={message}
+        type="error"
+        showIcon
+      />
+    );
+  }
+
+  render() {
+    const { form, login } = this.props;
+    const { getFieldDecorator } = form;
+    const { count, type } = this.state;
+    return (
+      <div className={styles.main}>
+        <Form onSubmit={this.handleSubmit}>
+          <Tabs animated={false} className={styles.tabs} activeKey={type} onChange={this.onSwitch}>
+            <TabPane tab={<span><Icon type="user"/>账户密码登录</span>} key="account">
+              <FormItem hasFeedback>
+                {getFieldDecorator('username', {
+                  rules: [{
+                    required: type === 'account', message: '请输入账户名!',
+                  }],
+                })(
+                  <Input
+                    size="large"
+                    prefix={<Icon type="user" className={styles.prefixIcon} />}
+                    placeholder="请输入用户名"
+                  />
+                )}
+              </FormItem>
+              <FormItem hasFeedback>
+                {getFieldDecorator('password', {
+                  rules: [{
+                    required: type === 'account', message: '请输入密码!',
+                  }],
+                })(
+                  <Input
+                    size="large"
+                    prefix={<Icon type="lock" className={styles.prefixIcon} />}
+                    type="password"
+                    placeholder="请输入密码"
+                  />
+                )}
+              </FormItem>
+            </TabPane>
+          </Tabs>
+          <FormItem className={styles.additional}>
+            <Button loading={login.loading} size="large" className={styles.submit} type="primary" htmlType="submit">
+              登录
+            </Button>
+          </FormItem>
+        </Form>
+      </div>
+    );
+  }
+}

+ 110 - 0
src/routes/Login/index.less

@@ -0,0 +1,110 @@
+@import "~antd/lib/style/themes/default.less";
+
+.main {
+  height: 340px;
+  width: 340px;
+  background-color: #fff;
+  margin: 0 auto;
+  box-shadow: 0 0 100px rgba(0, 0, 0, .08);
+  border-radius: 6px;
+  padding: 36px;
+
+  .tabs {
+    padding: 0 2px;
+    margin: 0 -2px;
+    :global {
+      .ant-tabs-tab {
+        font-size: 16px;
+        line-height: 24px;
+      }
+      .ant-input-affix-wrapper .ant-input:not(:first-child) {
+        padding-left: 34px;
+      }
+    }
+  }
+
+  :global {
+    .ant-tabs .ant-tabs-bar {
+      border-bottom: 0;
+      margin-bottom: 28px;
+      text-align: center;
+    }
+
+    // .ant-form-item {
+    //   margin-bottom: 15px;
+    // }
+  }
+
+  .prefixIcon {
+    font-size: @font-size-base;
+    color: @disabled-color;
+  }
+
+  .getCaptcha {
+    display: block;
+    width: 100%;
+  }
+
+  .additional {
+    text-align: left;
+
+    .forgot {
+      float: right;
+    }
+
+    .submit {
+      width: 100%;
+      margin-top: 20px;
+    }
+
+    :global {
+      .ant-form-item-control {
+        line-height: 22px;
+      }
+    }
+  }
+
+  .iconAlipay, .iconTaobao, .iconWeibo {
+    display: inline-block;
+    width: 24px;
+    height: 24px;
+    background: url('https://gw.alipayobjects.com/zos/rmsportal/itDzjUnkelhQNsycranf.svg');
+    margin-left: 16px;
+    vertical-align: middle;
+    cursor: pointer;
+  }
+
+  .iconAlipay {
+    background-position: -24px 0;
+
+    &:hover {
+      background-position: 0 0;
+    }
+  }
+
+  .iconTaobao {
+    background-position: -24px -24px;
+
+    &:hover {
+      background-position: 0 -24px;
+    }
+  }
+
+  .iconWeibo {
+    background-position: -24px -48px;
+
+    &:hover {
+      background-position: 0 -48px;
+    }
+  }
+
+  .other {
+    text-align: left;
+    margin-top: 24px;
+    line-height: 22px;
+
+    .register {
+      float: right;
+    }
+  }
+}

+ 21 - 0
src/services/login.js

@@ -0,0 +1,21 @@
+import { stringify } from 'qs';
+import request from '../utils/request';
+import { userLogin, userLogout } from '../utils/api';
+
+export async function login(params) {
+  const options = {
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded', // 以form表单格式提交
+    },
+    method: 'POST',
+    body: stringify(params),
+  }
+  return request(`${userLogin}`, options);
+}
+
+export async function logout() {
+  const options = {
+    method: 'DELETE',
+  };
+  return request(`${userLogout}`, options);
+}

+ 7 - 0
src/services/notice.js

@@ -0,0 +1,7 @@
+import { stringify } from 'qs';
+import request from '../utils/request';
+import { userLogin } from '../utils/api';
+
+export async function queryNotices(params) {
+  return request(`${queryNotices}?${stringify(params)}`);
+}

+ 11 - 0
src/services/user.js

@@ -0,0 +1,11 @@
+import { stringify } from 'qs';
+import request from '../utils/request';
+import { users, currentUser } from '../utils/api'
+
+export async function query(params) {
+  return request(`${users}?${stringify(params)}`);
+}
+
+export async function queryCurrent(params) {
+  return request(`${currentUser}?${stringify(params)}`);
+}

+ 5 - 0
src/theme.js

@@ -0,0 +1,5 @@
+// https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
+module.exports = {
+  // 'primary-color': '#10e99b',
+  'card-actions-background': '#f5f8fa',
+};

+ 8 - 0
src/utils/api.js

@@ -0,0 +1,8 @@
+import config from './config';
+
+module.exports = {
+  users: `${config.apiHost}/cms/user/list`,
+  currentUser: `${config.apiHost}/cms/user`,
+  userLogin: `${config.apiHost}/login`,
+  userLogout: `${config.apiHost}/logout`,
+};

+ 5 - 0
src/utils/config.js

@@ -0,0 +1,5 @@
+/* 项目配置文件 */
+
+module.exports = {
+  apiHost: 'http://lj.dev.cms.api.com:9100',
+};

+ 32 - 0
src/utils/helper.js

@@ -0,0 +1,32 @@
+/**
+ * 增加或更新用户信息到本地存储
+ *
+ * @param {object} value [登录接口返回内容]
+ */
+export function addLocalUser(value) {
+  window.localStorage.setItem('LJ@2B#CMS!User', JSON.stringify(value));
+}
+
+/**
+ * 从本地存储中获取用户信息
+ *
+ * @return {JSON} [用户信息]
+ */
+export function getLocalUser() {
+  const localUser = window.localStorage.getItem('LJ@2B#CMS!User');
+  if (localUser && localUser !== 'undefined') {
+    return JSON.parse(localUser);
+  }
+}
+
+/**
+ * 从本地存储中获取token信息
+ *
+ * @return {string} [token信息]
+ */
+export function getLocalToken() {
+  const localUser = window.localStorage.getItem('LJ@2B#CMS!User');
+  if (localUser && localUser !== 'undefined') {
+    return JSON.parse(localUser).token;
+  }
+}

+ 13 - 0
src/utils/map.js

@@ -0,0 +1,13 @@
+const actions = {
+  userLogin: 'login/login',
+  userLogout: 'login/logout',
+};
+
+const routers = {
+  home: '/',
+  login: '/user/login',
+  cpList: '/merchant/cp',
+  projectList: '/merchant/project',
+};
+
+module.exports = { actions, routers };

+ 126 - 0
src/utils/request.js

@@ -0,0 +1,126 @@
+import fetch from 'dva/fetch';
+import { stringify } from 'qs';
+import { notification } from 'antd';
+
+// HTTP响应状态码
+const httpCodeMessage = {
+  200: '服务器成功返回请求的数据',
+  201: '新建或修改数据成功。',
+  202: '一个请求已经进入后台排队(异步任务)',
+  204: '删除数据成功。',
+  400: '发出的请求有错误,服务器没有进行新建或修改数据,的操作。',
+  401: '用户没有权限(令牌、用户名、密码错误)。',
+  403: '用户得到授权,但是访问是被禁止的。',
+  404: '发出的请求针对的是不存在的记录,服务器没有进行操作',
+  406: '请求的格式不可得。',
+  410: '请求的资源被永久删除,且不会再得到的。',
+  422: '当创建一个对象时,发生一个验证错误。',
+  500: '服务器发生错误,请检查服务器',
+  502: '网关错误',
+  503: '服务不可用,服务器暂时过载或维护',
+  504: '网关超时',
+};
+
+// 自定义响应状态码
+const customCodeMessage = {
+  10004: 'Token认证失败',
+};
+
+/**
+ * 检查HTTP响应状态码,>=200 && < 300正常
+ */
+function checkStatus(response) {
+  if (response.status >= 200 && response.status < 300) {
+    return response;
+  }
+  const errortext = httpCodeMessage[response.status] || response.statusText;
+  notification.error({
+    message: `请求错误 ${response.status}: ${response.url}`,
+    description: errortext,
+  });
+  const error = new Error(errortext);
+  error.response = response;
+  throw error;
+}
+
+/**
+ * 拦截接口返回的数据状态,提示错误内容
+ */
+function checkAPIData(data) {
+  if (!data.success) {
+    const errortext = customCodeMessage[data.code] || data.message;
+    notification.error({
+      message: `操作错误 ${data.code}`,
+      description: errortext,
+    });
+    if (data.code === 10004) {
+      const error = new Error(errortext);
+      error.response = data;
+      throw error;
+    }
+  }
+  return { res: data };
+}
+
+/**
+ * 处理超时错误
+ */
+function checkTimeOutError(err) {
+  notification.error({
+    message: `请求超时`,
+    description: '请求失败,请确认网络状态是否可用。',
+  });
+}
+
+/**
+ * response为promise对象,转换为json
+ */
+function promise2Json(response) {
+  return response.json();
+}
+
+/**
+ * fetch api不支持超时设置,进行浅度加工,变相实现超时设定
+ */
+function _fetch(requestPromise, timeout=8000) {
+  let timeoutAction = null;
+  const timerPromise = new Promise((resolve, reject) => {
+    timeoutAction = () => { reject() }
+  });
+  setTimeout(() => { timeoutAction() }, timeout);
+  return Promise.race([requestPromise,timerPromise]);
+};
+
+/**
+ * Requests a URL, returning an object or none.
+ *
+ * @param  {string} url       The URL we want to request
+ * @param  {object} [options] The options we want to pass to "fetch"
+ * @return {object}           An object containing either "data" or "err"
+ */
+export default function request(url, options) {
+  const defaultOptions = {
+    credentials: 'include',   // with cookies(Post CORS requests)
+  };
+  const newOptions = { ...defaultOptions, ...options };
+  newOptions.method = (newOptions.method || 'GET').toUpperCase();
+  if (newOptions.method === 'POST' || newOptions.method === 'PUT' ||
+    newOptions.method === 'PATCH' || newOptions.method === 'DELETE') {
+    newOptions.headers = {
+      'Accept': 'application/json',
+      'Content-Type': 'application/json; charset=utf-8',
+      ...newOptions.headers,
+    };
+  }
+  else {
+    newOptions.headers = {
+      'Accept': 'application/json',
+      ...newOptions.headers,
+    }
+  }
+  const originRequest = fetch(url, newOptions)
+    .then(checkStatus)
+    .then(promise2Json)
+    .then(checkAPIData)
+  return _fetch(originRequest).catch(checkTimeOutError);
+}

+ 134 - 0
src/utils/utils.js

@@ -0,0 +1,134 @@
+import moment from 'moment';
+
+export function fixedZero(val) {
+  return val * 1 < 10 ? `0${val}` : val;
+}
+
+export function getTimeDistance(type) {
+  const now = new Date();
+  const oneDay = 1000 * 60 * 60 * 24;
+
+  if (type === 'today') {
+    now.setHours(0);
+    now.setMinutes(0);
+    now.setSeconds(0);
+    return [moment(now), moment(now.getTime() + (oneDay - 1000))];
+  }
+
+  if (type === 'week') {
+    let day = now.getDay();
+    now.setHours(0);
+    now.setMinutes(0);
+    now.setSeconds(0);
+
+    if (day === 0) {
+      day = 6;
+    } else {
+      day -= 1;
+    }
+
+    const beginTime = now.getTime() - (day * oneDay);
+
+    return [moment(beginTime), moment(beginTime + ((7 * oneDay) - 1000))];
+  }
+
+  if (type === 'month') {
+    const year = now.getFullYear();
+    const month = now.getMonth();
+    const nextDate = moment(now).add(1, 'months');
+    const nextYear = nextDate.year();
+    const nextMonth = nextDate.month();
+
+    return [moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000)];
+  }
+
+  if (type === 'year') {
+    const year = now.getFullYear();
+
+    return [moment(`${year}-01-01 00:00:00`), moment(`${year}-12-31 23:59:59`)];
+  }
+}
+
+export function getPlainNode(nodeList, parentPath = '') {
+  const arr = [];
+  nodeList.forEach((node) => {
+    const item = node;
+    item.path = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/');
+    item.exact = true;
+    if (item.children && !item.component) {
+      arr.push(...getPlainNode(item.children, item.path));
+    } else {
+      if (item.children && item.component) {
+        item.exact = false;
+      }
+      arr.push(item);
+    }
+  });
+  return arr;
+}
+
+export function digitUppercase(n) {
+  const fraction = ['角', '分'];
+  const digit = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
+  const unit = [
+    ['元', '万', '亿'],
+    ['', '拾', '佰', '仟'],
+  ];
+  let num = Math.abs(n);
+  let s = '';
+  fraction.forEach((item, index) => {
+    s += (digit[Math.floor(num * 10 * (10 ** index)) % 10] + item).replace(/零./, '');
+  });
+  s = s || '整';
+  num = Math.floor(num);
+  for (let i = 0; i < unit[0].length && num > 0; i += 1) {
+    let p = '';
+    for (let j = 0; j < unit[1].length && num > 0; j += 1) {
+      p = digit[num % 10] + unit[1][j] + p;
+      num = Math.floor(num / 10);
+    }
+    s = p.replace(/(零.)*零$/, '').replace(/^$/, '零') + unit[0][i] + s;
+  }
+
+  return s.replace(/(零.)*零元/, '元').replace(/(零.)+/g, '零').replace(/^整$/, '零元整');
+}
+
+function getRelation(str1, str2) {
+  if (str1 === str2) {
+    console.warn('Two path are equal!');  // eslint-disable-line
+  }
+  const arr1 = str1.split('/');
+  const arr2 = str2.split('/');
+  if (arr2.every((item, index) => item === arr1[index])) {
+    return 1;
+  } else if (arr1.every((item, index) => item === arr2[index])) {
+    return 2;
+  }
+  return 3;
+}
+
+export function getRoutes(path, routerData) {
+  let routes = Object.keys(routerData).filter(routePath =>
+    routePath.indexOf(path) === 0 && routePath !== path);
+  routes = routes.map(item => item.replace(path, ''));
+  let renderArr = [];
+  renderArr.push(routes[0]);
+  for (let i = 1; i < routes.length; i += 1) {
+    let isAdd = false;
+    isAdd = renderArr.every(item => getRelation(item, routes[i]) === 3);
+    renderArr = renderArr.filter(item => getRelation(item, routes[i]) !== 1);
+    if (isAdd) {
+      renderArr.push(routes[i]);
+    }
+  }
+  const renderRoutes = renderArr.map((item) => {
+    const exact = !routes.some(route => route !== item && getRelation(route, item) === 1);
+    return {
+      key: `${path}${item}`,
+      path: `${path}${item}`,
+      component: routerData[`${path}${item}`].component,
+      exact,
+    };
+  });
+  return renderRoutes;
+}

+ 50 - 0
src/utils/utils.less

@@ -0,0 +1,50 @@
+.textOverflow() {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  word-break: break-all;
+  white-space: nowrap;
+}
+
+.textOverflowMulti(@line: 3, @bg: #fff) {
+  overflow: hidden;
+  position: relative;
+  line-height: 1.5em;
+  max-height: @line * 1.5em;
+  text-align: justify;
+  margin-right: -1em;
+  padding-right: 1em;
+  &:before {
+    background: @bg;
+    content: '...';
+    padding: 0 1px;
+    position: absolute;
+    right: 14px;
+    bottom: 0;
+  }
+  &:after {
+    background: white;
+    content: '';
+    margin-top: 0.2em;
+    position: absolute;
+    right: 14px;
+    width: 1em;
+    height: 1em;
+  }
+}
+
+// mixins for clearfix
+// ------------------------
+.clearfix() {
+  zoom: 1;
+  &:before,
+  &:after {
+    content: " ";
+    display: table;
+  }
+  &:after {
+    clear: both;
+    visibility: hidden;
+    font-size: 0;
+    height: 0;
+  }
+}

+ 1 - 0
tests/jasmine.js

@@ -0,0 +1 @@
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;

+ 35 - 0
tests/run-tests.js

@@ -0,0 +1,35 @@
+const { spawn } = require('child_process');
+const { kill } = require('cross-port-killer');
+
+const env = Object.create(process.env);
+env.BROWSER = 'none';
+const startServer = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['start'], {
+  env,
+});
+
+startServer.stderr.on('data', (data) => {
+  // eslint-disable-next-line
+  console.log(data);
+});
+
+startServer.on('exit', () => {
+  kill(process.env.PORT || 8000);
+});
+
+// eslint-disable-next-line
+console.log('Starting development server for e2e tests...');
+startServer.stdout.on('data', (data) => {
+  // eslint-disable-next-line
+  console.log(data.toString());
+  if (data.toString().indexOf('The app is running at') >= 0 ||
+      data.toString().indexOf('Compiled with warnings') >= 0) {
+    // eslint-disable-next-line
+    console.log('Development server is started, ready to run tests.');
+    const testCmd = spawn(/^win/.test(process.platform) ? 'npm.cmd' : 'npm', ['test'], {
+      stdio: 'inherit',
+    });
+    testCmd.on('exit', () => {
+      startServer.kill();
+    });
+  }
+});

+ 16 - 0
tests/setupTests.js

@@ -0,0 +1,16 @@
+/* eslint-disable import/first */
+global.requestAnimationFrame =
+  global.requestAnimationFrame || function requestAnimationFrame(callback) {
+    setTimeout(callback, 0);
+  };
+import { jsdom } from 'jsdom';
+import Enzyme from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+Enzyme.configure({ adapter: new Adapter() });
+
+// fixed jsdom miss
+const documentHTML = '<!doctype html><html><body><div id="root"></div></body></html>';
+global.document = jsdom(documentHTML);
+global.window = document.defaultView;
+global.navigator = global.window.navigator;

+ 1 - 0
tests/styleMock.js

@@ -0,0 +1 @@
+module.exports = {};