技术选型
- 基于
react
,官网地址:https://reactjs.org/;中文:https://react.docschina.org/; - UI组件库使用
ant-design
,官网文档:https://ant.design/docs/react/introduce-cn; - 脚手架用
ant-design-pro
V1,官网地址: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-pro
V1的基础上,改造了部分代码,已使用我司内部项目的需求;
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/', // 刘云江ip
target: '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 ID
const 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 FormData
newOptions.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-undef
if(__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 successfully
reloadAuthorized();
// 用window.location.href 登录成功刷新页面,重新初始化routerData,否则routerData不是用服务端返回的menuData做关联,权限控制会有bug
const 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模板,进一步重构代码的实现