技术选型
- 基于
react,官网地址:https://reactjs.org/;中文:https://react.docschina.org/; - UI组件库使用
ant-design,官网文档:https://ant.design/docs/react/introduce-cn; - 脚手架用
ant-design-proV1,官网地址:https://pro.ant.design/index-cn;
参考文档
目录结构
├── mock 本地模拟数据├── public│ └── favicon.ico Favicon├── src│ ├── assets 本地静态资源│ ├── common 应用公用配置,如导航信息│ ├── components 业务通用组件│ ├── e2e 集成测试用例│ ├── layouts 通用布局│ ├── models dva model│ ├── routes 业务页面入口和常用模板│ ├── services 后台接口服务│ ├── utils 工具库│ ├── g2.js 可视化图形配置│ ├── theme.js 主题配置│ ├── index.ejs HTML 入口模板│ ├── index.js 应用入口│ ├── index.less 全局样式│ └── router.js 路由入口├── tests 测试工具├── README.md└── package.json
模板改造
为了适应项目具体的业务逻辑,在
ant-design-proV1的基础上,改造了部分代码,已使用我司内部项目的需求;
package.json
- 对
scripts下的start命令和start:no-proxy命令增加ENV=dev的参数设置,即设置环境变量ENV为dev
{"name": "ant-design-pro","version": "1.3.0","description": "An out-of-box UI solution for enterprise applications","private": true,"scripts": {"start": "cross-env ESLINT=none PORT=9001 ENV=dev roadhog dev","start:no-proxy": "cross-env NO_PROXY=true ENV=dev ESLINT=none roadhog dev","build": "cross-env ESLINT=none roadhog build","site": "roadhog-api-doc static && gh-pages -d dist","analyze": "cross-env ANALYZE=true roadhog build","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": "roadhog test","test:component": "roadhog test ./src/components","test:all": "node ./tests/run-tests.js","prettier": "prettier --write ./src/**/**/**/*"},"dependencies": {"@antv/data-set": "^0.8.0","@babel/polyfill": "^7.0.0-beta.36","antd": "^3.4.3","axios": "^0.18.0","babel-plugin-transform-decorators-legacy": "^1.3.4","babel-runtime": "^6.9.2","bizcharts": "^3.1.5","bizcharts-plugin-slider": "^2.0.1","classnames": "^2.2.5","cropperjs": "^1.4.0","draft-js": "^0.10.5","draftjs-to-html": "^0.8.4","draftjs-to-markdown": "^0.5.1","dva": "^2.2.3","dva-loading": "^1.0.4","echarts": "^4.2.0-rc.2","enquire-js": "^0.2.1","less": "2.7.2","lodash": "^4.17.4","lodash-decorators": "^4.4.1","moment": "^2.19.1","numeral": "^2.0.6","omit.js": "^1.0.0","path-to-regexp": "^2.1.0","prop-types": "^15.5.10","qs": "^6.5.0","quill": "^1.3.6","rc-drawer-menu": "^0.5.0","react": "^16.4.0","react-container-query": "^0.9.1","react-document-title": "^2.0.3","react-dom": "^16.4.0","react-draft-wysiwyg": "^1.12.13","react-fittext": "^1.0.0","rollbar": "^2.3.4","url-polyfill": "^1.0.10"},"devDependencies": {"babel-eslint": "^8.1.2","babel-plugin-dva-hmr": "^0.4.1","babel-plugin-import": "^1.6.7","babel-plugin-module-resolver": "^3.1.1","babel-plugin-transform-remove-console": "^6.9.4","cross-env": "^5.1.1","cross-port-killer": "^1.0.1","enzyme": "^3.1.0","eslint": "^4.14.0","eslint-config-airbnb": "^16.0.0","eslint-config-prettier": "^2.9.0","eslint-plugin-babel": "^4.0.0","eslint-plugin-compat": "^2.1.0","eslint-plugin-import": "^2.8.0","eslint-plugin-jsx-a11y": "^6.0.3","eslint-plugin-markdown": "^1.0.0-beta.6","eslint-plugin-react": "^7.0.1","gh-pages": "^1.0.0","husky": "^0.14.3","lint-staged": "^6.0.0","mockjs": "^1.0.1-beta3","prettier": "^1.13.5","pro-download": "^1.0.1","redbox-react": "^1.5.0","regenerator-runtime": "^0.11.1","roadhog": "^2.3.0","roadhog-api-doc": "^1.0.2","stylelint": "^8.4.0","stylelint-config-prettier": "^3.0.4","stylelint-config-standard": "^18.0.0"},"optionalDependencies": {"puppeteer": "^1.1.1"},"lint-staged": {"**/*.{js,jsx,less}": ["prettier --write","git add"],"**/*.{js,jsx}": "lint-staged:js","**/*.less": "stylelint --syntax less"},"engines": {"node": ">=8.0.0"},"browserslist": ["> 1%","last 2 versions","not ie <= 10"]}
.webpacjrc.js
- 在.webpacjrc.js的proxy字段设置代理
- 在.webpacjrc.js的define字段设置如下,依据环境变量值,注入页面的环境变量
__ENV__,在页面开发中就能获取这个参数判断是开发环境还是线上环境,从而执行不同的操作(后续打包的时候会用到这个变量,从而避免打包是反复修改代码造成的重复工作)
const path = require('path');export default {entry: 'src/index.js',extraBabelPlugins: [['import', { libraryName: 'antd', libraryDirectory: 'es', style: true }]],env: {development: {extraBabelPlugins: ['dva-hmr'],},},alias: {components: path.resolve(__dirname, 'src/components/'),},ignoreMomentLocale: true,theme: './src/theme.js',html: {template: './src/index.ejs',},disableDynamicImport: true,publicPath: '/',hash: true,define: {__ENV__: process.env.ENV || '',},// proxy,proxy: {'/proxy': {// target: 'http://61.174.254.204:8008', // 测试ip// target: 'http://192.168.18.169:8080/', // 刘云江iptarget: 'http://192.168.18.135:8080/', // 楼高峰ip// target: 'http://192.168.18.153:8080/', // 朱鹏飞ip// target: 'http://192.168.18.231:8080/',// target: 'http://183.131.202.93:9071/mock/15/',// target: 'http://127.0.0.1:3000/',changeOrigin: true,// pathRewrite: { '^/proxy': '/hdwh' }, // 线上测试用pathRewrite: { '^/proxy': '' },},},};
./src/utils/request.js
依据和后端ajax返回值状态的预定,改造request.js,目前的版本因为有历史原因,封装的不够完美,还有些后续的错误处理可以封装在request.js里面,后续项目启动时候需要改造优化,减少重复代码,目前版本的代码如下(原版使用的是fetch,改为使用axios)
import fetch from 'dva/fetch';import { notification, message } from 'antd';import { routerRedux } from 'dva/router';import axios from 'axios';import store from '../index';// Make a request for a user with a given IDconst codeMessage = {200: '服务器成功返回请求的数据。',201: '新建或修改数据成功。',202: '一个请求已经进入后台排队(异步任务)。',204: '删除数据成功。',400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',401: '用户没有权限(令牌、用户名、密码错误)。',403: '用户得到授权,但是访问是被禁止的。',404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',406: '请求的格式不可得。',410: '请求的资源被永久删除,且不会再得到的。',422: '当创建一个对象时,发生一个验证错误。',500: '服务器发生错误,请检查服务器。',502: '网关错误。',503: '服务不可用,服务器暂时过载或维护。',504: '网关超时。',};function checkStatus(response) {if (response.status >= 200 && response.status < 300) {return response;}const errortext = codeMessage[response.status] || response.statusText;notification.error({message: `请求错误 ${response.status}: ${response.url}`,description: errortext,});const error = new Error(errortext);error.name = response.status;error.response = response;throw error;}/*** Requests a URL, returning a promise.** @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 function requestFetch(url, options) {const defaultOptions = {credentials: 'include',};const newOptions = { ...defaultOptions, ...options };if (newOptions.method === 'POST' || newOptions.method === 'PUT') {if (!(newOptions.body instanceof FormData)) {newOptions.headers = {Accept: 'application/json','Content-Type': 'application/json; charset=utf-8',...newOptions.headers,};newOptions.body = JSON.stringify(newOptions.body);} else {// newOptions.body is FormDatanewOptions.headers = {Accept: 'application/json',...newOptions.headers,};}}return fetch(url, newOptions).then(checkStatus).then(response => {if (newOptions.method === 'DELETE' || response.status === 204) {return response.text();}return response.json();}).catch(e => {const { dispatch } = store;const status = e.name;if (status === 401) {dispatch({type: 'login/logout',});return;}if (status === 403) {dispatch(routerRedux.push('/exception/403'));return;}if (status <= 504 && status >= 500) {dispatch(routerRedux.push('/exception/500'));return;}if (status >= 404 && status < 422) {dispatch(routerRedux.push('/exception/404'));}});}/*** 切换到axios来执行ajax* 'x-requested-with': 'XMLHttpRequest',参数用来传递的后端判断是否是ajax请求,是的话session过期请求头返回* sessionstatus:timeout,前端统一判断处理* @param url* @param options* @returns {Promise<AxiosResponse<any>>}**/export default function request(url, options) {return axios(url, {...options,headers: options? {'x-requested-with': 'XMLHttpRequest',...options.headers,}: {'x-requested-with': 'XMLHttpRequest',},data: options && options.body,}).then(response => {// 统一处理session过期if (response.headers.sessionstatus && response.headers.sessionstatus === 'timeout') {message.success('登录过期,请重新登录!', 1.5);store.dispatch({type: 'login/timeOut',});}if (response.config.method.toLowerCase() === 'delete' && response.status === 204) {message.success('删除成功!', 1.5);}return response.data;}).catch(e => {console.dir(e);// console.log(e.response)const { dispatch } = store;const status = e.response.status;message.error(e.toString());// message.error(`${status}:${e.response.data.error},${e.response.data.message}`);if (status === 401) {dispatch({type: 'login/logout',});return;}if (status === 403) {dispatch(routerRedux.push('/exception/403'));return;}if (status <= 504 && status >= 500) {dispatch(routerRedux.push('/exception/500'));return;}if (status >= 404 && status < 422) {dispatch(routerRedux.push('/exception/404'));}});}
./src/services/api.js
- 封装UrlExchange方法用于转化请求路径,开发过程中,前端自定义一个前缀(现在基本用proxy)用于转发代理,线上发布后请求用nginx转发,会约定一个前缀(下面的示例代码用hdwh),不同项目线上的前缀不一样,问后台就行了,这里用到了配置的环境变量
__ENV__,避免打包时候反复修改代码
import { stringify } from 'qs';import request, { requestFetch } from '../utils/request';export function UrlExchange(url) {// eslint-disable-next-line no-undefif(__ENV__ === 'dev'){ // __ENV__是环境变量,在.webpackrc中define栏配置return `/proxy${url}`; // 开发使用}return `/hdwh${url}`; // 线上打包地址}/*** 测试* @param params 分页参数* @returns {Promise<AxiosResponse<any>>}*/export async function queryTest(params) {return request(UrlExchange(`/getAccountByName?name=liuyunjaing`));}
菜单权限管理
项目需求:菜单可以配置,角色可以配置,不同角色可以分配不同菜单,不同账号可以分配不同角色,目前逻辑一个账号只能对应一个角色。(具体可以参考华东五禾后台管理系统项目的代码,在./src/routes/Permission目录下)

- 线上配置菜单(菜单标题,icon是ant-design自带的Icon的名字,菜单路径即路由的path)
- 在./src/common/router.js中配置菜单路径对应的组件(key为菜单路径),类似如下:(注意,routerConfig中路由配置的顺序也是有用的,用户登录进来后会优先从上往下匹配路径,匹配到有权限使用的路径则返回,所以需要根据业务需求,调整为适合自己项目的排序)

- 菜单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
effects: {*login({ payload }, { call, put }) {const res = yield call(bgAccountLogin, payload);localStorage.setItem('initMenu', JSON.stringify(res.data));if (res.code && res.code === '1000004') {res.msg && message.success(res.msg, 1.5);// 如果是第一次登录的话if (res.firstLogin) {yield put(routerRedux.push('/user/first-login'));} else {setStorage('menuData', menuDataMap(res.data));setStorage('userInfo', {loginId: res.loginId,displayname: res.displayname,publisherName: res.publisherName,});setStorage('roles', res.roles);setStorage('roleTypes', res.roleTypes);yield put({type: 'changeLoginStatus',payload: res,});// Login successfullyreloadAuthorized();// 用window.location.href 登录成功刷新页面,重新初始化routerData,否则routerData不是用服务端返回的menuData做关联,权限控制会有bugconst urlParams = new URL(window.location.href);const redirect = urlParams.searchParams.get('redirect');if (redirect) {window.location.href = redirect;} else {window.location.href = '/';}}} else if (res.code && res.msg) {message.error(res.msg);} else {message.error('登录异常!');}// const rs = yield call(queryTest,payload);// console.log(rs);// const response = yield call(fakeAccountLogin, payload);},
./src/common/menu.js
import { isUrl } from '../utils/utils';let menuData = [];function formatter(data, parentPath = '/', parentAuthority) {return data.map(item => {let { path } = item;if (!isUrl(path)) {path = parentPath + item.path;}const result = {...item,path,authority: item.authority || parentAuthority,};if (item.children) {result.children = formatter(item.children, `${parentPath}${item.path}/`, item.authority);}return result;});}export const getMenuData = () => {if (localStorage.getItem('menuData')) {menuData = JSON.parse(localStorage.getItem('menuData'));} else {menuData = [{name: 'dashboard',icon: 'dashboard',path: 'dashboard',children: [{name: '分析页',path: 'analysis',},{name: '监控页',path: 'monitor',},{name: '工作台',path: 'workplace',// hideInBreadcrumb: true,// hideInMenu: true,},],},];}return formatter(menuData);};
鉴权组件的改造
增加了vip[String]控制组件能被所有用户访问,权限这块的改造比较麻烦,涉及到很多地方,具体代码参考五禾项目的代码(应该有相关注释), ./src/layouts/BasicLayout.js等js文件的代码都会有影响,新项目可以重新理一下

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