该文档旨在对该项目整体进行研发层面的说明,主要是对部分隐晦、复杂的逻辑进行说明,文件结构、组件布局、操作流程等均有涉及。
|-- dist 打包生成的文件
|-- node_modules 依赖包
|-- src 主程序
|-- components 页面组件
|-- constants URL配置
|-- lib 非NPM引入模块
|-- models DvaJS/Model
|-- routes DvaJS/Router
|-- services
|-- themes
|-- utils
-- index.ejs
-- index.js
-- theme.js
|-- static 静态资源文件
-- .editorconfig 跨编辑器配置
-- .eslintrc.js -eslint配置
-- .webpackrc.js -- roadhog 配置
npm install
npm start
npm run build
执行打包指令后将在根目录生成dist文件夹,因为项目配置了按需加载,打包之后将生成许多模块文件。需要将dist下所有文件复制到服务端项目路径platform-bi-server\bi-server\src\main\resources\static下。
系统路由示意图如下。

路由设置方式如下所示。
// src\routes\router.js
<Router history={history}>
<Switch>
<Route sensitive path='/login' component={Login} />
<Route sensitive path='/register' component={Register} />
<Route sensitive path='/dashboard/share/:code' component={DashboardShareView} />
<Route sensitive path='/dashboard/share_key/:code' component={DashboardShareKeyView} />
<PrivateRoute sensitive path='/chart/:code' component={ChartDesigner} />
<PrivateRoute sensitive path='/dashboard/:code/' component={DashboardView}/>
<PrivateRoute path='/' component={MainLayout} />
</Switch>
</Router>
项目身份信息保存在sessionStorage中,即每一个新开的页面都需要登录。
在未登录情况下打开任一权限路由将自动跳转到登录页
// src\routes\authLayout.jsx
isLogin ? (
...
) : (
<div style={{ width: '100%', height: '100%' }}>
{ children }
{ !checking && <Redirect
to={{
pathname: '/login',
state: { from: location }
}}
></Redirect>}
</div>
)
登录成功后会返回到目标页面。
// src\components\common\login\login.jsx
login = (username, password) => {
...
dispatch({ type: 'main/login', username, password, autoLogin })
.then((d) => {
this.setState({
redirectToReferrer: d,
})
};
...
if (redirectToReferrer) {
return <Redirect to={from} />;
}
若登录时间超过设定值,还将弹出重新登录框。

Model main加载时会添加一个定时器计算剩余时间弹出重新登录框,在页面失焦和聚焦事件也对定时器进行关闭和激活操作。
// src\models\main.js
/************************ 登录超时弹出重新登录框 **************************/
let checkExpireTime = () => {
let expireTime = window.sessionStorage.getItem('expireTime');
let t = moment(+expireTime).diff(moment())
return t >= 0 ? t : 0;
}
let onExpired = () => {
dispatch({ type: 'setFields', fields: [{ name: 'authenticated', value: false}] });
}
let hiddenProperty = 'hidden' in document ? 'hidden' :
'webkitHidden' in document ? 'webkitHidden' :
'mozHidden' in document ? 'mozHidden' :
null;
let visibilityChangeEvent = hiddenProperty.replace(/hidden/i, 'visibilitychange');
let timeoutKey = window.setTimeout(onExpired, checkExpireTime());
let onVisibilityChange = () => {
if (document[hiddenProperty]) {
window.clearTimeout(timeoutKey);
}else{
timeoutKey = window.setTimeout(onExpired, checkExpireTime());
}
}
document.addEventListener(visibilityChangeEvent, onVisibilityChange);
权限判断路由根据authenticated属性判断是否需要弹出重新登录框。
// src\routes\authLayout.jsx
isLogin ? (
<div style={{ width: '100%', height: '100%' }}>
{ children }
{ !checking && !authenticated && (
<Relogin
visibleBox={true}
></Relogin>
) }
</div>
) : (
...
)
首页左侧的报表目录树与报表制作-报表中的目录树用的是用一个组件src\components\dashboard\menu.jsx,区别在于首页的目录树显示报表目录和报表,报表制作-报表中的目录树仅显示报表目录,且首页的报表目录显示目录+报表,报表制作-报表中的目录树仅显示目录。
每个页签都是由一个报表展示组件构成,由于Dva中的Model的单一性,无法在同一个页面展示多个属性值不同的同名Model(也许是本人没有找到方法),所以这里不同报表放在同一个页面作页签切换展示实际上是对该组件的Model做数据切换。
即视图展示使用的都是同一个Model(DashboardDesigner),只是为其赋予不同的数据,所以在切换页签时能看到每次都有一个组件渲染过程。
首页在引入报表目录组件(src\components\dashboard\menu.jsx)时指定了目录onSelect的操作。
// src\components\homePage\sider.jsx
<DashboardMenu
onSelect={selectedMenu => {
if(selectedMenu && selectedMenu.type === 'dashboard') {
dispatch({ type: 'home/openTab', tab: {
code: selectedMenu.code,
name: selectedMenu.name
} })
}
}}
/>
// src\models\home.js
addTab(state, action) {
const { tabs } = state;
const { tab } = action;
return Object.assign({}, state, { tabs: tabs.concat([tab]) });
},
*openTab(action, { select, call, put }) {
const { tab } = action;
yield put({ type: 'addTab', tab });
yield put({ type: 'changeTab', tab: tab });
},
报表切换实际上就是给dashboardDesigner Model 赋值,引起页面刷新
// src\components\homePage\index.jsx
onChange = (activeKey) => {
const { tab } = action;
dispatch({ type: 'home/changeTab', tab });
}
// src\models\home.js
*changeTab(action, { select, call, put }) {
const home = yield select(state => state.home);
const { tabs, selectedTab } = home;
const { tab } = action;
let fields = []; // model fields
let fTab = tabs.find(t => t.code === tab.code);
let data = { ...fTab.config };
for(let key in data) {
fields.push({ // 将数据添加到数组
name: key,
value: data[key]
})
}
yield put({ type: 'dashboardDesigner/reset' }); // model初始化
yield put({ type: 'dashboardDesigner/silentSetFields', fields: fields }); // 批量改动model
},
报表关闭有关闭所有、关闭其他、关闭当前三种操作,需要处理关闭后页面焦点的问题。
// src\components\homePage\index.jsx
remove = (targetKey) => {
const { dispatch, home } = this.props;
const { tabs: allTabs } = home;
let tab = allTabs.find(t => t.code === targetKey);
dispatch({ type: 'home/closeTab', tab });
}
removeOther = (targetKey) => {
const { dispatch, home } = this.props;
const { tabs: allTabs, fixedDashboard } = home;
let tabs = allTabs.filter(t => (t.code !== targetKey && (!fixedDashboard || t.code !== fixedDashboard.code) ));
let tab = allTabs.find(t => t.code === targetKey);
dispatch({ type: 'home/closeTabs', tabs, tab });
}
removeAll = () => {
const { dispatch } = this.props;
dispatch({ type: 'home/closeAllTabs' });
}
// src\models\home.js
*closeTab(action, { select, call, put }) { // 关闭当前
const { tab } = action;
yield put({ type: 'removeTab', tab }); // 关闭当前tab
const home = yield select(state => state.home);
const { tabs, selectedTab } = home;
if(selectedTab.code === tab.code && tabs.length > 0) { // 如果关闭的是当前tab且还有其他tab
yield put({ type: 'changeTab', tab: tabs[tabs.length - 1] }); // 切换到最后一个tab
}
},
*closeTabs(action, { select, call, put }) { // 关闭其他
const { tabs, tab } = action;
yield put({ type: 'changeTab', tab }); // 切换到当前tab
yield put({ type: 'removeTabs', tabs }); // 关闭其他tab
},
*closeAllTabs(action, { select, call, put }) { // 关闭所有
const { home } = yield select(state => ({ home: state.home }));
const { tabs, fixedDashboard } = home;
let rmTabs = []; // 待关闭tab数组
for(let i = tabs.length - 1; i >= 0; i--) {
if(!fixedDashboard || (tabs[i].code !== fixedDashboard.code)) { // 排除固定tab
rmTabs.push(tabs[i]); // 添加到待关闭tab数组
}
}
yield put({ type: 'removeTabs', tabs: rmTabs }); // 关闭tabs
if(fixedDashboard && rmTabs.length > 0) { // 存在固定tab时切换到该tab
let sTab = tabs.find(t => t.code === fixedDashboard.code);
yield put({ type: 'changeTab', tab: sTab });
}
},
使用弹出框嵌入一个展示报表的DashboardDesigner组件。
实际上是遍历报表中的图表进行单个分别重新请求数据,并没有重新获取整个报表数据。
报表收藏&取消收藏
报表固定到首页&取消固定
数据源详情页分为新增、修改两种,通过判断路由:code决定渲染那种页面。
{code === 'create' ? <DataSourceCreateHeader /> : <DataSourceDetailHeader />}
新增页面分为选择数据链接、数据列配置和完成三个步骤,必须做完前一步才能进入下一步操作。
页面分为基本信息、数据列配置和数据开放策略三个页签。
后台返回的数据列属性需要在前台做转换,具体转化的对应关系如下。
| 属性 | 说明 | 默认值 | | :------- | :----------------------------------------------------------- | :----------------- | | 启用 | 只有启用的列才能在后续操作中可见 | true | | 列名 | 数据对象的原始列名,不可更改 | - | | 分析类型 | 从数据类型到分析类型的设定,参照数据类型-分析类型关系对照表 | - | | 允许分组 | 只有允许分组才会在有分组属性的可视化模式中可选 | 类别类型默认为true | | 允许过滤 | 只有允许过滤才会在过滤组件中可见 | true | | 别名 | 默认等于列名 | 列表 |
数据类型-分析类型关系对照表
| 数据类型 | 可选分析类型 | 默认分析类型 | | ---------- | ----------------------------------------- | ---------------- | | String | 类别 categorical、文本 string | 类别 categorical | | Date | 时间 time | 时间 time | | Timestamp | 时间 time | 时间 time | | BigDecimal | 类别 categorical、标量 scale、文本 string | 标量 scale | | Double | 类别 categorical、标量 scale、文本 string | 标量 scale | | Long | 类别 categorical、标量 scale、文本 string | 标量 scale | | Float | 类别 categorical、标量 scale、文本 string | 标量 scale | | Int | 类别 categorical、标量 scale、文本 string | 标量 scale | | Boolean | 类别 categorical、文本 string | 类别 categorical | | Byte | 类别 categorical、文本 string | 文本 string | | Short | 类别 categorical、标量 scale、文本 string | 标量 scale |
src\components\dataSourceDetail\columnType.json
src\models\defaultColumnType.json
保留重复列的意义在于在针对一次获取后的数据列进行大量自定义改动之后,如果需要对数据对象进行微调,比如增加或删减字段,则可以将改动保留。
// src\models\dataSourceDetail.js
let mergeColumns = []; // 保留了改动的数组
columns.forEach(c => {
let tc = oldColumns.find(o => o.name === c.name );
if(tc) { // 如果旧数据存在
let o = {};
o.columnType = tc.dataType === c.dataType ? tc.columnType : getColumnType(c.dataType); // 处理新同名列列类型变动情况
mergeColumns.push({ ...c, ...o, alias: tc.alias, using: tc.using, groupable: tc.groupable, filterable: tc.filterable }); // 将别名、启用、允许分组、允许过滤的配置复制到新同名列
}else {
mergeColumns.push(c); // 旧数据不存在则直接添加
}
});
因为数据源的改动可能导致图表解析展示出现问题,所以需要在图表展示前对数据源和图表配置进行对比,将可能导致问题的差异做一次处理。可能导致问题的差异项如下。
1. 使用的数据源列列名变更
2. 使用的数据源列列类型变更
3. 使用的数据源列被删除
需要对可视化模式用到的列、过滤条件中用到的列进行对比处理。
```javascript
// src\models\chartDesigner.js
*updateColumns function() {...}
```
chartDesigner model中对每一种可视化模式配置进行了初始化,打开具体图表时会把对应的可视化模式配置更新,可视化模式的切换实际上就是组件切换不同的配置进行展示。
除了指标看板外,其他可视化模式看板对自适应都有了一定的支持,所以这里只对指标看板进行说明。
指标看板的自适应逻辑处理发生在componentDidMount事件,待指标看板各项在界面排布好了之后对其进行二次排布。
// src\components\chartDesigner\charts\indicatorView.jsx
autoLayout = () {
// 拿到所有数据
const { chartOption } = this.props;
const { data } = chartOption;
if(!data || data.length === 0) {
return;
}
// 判断指标看板包含的额外字段数量,计算单个卡片的最小高度
let extraRowCount = data[0].others.length;
let cardMinHeight = extraRowCount === 0 ? 100 : (100 + 10 + (extraRowCount-1) * 21);
// 获取卡片容器大小和卡片数量
let container = this.containerRef;
if(!container) return;
let body = container.getElementsByClassName('indicator-body')[0];
let cards = body.getElementsByClassName('indicator-box');
let cardCount = cards.length;
let bodyBox = body.getBoundingClientRect();
// 首次设置容器最大可展示卡片的行数和列数
let maxColNum = cardCount > 4 ? 4 : cardCount; // 列数
let maxRowNum = Math.ceil(cardCount / maxColNum); // 行数
// 通过最大行数计算卡片的高度
let cardHeight = bodyBox.height / maxRowNum - 8;
// 防止卡片高度小于最小卡片高度
cardHeight = cardHeight < cardMinHeight ? cardMinHeight : cardHeight;
// 判断是否会出现滚动条
let inScroll = maxRowNum * (8 + cardHeight) > bodyBox.height;
// 结合容器宽度、是否有滚动条和容器最大展示列数计算卡片宽度
let cardWidth = (bodyBox.width - (inScroll ? 12 : 2)) / maxColNum - 8;
// 防止卡片宽度过小(小于180)
while(cardWidth < 180 && maxColNum > 1) {
// 卡片宽度过小时减少一列,然后重新计算卡片的高宽
maxColNum--;
maxRowNum = Math.ceil(cardCount / maxColNum);
cardHeight = bodyBox.height / maxRowNum - 8;
cardHeight = cardHeight < cardMinHeight ? cardMinHeight : cardHeight;
inScroll = maxRowNum * (8 + cardHeight) > bodyBox.height;
cardWidth = (bodyBox.width - (inScroll ? 12 : 2)) / maxColNum - 8;
}
// 防止卡片宽度过大(大于300)
while(cardWidth > 300 && maxColNum < cardCount) {
// 卡片宽度过大时增加一列,然后重新计算卡片的高宽
maxColNum++;
maxRowNum = Math.ceil(cardCount / maxColNum);
cardHeight = bodyBox.height / maxRowNum - 8;
cardHeight = cardHeight < cardMinHeight ? cardMinHeight : cardHeight;
inScroll = maxRowNum * (8 + cardHeight) > bodyBox.height;
cardWidth = (bodyBox.width - (inScroll ? 12 : 2)) / maxColNum - 8;
}
// 样式应用
...
}
部分可视化模式看板在样式设置页面有可调整的样式,对于Echarts组件是重新设置了配置档option,对于其他则是通过代码逻辑控制展示效果。
样式应用不需要重新请求数据,是直接改变chartOption的。
因为不同可视化模式组件的样式定义方式不同,所以主题应用会包括样式名替换、echarts配置档属性替换等多种形式。
// src\components\chartDesigner\sections\style\theme\index.js
import defaultTheme from './default.json';
import theme1 from './theme1.json';
import theme2 from './theme2.json';
import './default.less';
import './theme1.less';
import './theme2.less';
export default [{
name: 'default',
label: '默认',
config: defaultTheme
}, {
name: 'theme1',
label: '昂扬',
config: theme1
}, {
name: 'theme2',
label: '藏拙',
config: theme2
}]
样式名替换
<div className={`indicator-container ${themeName}`}
配置档应用
// src\models\parseChartOption.js
themeConfig = deepAssign({}, theme.base, theme.bar, theme.xAxis, theme.yAxis, theme.dataZoom);
o = barOption(data, chartConfig, themeConfig, styleConfig, drillDown);
主题应用因为逻辑不统一,只能重新渲染,所以需要重新请求数据。
页面组件使用大量三目运算通过dashboardDesigner model 中的editMode属性判断控制编辑预览模式的切换,使用浏览器的resize事件触发页面的重新布局。
width={editMode ? 200 : 0}
{esMobile && <Header/>}
// src\components\dashboardDesigner\header.jsx
onChange={(checked) => {
dispatch({ type: 'dashboardDesigner/setEditMode', checked });
// 主动触发一次window的resize事件
window.setTimeout(() => {
var e = document.createEvent("Event");
e.initEvent("resize", true, true);
window.dispatchEvent(e);
}, 500);
}}
富文本使用的是wangEditor。添加了对工具栏位置的自适应调整和切换编辑预览模式对富文本编辑器编辑状态的控制。
图表布局使用的是react-grid-layout,通过记录每个图表的layout(x, y, w, h)属性保存图表的位置。
react-grid-layout使用百分比布局,已经很好的支持了自适应,需要特殊处理的是表格类型的看板。
默认表格取数的pageSize是根据表格的高度计算出来的,计算的公式为向下取整(表格高度 / 单行高度) + 1,当表格高度变化时,需要重新计算表格的pageSize重新取数。
// src\components\dashboardDesigner\chartView.jsx
onPageSizeChange={(page, pageSize) => {
dispatch({ type: 'dashboardDesigner/fetchChartData', item, mandatory: true, page, pageSize });
}}
// src\components\chartDesigner\charts\tableView.jsx
componentDidMount() {
const { viewRef } = this.props;
this.onTableLayoutChange();
this[viewRef].addEventListener('resize', this.onTableLayoutChange);
window.addEventListener('resize', this.onTableLayoutChange);
}
componentWillUnmount() {
const { viewRef } = this.props;
this[viewRef].removeEventListener('resize', this.onTableLayoutChange);
window.removeEventListener('resize', this.onTableLayoutChange);
}
onTableLayoutChange = () => {
const { chartOption, viewRef, onPageSizeChange } = this.props;
if(typeof onPageSizeChange === 'function' && pageSize !== chartOption.pageSize && refreshable) {
// 在报表中的dataView第一次数据请求在此发起
onPageSizeChange(1, pageSize)
}
}
自定义过滤字段是为了将同一报表中不同看板使用的两个不同数据源中的同类字段合并成为一个新的过滤字段,以供过滤组件选用。
如下图所示,数据源"employee"和"job"的字段"jid"和"id"是有关联的字段,在做报表筛选时需要创建两个筛选项。通过自定义过滤字段可以将两个字段变为一个过滤字段,这样只需要创建一个筛选项就可以同时为两个数据源的过滤字段赋值。
自定义过滤字段关联关系数据结构示意图。
- 每一个自定义过滤字段都包含一个关联关系
- 关联关系中的首位决定这个自定义过滤字段的类型,后面选择的数据源字段类型必须与首位相同
定义一个自定义过滤字段的过程如下。
// src\components\dashboardDesigner\cusFilterBox.jsx
// 1.添加一个空的新自定义过滤字段
addRelationColumn = () => {
const { relationColumns } = this.state;
relationColumns.push({
code: Math.random()+'',
name: '新字段',
relations: []
});
this.setState({
relationColumns,
});
}
// 2.选中第一个数据源,获得该数据源下的列字段
onClick={() => {
this.setState({
selectedDataSource: {
code: d.code,
name: d.name
},
selectedColumn: null
});
dispatch({ type: 'dashboardDesigner/remoteGetColumns', dataSourceCode: d.code });
}}
// 3.选中第一个数据源列字段和选中第二个数据源列字段都走这一个方法
/**
* @param { code, label, type } c 所选列
* @param { code, label, relations } r 当前自定义过滤字段
*/
relationColumnClick = (c, r) => {
const { relationColumns, selectedDataSource } = this.state;
let setFlag, cusName;
this.setState({
selectedColumn: {
name: c.name,
label: c.label
}
}, () => {
let { selectedColumn } = this.state;
const { relations } = r;
let idx = relations.findIndex(r => r.dataSource.code === selectedDataSource.code);
if(idx === -1){ // 设置首位
setFlag = relations.length === 0; // 当添加第一个数据源列时
cusName = c.label;
relations.push({
dataSource: {
code: selectedDataSource.code,
name: selectedDataSource.name
},
column: {
name: c.name,
label: c.label,
type: c.type
}
});
}else if(idx === 0) { // 重新定义首位
let cr = relations[idx];
setFlag = true;
cusName = c.label;
if(cr.column.name === selectedColumn.name) { // 再次点击首位选择的列
relations.splice(0, relations.length); // 全部清空
}else { // 改变首位选择的列
relations[idx] = {
dataSource: {
code: selectedDataSource.code,
name: selectedDataSource.name
},
column: {
name: c.name,
label: c.label,
type: c.type
}
};
}
}else { // 设置N位
let cr = relations[idx];
if(cr.column.name === selectedColumn.name) { // 再次点击N位选择的列
relations.splice(idx, 1); // 移除该位
}else { // 改变N位选择的列
relations[idx] = {
dataSource: {
code: selectedDataSource.code,
name: selectedDataSource.name
},
column: {
name: c.name,
label: c.label,
type: c.type
}
};
}
}
// 获得当前自定义过滤字段在自定义过滤字段数组中的索引
let index = relationColumns.findIndex(rc => rc.code === r.code);
if(setFlag) {
// 改变了首位列后额外重设关联字段的名称为新的首位所选列名
relationColumns[index] = { ...r, name: cusName, relations };
}else {
// 重设该自定义过滤字段的关联关系
relationColumns[index] = { ...r, relations };
}
// 发布到state
this.setState({
relationColumns
});
});
}
导出Excel使用的是xlsx-populate工具,由于使用npm下载的xlsx-populate模块打包时因为存在es6代码会报错,所以手动转es5存了一份在lib中。
导出工具
// src\utils\exportor.js
生成导出数据
// src\models\dashboardDesigner.js
*exportToExcel
导出图片使用html2canvas工具,需要对一些影响导出效果的样式进行调整,在导出结束后再恢复。
好像可以使用html2canvas中的ignoreElements配置项
// src\models\dashboardDesigner.js
*exportToImage
项目使用react-loadable进行组件级按需加载(因为不同路由使用相同组件较多所以未使用dva/dynamic分路由按需加载)。
import React from 'react';
import Loadable from 'react-loadable';
import Skeleton from 'components/common/skeleton';
import SkeletonModal from 'components/common/skeletonModal';
export default (loader, inModal) => {
return Loadable({
loader,
loading() {
return inModal ? <SkeletonModal /> : <Skeleton />
},
});
}
const TableView = Loadable(() => import('components/chartDesigner/charts/tableView'));
### 筛选组件
筛选组件用于图表、报表界面的条件控制,先使用条件构造器构造一个可以修改值的筛选组件,然后改变筛选组件的值就可以自动生成可用于数据请求的筛选条件。
*由于报表界面的条件存在[自定义关联字段](#cus-filter-field),所以图表和报表使用的筛选组件有细微差别。*
条件构造器
javascript // src\components\common\filterBox\filterBox.jsx 图表用 // src\components\common\filterBox\filterBox2.jsx 报表用
筛选组件
javascript // src\components\common\filterBox\filter.jsx 图表用 // src\components\common\filterBox\filter2.jsx 报表用 ```