技术选型

参考文档

目录结构

  1. ├── mock 本地模拟数据
  2. ├── public
  3. └── favicon.ico Favicon
  4. ├── src
  5. ├── assets 本地静态资源
  6. ├── common 应用公用配置,如导航信息
  7. ├── components 业务通用组件
  8. ├── e2e 集成测试用例
  9. ├── layouts 通用布局
  10. ├── models dva model
  11. ├── routes 业务页面入口和常用模板
  12. ├── services 后台接口服务
  13. ├── utils 工具库
  14. ├── g2.js 可视化图形配置
  15. ├── theme.js 主题配置
  16. ├── index.ejs HTML 入口模板
  17. ├── index.js 应用入口
  18. ├── index.less 全局样式
  19. └── router.js 路由入口
  20. ├── tests 测试工具
  21. ├── README.md
  22. └── package.json

模板改造

为了适应项目具体的业务逻辑,在ant-design-pro V1的基础上,改造了部分代码,已使用我司内部项目的需求;

package.json

  • scripts下的start命令和start:no-proxy命令增加ENV=dev的参数设置,即设置环境变量ENV为dev
  1. {
  2. "name": "ant-design-pro",
  3. "version": "1.3.0",
  4. "description": "An out-of-box UI solution for enterprise applications",
  5. "private": true,
  6. "scripts": {
  7. "start": "cross-env ESLINT=none PORT=9001 ENV=dev roadhog dev",
  8. "start:no-proxy": "cross-env NO_PROXY=true ENV=dev ESLINT=none roadhog dev",
  9. "build": "cross-env ESLINT=none roadhog build",
  10. "site": "roadhog-api-doc static && gh-pages -d dist",
  11. "analyze": "cross-env ANALYZE=true roadhog build",
  12. "lint:style": "stylelint \"src/**/*.less\" --syntax less",
  13. "lint": "eslint --ext .js src mock tests && npm run lint:style",
  14. "lint:fix": "eslint --fix --ext .js src mock tests && npm run lint:style",
  15. "lint-staged": "lint-staged",
  16. "lint-staged:js": "eslint --ext .js",
  17. "test": "roadhog test",
  18. "test:component": "roadhog test ./src/components",
  19. "test:all": "node ./tests/run-tests.js",
  20. "prettier": "prettier --write ./src/**/**/**/*"
  21. },
  22. "dependencies": {
  23. "@antv/data-set": "^0.8.0",
  24. "@babel/polyfill": "^7.0.0-beta.36",
  25. "antd": "^3.4.3",
  26. "axios": "^0.18.0",
  27. "babel-plugin-transform-decorators-legacy": "^1.3.4",
  28. "babel-runtime": "^6.9.2",
  29. "bizcharts": "^3.1.5",
  30. "bizcharts-plugin-slider": "^2.0.1",
  31. "classnames": "^2.2.5",
  32. "cropperjs": "^1.4.0",
  33. "draft-js": "^0.10.5",
  34. "draftjs-to-html": "^0.8.4",
  35. "draftjs-to-markdown": "^0.5.1",
  36. "dva": "^2.2.3",
  37. "dva-loading": "^1.0.4",
  38. "echarts": "^4.2.0-rc.2",
  39. "enquire-js": "^0.2.1",
  40. "less": "2.7.2",
  41. "lodash": "^4.17.4",
  42. "lodash-decorators": "^4.4.1",
  43. "moment": "^2.19.1",
  44. "numeral": "^2.0.6",
  45. "omit.js": "^1.0.0",
  46. "path-to-regexp": "^2.1.0",
  47. "prop-types": "^15.5.10",
  48. "qs": "^6.5.0",
  49. "quill": "^1.3.6",
  50. "rc-drawer-menu": "^0.5.0",
  51. "react": "^16.4.0",
  52. "react-container-query": "^0.9.1",
  53. "react-document-title": "^2.0.3",
  54. "react-dom": "^16.4.0",
  55. "react-draft-wysiwyg": "^1.12.13",
  56. "react-fittext": "^1.0.0",
  57. "rollbar": "^2.3.4",
  58. "url-polyfill": "^1.0.10"
  59. },
  60. "devDependencies": {
  61. "babel-eslint": "^8.1.2",
  62. "babel-plugin-dva-hmr": "^0.4.1",
  63. "babel-plugin-import": "^1.6.7",
  64. "babel-plugin-module-resolver": "^3.1.1",
  65. "babel-plugin-transform-remove-console": "^6.9.4",
  66. "cross-env": "^5.1.1",
  67. "cross-port-killer": "^1.0.1",
  68. "enzyme": "^3.1.0",
  69. "eslint": "^4.14.0",
  70. "eslint-config-airbnb": "^16.0.0",
  71. "eslint-config-prettier": "^2.9.0",
  72. "eslint-plugin-babel": "^4.0.0",
  73. "eslint-plugin-compat": "^2.1.0",
  74. "eslint-plugin-import": "^2.8.0",
  75. "eslint-plugin-jsx-a11y": "^6.0.3",
  76. "eslint-plugin-markdown": "^1.0.0-beta.6",
  77. "eslint-plugin-react": "^7.0.1",
  78. "gh-pages": "^1.0.0",
  79. "husky": "^0.14.3",
  80. "lint-staged": "^6.0.0",
  81. "mockjs": "^1.0.1-beta3",
  82. "prettier": "^1.13.5",
  83. "pro-download": "^1.0.1",
  84. "redbox-react": "^1.5.0",
  85. "regenerator-runtime": "^0.11.1",
  86. "roadhog": "^2.3.0",
  87. "roadhog-api-doc": "^1.0.2",
  88. "stylelint": "^8.4.0",
  89. "stylelint-config-prettier": "^3.0.4",
  90. "stylelint-config-standard": "^18.0.0"
  91. },
  92. "optionalDependencies": {
  93. "puppeteer": "^1.1.1"
  94. },
  95. "lint-staged": {
  96. "**/*.{js,jsx,less}": [
  97. "prettier --write",
  98. "git add"
  99. ],
  100. "**/*.{js,jsx}": "lint-staged:js",
  101. "**/*.less": "stylelint --syntax less"
  102. },
  103. "engines": {
  104. "node": ">=8.0.0"
  105. },
  106. "browserslist": [
  107. "> 1%",
  108. "last 2 versions",
  109. "not ie <= 10"
  110. ]
  111. }

.webpacjrc.js

  • 在.webpacjrc.js的proxy字段设置代理
  • 在.webpacjrc.js的define字段设置如下,依据环境变量值,注入页面的环境变量__ENV__,在页面开发中就能获取这个参数判断是开发环境还是线上环境,从而执行不同的操作(后续打包的时候会用到这个变量,从而避免打包是反复修改代码造成的重复工作)
  1. const path = require('path');
  2. export default {
  3. entry: 'src/index.js',
  4. extraBabelPlugins: [['import', { libraryName: 'antd', libraryDirectory: 'es', style: true }]],
  5. env: {
  6. development: {
  7. extraBabelPlugins: ['dva-hmr'],
  8. },
  9. },
  10. alias: {
  11. components: path.resolve(__dirname, 'src/components/'),
  12. },
  13. ignoreMomentLocale: true,
  14. theme: './src/theme.js',
  15. html: {
  16. template: './src/index.ejs',
  17. },
  18. disableDynamicImport: true,
  19. publicPath: '/',
  20. hash: true,
  21. define: {
  22. __ENV__: process.env.ENV || '',
  23. },
  24. // proxy,
  25. proxy: {
  26. '/proxy': {
  27. // target: 'http://61.174.254.204:8008', // 测试ip
  28. // target: 'http://192.168.18.169:8080/', // 刘云江ip
  29. target: 'http://192.168.18.135:8080/', // 楼高峰ip
  30. // target: 'http://192.168.18.153:8080/', // 朱鹏飞ip
  31. // target: 'http://192.168.18.231:8080/',
  32. // target: 'http://183.131.202.93:9071/mock/15/',
  33. // target: 'http://127.0.0.1:3000/',
  34. changeOrigin: true,
  35. // pathRewrite: { '^/proxy': '/hdwh' }, // 线上测试用
  36. pathRewrite: { '^/proxy': '' },
  37. },
  38. },
  39. };

./src/utils/request.js

依据和后端ajax返回值状态的预定,改造request.js,目前的版本因为有历史原因,封装的不够完美,还有些后续的错误处理可以封装在request.js里面,后续项目启动时候需要改造优化,减少重复代码,目前版本的代码如下(原版使用的是fetch,改为使用axios)

  1. import fetch from 'dva/fetch';
  2. import { notification, message } from 'antd';
  3. import { routerRedux } from 'dva/router';
  4. import axios from 'axios';
  5. import store from '../index';
  6. // Make a request for a user with a given ID
  7. const codeMessage = {
  8. 200: '服务器成功返回请求的数据。',
  9. 201: '新建或修改数据成功。',
  10. 202: '一个请求已经进入后台排队(异步任务)。',
  11. 204: '删除数据成功。',
  12. 400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
  13. 401: '用户没有权限(令牌、用户名、密码错误)。',
  14. 403: '用户得到授权,但是访问是被禁止的。',
  15. 404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',
  16. 406: '请求的格式不可得。',
  17. 410: '请求的资源被永久删除,且不会再得到的。',
  18. 422: '当创建一个对象时,发生一个验证错误。',
  19. 500: '服务器发生错误,请检查服务器。',
  20. 502: '网关错误。',
  21. 503: '服务不可用,服务器暂时过载或维护。',
  22. 504: '网关超时。',
  23. };
  24. function checkStatus(response) {
  25. if (response.status >= 200 && response.status < 300) {
  26. return response;
  27. }
  28. const errortext = codeMessage[response.status] || response.statusText;
  29. notification.error({
  30. message: `请求错误 ${response.status}: ${response.url}`,
  31. description: errortext,
  32. });
  33. const error = new Error(errortext);
  34. error.name = response.status;
  35. error.response = response;
  36. throw error;
  37. }
  38. /**
  39. * Requests a URL, returning a promise.
  40. *
  41. * @param {string} url The URL we want to request
  42. * @param {object} [options] The options we want to pass to "fetch"
  43. * @return {object} An object containing either "data" or "err"
  44. */
  45. export function requestFetch(url, options) {
  46. const defaultOptions = {
  47. credentials: 'include',
  48. };
  49. const newOptions = { ...defaultOptions, ...options };
  50. if (newOptions.method === 'POST' || newOptions.method === 'PUT') {
  51. if (!(newOptions.body instanceof FormData)) {
  52. newOptions.headers = {
  53. Accept: 'application/json',
  54. 'Content-Type': 'application/json; charset=utf-8',
  55. ...newOptions.headers,
  56. };
  57. newOptions.body = JSON.stringify(newOptions.body);
  58. } else {
  59. // newOptions.body is FormData
  60. newOptions.headers = {
  61. Accept: 'application/json',
  62. ...newOptions.headers,
  63. };
  64. }
  65. }
  66. return fetch(url, newOptions)
  67. .then(checkStatus)
  68. .then(response => {
  69. if (newOptions.method === 'DELETE' || response.status === 204) {
  70. return response.text();
  71. }
  72. return response.json();
  73. })
  74. .catch(e => {
  75. const { dispatch } = store;
  76. const status = e.name;
  77. if (status === 401) {
  78. dispatch({
  79. type: 'login/logout',
  80. });
  81. return;
  82. }
  83. if (status === 403) {
  84. dispatch(routerRedux.push('/exception/403'));
  85. return;
  86. }
  87. if (status <= 504 && status >= 500) {
  88. dispatch(routerRedux.push('/exception/500'));
  89. return;
  90. }
  91. if (status >= 404 && status < 422) {
  92. dispatch(routerRedux.push('/exception/404'));
  93. }
  94. });
  95. }
  96. /**
  97. * 切换到axios来执行ajax
  98. * 'x-requested-with': 'XMLHttpRequest',参数用来传递的后端判断是否是ajax请求,是的话session过期请求头返回
  99. * sessionstatus:timeout,前端统一判断处理
  100. * @param url
  101. * @param options
  102. * @returns {Promise<AxiosResponse<any>>}
  103. *
  104. */
  105. export default function request(url, options) {
  106. return axios(url, {
  107. ...options,
  108. headers: options
  109. ? {
  110. 'x-requested-with': 'XMLHttpRequest',
  111. ...options.headers,
  112. }
  113. : {
  114. 'x-requested-with': 'XMLHttpRequest',
  115. },
  116. data: options && options.body,
  117. })
  118. .then(response => {
  119. // 统一处理session过期
  120. if (response.headers.sessionstatus && response.headers.sessionstatus === 'timeout') {
  121. message.success('登录过期,请重新登录!', 1.5);
  122. store.dispatch({
  123. type: 'login/timeOut',
  124. });
  125. }
  126. if (response.config.method.toLowerCase() === 'delete' && response.status === 204) {
  127. message.success('删除成功!', 1.5);
  128. }
  129. return response.data;
  130. })
  131. .catch(e => {
  132. console.dir(e);
  133. // console.log(e.response)
  134. const { dispatch } = store;
  135. const status = e.response.status;
  136. message.error(e.toString());
  137. // message.error(`${status}:${e.response.data.error},${e.response.data.message}`);
  138. if (status === 401) {
  139. dispatch({
  140. type: 'login/logout',
  141. });
  142. return;
  143. }
  144. if (status === 403) {
  145. dispatch(routerRedux.push('/exception/403'));
  146. return;
  147. }
  148. if (status <= 504 && status >= 500) {
  149. dispatch(routerRedux.push('/exception/500'));
  150. return;
  151. }
  152. if (status >= 404 && status < 422) {
  153. dispatch(routerRedux.push('/exception/404'));
  154. }
  155. });
  156. }

./src/services/api.js

  • 封装UrlExchange方法用于转化请求路径,开发过程中,前端自定义一个前缀(现在基本用proxy)用于转发代理,线上发布后请求用nginx转发,会约定一个前缀(下面的示例代码用hdwh),不同项目线上的前缀不一样,问后台就行了,这里用到了配置的环境变量__ENV__,避免打包时候反复修改代码
  1. import { stringify } from 'qs';
  2. import request, { requestFetch } from '../utils/request';
  3. export function UrlExchange(url) {
  4. // eslint-disable-next-line no-undef
  5. if(__ENV__ === 'dev'){ // __ENV__是环境变量,在.webpackrc中define栏配置
  6. return `/proxy${url}`; // 开发使用
  7. }
  8. return `/hdwh${url}`; // 线上打包地址
  9. }
  10. /**
  11. * 测试
  12. * @param params 分页参数
  13. * @returns {Promise<AxiosResponse<any>>}
  14. */
  15. export async function queryTest(params) {
  16. return request(UrlExchange(`/getAccountByName?name=liuyunjaing`));
  17. }

菜单权限管理

项目需求:菜单可以配置,角色可以配置,不同角色可以分配不同菜单,不同账号可以分配不同角色,目前逻辑一个账号只能对应一个角色。(具体可以参考华东五禾后台管理系统项目的代码,在./src/routes/Permission目录下)

image.png

  1. 线上配置菜单(菜单标题,icon是ant-design自带的Icon的名字,菜单路径即路由的path)
  2. 在./src/common/router.js中配置菜单路径对应的组件(key为菜单路径),类似如下:(注意,routerConfig中路由配置的顺序也是有用的,用户登录进来后会优先从上往下匹配路径,匹配到有权限使用的路径则返回,所以需要根据业务需求,调整为适合自己项目的排序)image.png
  3. 菜单menuData的改造,现在menuData是登录的时候返回(在./src/mosrc/models/login中查看相关代码),此时的menuData数据结构并不是ant-design-pro需要的数据结构,需要自己改造成ant-design-pro能使用的数据结构,改造过程比较复杂,方法都在./src/utils/utils.js中,改造成功后的menuData会存放在localStorage中,登录成功后通过从localStorage中重新获取menuData来渲染菜单

./src/mosrc/models/login

  1. effects: {
  2. *login({ payload }, { call, put }) {
  3. const res = yield call(bgAccountLogin, payload);
  4. localStorage.setItem('initMenu', JSON.stringify(res.data));
  5. if (res.code && res.code === '1000004') {
  6. res.msg && message.success(res.msg, 1.5);
  7. // 如果是第一次登录的话
  8. if (res.firstLogin) {
  9. yield put(routerRedux.push('/user/first-login'));
  10. } else {
  11. setStorage('menuData', menuDataMap(res.data));
  12. setStorage('userInfo', {
  13. loginId: res.loginId,
  14. displayname: res.displayname,
  15. publisherName: res.publisherName,
  16. });
  17. setStorage('roles', res.roles);
  18. setStorage('roleTypes', res.roleTypes);
  19. yield put({
  20. type: 'changeLoginStatus',
  21. payload: res,
  22. });
  23. // Login successfully
  24. reloadAuthorized();
  25. // 用window.location.href 登录成功刷新页面,重新初始化routerData,否则routerData不是用服务端返回的menuData做关联,权限控制会有bug
  26. const urlParams = new URL(window.location.href);
  27. const redirect = urlParams.searchParams.get('redirect');
  28. if (redirect) {
  29. window.location.href = redirect;
  30. } else {
  31. window.location.href = '/';
  32. }
  33. }
  34. } else if (res.code && res.msg) {
  35. message.error(res.msg);
  36. } else {
  37. message.error('登录异常!');
  38. }
  39. // const rs = yield call(queryTest,payload);
  40. // console.log(rs);
  41. // const response = yield call(fakeAccountLogin, payload);
  42. },

./src/common/menu.js

  1. import { isUrl } from '../utils/utils';
  2. let menuData = [];
  3. function formatter(data, parentPath = '/', parentAuthority) {
  4. return data.map(item => {
  5. let { path } = item;
  6. if (!isUrl(path)) {
  7. path = parentPath + item.path;
  8. }
  9. const result = {
  10. ...item,
  11. path,
  12. authority: item.authority || parentAuthority,
  13. };
  14. if (item.children) {
  15. result.children = formatter(item.children, `${parentPath}${item.path}/`, item.authority);
  16. }
  17. return result;
  18. });
  19. }
  20. export const getMenuData = () => {
  21. if (localStorage.getItem('menuData')) {
  22. menuData = JSON.parse(localStorage.getItem('menuData'));
  23. } else {
  24. menuData = [
  25. {
  26. name: 'dashboard',
  27. icon: 'dashboard',
  28. path: 'dashboard',
  29. children: [
  30. {
  31. name: '分析页',
  32. path: 'analysis',
  33. },
  34. {
  35. name: '监控页',
  36. path: 'monitor',
  37. },
  38. {
  39. name: '工作台',
  40. path: 'workplace',
  41. // hideInBreadcrumb: true,
  42. // hideInMenu: true,
  43. },
  44. ],
  45. },
  46. ];
  47. }
  48. return formatter(menuData);
  49. };

鉴权组件的改造

增加了vip[String]控制组件能被所有用户访问,权限这块的改造比较麻烦,涉及到很多地方,具体代码参考五禾项目的代码(应该有相关注释), ./src/layouts/BasicLayout.js等js文件的代码都会有影响,新项目可以重新理一下

image.png

待续

ant-design-pro已经升级V2,后续项目如果时间允许,可以考虑使用V2模板,进一步重构代码的实现