react实战系列 —— 我的仪表盘(bizcharts、antd、moment)


其他章节请看:

My Dashboard

我们在 spug 项目中模仿”任务计划“模块实现一个类似的一级导航页面(”My任务计划“),本篇,我们将模仿“Dashboard”来实现一个仪表盘“My Dashboard”。

主要涉及 antd 的 GridCardDescriptions等组件、bizcharts 的使用、moment 日期库和页面适配。

:实现的代码在的基础上展开。

Dashboard

界面如下:
mydashboard-1.png

里面用到了:

  • antd 的 GridCardDescriptions 描述列表 (文字长度不同,有时会感觉没对齐)
  • bizcharts 中的折线图、柱状图
  • moment(日期相关的库),比如按天、按月、最近 30 天都很方便

My Dashboard

最终效果

mydashboard-2.png

无需权限即可访问:
mydashboard-3.png

全屏效果:
mydashboard-4.png

实现的代码

安装两个依赖包:

  • @antv/data-set,柱状图和饼状图需要使用
  • bx-tooltip,自定义 bizcharts 中的 tooltip。折线图和柱状图的 tooltip 都使用了。
spug-study> npm i @antv/data-set

added 31 packages, and audited 1820 packages in 26s

107 packages are looking for funding
  run `npm fund` for details

33 vulnerabilities (1 low, 16 moderate, 15 high, 1 critical)

To address issues that do not require attention, run:       
  npm audit fix

To address all issues (including breaking changes), run:    
  npm audit fix --force

Run `npm audit` for details.
spug-study> npm i -D bx-tooltip

added 1 package, and audited 1821 packages in 9s

107 packages are looking for funding
  run `npm fund` for details

33 vulnerabilities (1 low, 16 moderate, 15 high, 1 critical)

To address issues that do not require attention, run:
  npm audit fix

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

package.json 变动如下:

"dependencies": {
  "@antv/data-set": "^0.11.8",
}
"devDependencies": {
  "bx-tooltip": "^0.1.6",
}

增强表格组件

spug 中封装的表格组件,不支持 style和 size。替换一行,以及增加一行:

// src/components/TableCard.js

- 
+

准备 mock 数据

将 mydashboard 模块的的 mock 专门放入一个文件,并在 mock/index.js 中引入。

// src\mock\index.js

+ import './mydashboard'

// src\mock\mydashboard.js

import Mock from 'mockjs'

// 开发环境引入 mock
if (process.env.NODE_ENV === 'development') {
    
Mock.mock('/api/mdashboard/occupancy_rate/', 'get', () => (
    {"data": [ {
        month: "2022-01-01",
        city: "城市-名字很长很长很长",
        happiness: 10,
        per: 90,
        msg1: '信息xxx'
    },
    {
        month: "2022-01-01",
        city: "城市B",
        per: 30,
        happiness: 50,
        msg1: '信息xxx'
    },
    {
        month: "2022-02-01",
        city: "城市-名字很长很长很长",
        happiness: 20,
        per: 40,
        msg1: '信息xxx'
    },
    
    {
        month: "2022-02-01",
        city: "城市B",
        happiness: 20,
        per: 60,
        msg1: '信息xxx'
    },
    {
        month: "2022-03-01",
        city: "城市-名字很长很长很长",
        happiness: 30,
        per: 80,
        msg1: '信息xxx'
    },], "error": ""}

))

let mIdSeed = 1;
Mock.mock('/api/mdashboard/table', 'get', () => ({
    "data": [{ "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
    { "id": mIdSeed++, "name": "苹果" + mIdSeed, address: '场地' +mIdSeed, time: new Date().toLocaleTimeString() },
]
}))
}

路由配置

配置 /mdashboard/mydashboard 两个路由:

// src\App.js

+ import MDashboard from './pages/mdashboard/tIndex';

class App extends Component {
  render() {
    return (
      
      //  无需权限
      + 
        
        
        
      
    );
  }
}
// src\routes.js

+ import MyDashboardIndex from './pages/mdashboard';

export default [
  {icon: , title: '工作台', path: '/home', component: HomeIndex},
  {
    icon: ,
    title: 'Dashboard',
    auth: 'dashboard.dashboard.view',
    path: '/dashboard',
    component: DashboardIndex
  },
+ // 我的仪表盘
+ {
+   icon: ,
+   title: 'MyDashboard',
+   auth: 'mydashboard.mydashboard.view',
+   path: '/mydashboard',
+   component: MyDashboardIndex
+ },

新建仪表盘组件。一个需要权限访问,另一个无需权限即可访问,故将仪表盘提取成一个单独的文件:

// src\pages\mdashboard\Dashboard.js

import React from 'react';
export default function () {
  return (
    
仪表盘
) }
// src\pages\mdashboard\index.js

import React from 'react';
import { AuthDiv } from 'components';
import Dashboard from './Dashboard';

export default function () {
  return (
    
// AuthDiv 是 spug 封装的与权限相关的组件

需要权限才能访问

) }
// src\pages\mdashboard\tIndex.js

import React from 'react';
import Dashboard from './Dashboard';

export default function () {
  return (
    

无需权限也能访问

) }

重启服务,倘若能访问,说明一切就绪,只差仪表盘核心代码。

访问 /mydashboard
mydashboard-5.png

访问 /mdashboard
mydashboard-6.png

仪表盘的核心代码

样式
// src\pages\mdashboard\index.module.less

.tdashboardBox {
    .react{
        width: 10px;
        height: 10px;
        display: inline-block;
        background: #52c41a; /* #00000040 */
        margin-left: 30px;
        margin-right: 10px;
    }

    // 参考:src\components\index.module.less 中 global
    :global(.trendBox .ant-card-head-wrapper) {
        width: 100%;
    }
}
表格(水果信息)
// src\pages\mdashboard\Table.js

import React from 'react';
import { observer } from 'mobx-react';
import { Descriptions } from 'antd';
import { TableCard } from 'components';
import store from './store';

@observer
class ComTable extends React.Component {
  // 默认值
  static defaultProps = {
    tableHeight: 353
  }

  // scrollY 以外的高度
  excludeScrollY = 120;
  componentDidMount() {
    store.fetchRecords()
  }

  columns = [{
    title: 'id',
    dataIndex: 'id',
  },{
    title: '名称',
    dataIndex: 'name',
  }, {
    title: '生产地',
    dataIndex: 'address',
  }, {
    title: '时间',
    dataIndex: 'time',
  }];

  handleExpand = record => {
    return 
      {record.time}
      xxx
      xxxxxx
      xxxxxxxxxxxxxxx
      xxx
      
        xxxxx xxxxx xxxxxxxxxx xxxxxxxxx
      
    
  }

  render() {
    console.log('this.props.tableHeight', this.props.tableHeight, 'y', this.props.tableHeight * this.scrollRadio)
    return (
      
    )
  }
}

export default ComTable

折线图(居住趋势)
// src\pages\mdashboard\Trend.js

import React, { useState, useEffect } from 'react';
import { Card, DatePicker, Modal } from 'antd';
import { Chart, Geom, Axis, Tooltip, Legend } from 'bizcharts';
import { http } from 'libs';
import styles from './index.module.less'
// 日期相关的库,比如最近30天等
import moment from 'moment';

/*
bizcharts 官网:
通过bx-tooltip插件自定义 
为了满足更灵活多变的Tooltip自定义需求,提供bx-tooltip插件来实现ReactNode渲染,摆脱HTML模板的繁琐和死板
*/
import useCustTooltip from 'bx-tooltip';
import { Typography, Space } from 'antd';
import store from './store'

export default function (props = { cardBodyHeight: 450 }) {
  // chart 高度占比
  const chartHeightRatio = 0.888

  const { Text, Link, Title } = Typography;
  const [loading, setLoading] = useState(true);
  // 本月第一天 —— 本月最后一天
  // const [duration, setDuration] = useState([moment().startOf('month'), moment().endOf('month')]);
  // 最近三十天
  const [duration, setDuration] = useState([moment().subtract(29, 'days'), moment()]);
  const [res, setRes] = useState([]);

  useEffect(() => {
    const strDuration = duration.map(x => x.format('YYYY-MM-DD'))

    setLoading(true);
    http.get('/api/mdashboard/occupancy_rate/', { duration: strDuration })
      .then(res => {
        setRes(res)
      })
      .finally(() => setLoading(false))
  }, [duration])

  // bx-tooltip插件的使用
  const [BxChart, CustTooltip] = useCustTooltip.create(Chart, Tooltip);

  return (
    // headStyle、bodyStyle 在这里都是用于适配(响应式)
    
         setDuration(val)} />
      
    )}>

      
         {
          const maxNum = 10
          return val.length > maxNum ? val.split('').slice(0, maxNum - 3).join('') + '...' : val
        }} />
        {/* x 坐标格式化 */}
         0101
            return `${text.split('-').slice(1).join('')}`;
          }
        }} />

        

        {/* 自定义 tooltip */}
        
          {(title, items) => {
            return 
{ items.map((x, i) => { let oData = x.point._origin return
{Object.is(i, 0) && {oData.month}}
{oData.city} 幸福指数:{oData.happiness} 跳转 { Modal.info({ title: 'title', content: oData.msg1 }); }}> 详情
}) }
}}
) }
饼状图(统计苹果和梨子)
// src\pages\mdashboard\PieChart.js

import React from 'react';
import { Typography} from 'antd';
import {
    Chart,
    Geom,
    Axis,
    Tooltip,
    Coord,
    Label,
    Legend
} from 'bizcharts';
import DataSet from '@antv/data-set';

// chartHeight 默认高度 250px ,用于适配
export default function (props = {chartHeight: 250}) {
    const { Text } = Typography;

    const { DataView } = DataSet;
    const data = [
        {
            item: '苹果',
            count: 10,
        },
        {
            item: '梨子',
            count: 20,
        },
    ];
    const dv = new DataView();
    dv.source(data).transform({
        type: 'percent',
        field: 'count',
        dimension: 'item',
        as: 'percent',
    });
    const cols = {
        percent: {
            formatter: val => {
                val = val * 100 + '%';
                return val;
            },
        },
    };
    function getXY(c, { index: idx = 0, field = 'percent', radius = 0.5 }) {
        const d = c.get('data');
        if (idx > d.length) return;
        const scales = c.get('scales');
        let sum = 0;
        for (let i = 0; i < idx + 1; i++) {
            let val = d[i][field];
            if (i === idx) {
                val = val / 2;
            }
            sum += val;
        }
        const pt = {
            y: scales[field].scale(sum),
            x: radius,
        };
        const coord = c.get('coord');
        let xy = coord.convert(pt);
        return xy;
    }
    return (
        
统计苹果和梨子 { const xy = getXY(c, { index: 0 }); c.showTooltip(xy); }} > { // 处理 33.33333333% -> 33.33 percent = (percent * 100).toFixed(2) + '%'; return { name: item, value: percent, }; }, ]} style={{ lineWidth: 1, stroke: '#fff', }} >
); }
柱状图(堆叠柱状图)
// src\pages\mdashboard\BarChart.js

import React from "react";
import { Typography, Space } from 'antd'
import {
  Chart,
  Geom,
  Axis,
  Tooltip,
  Coord,
  Legend,
} from "bizcharts";
import useCustTooltip from 'bx-tooltip';
import DataSet from "@antv/data-set";

export default function (props = {barHeight: 240}) {
  const [BxChart, CustTooltip] = useCustTooltip.create(Chart, Tooltip);
  const { Text,Title } = Typography;
  const retains = ["State", '总比例', 'bad', 'good', 'Total']
  const fields = ["好的比例", "坏的比例"]
  const data = [
    {
      State: "苹果(红富士、糖心苹果)",
      good: 50,
      bad: 150,
      Total: 200,
      好的比例: 25,
      坏的比例: 75,
      总比例: 100
    },
    {
      State: "梨子(香梨)",
      good: 75,
      bad: 125,
      Total: 200,
      好的比例: 37.5,
      坏的比例: 62.5,
      总比例: 100
    },
  ];

  const ds = new DataSet();
  const dv = ds.createView().source(data);

  dv.transform({
    type: "fold",
    fields: fields,
    key: "比例",
    value: "百分总计",
    retains: retains // 保留字段集,默认为除fields以外的所有字段
  });

  return (
    
堆叠柱状图 maxNum ? text.split('').slice(0, maxNum - 3).join('') + '...' : text } }} /> {(title, items) => { return
{ items.map((x, i) => { // 取得原始数据 let oData = x.point._origin return
{Object.is(i, 0) && {oData.State}}
{oData['比例']}:{oData['百分总计']}% good数量:{oData['good']} bad数量:{oData['bad']} 总数量:{oData['Total']}
}) }
}}
); }
store.js
// src\pages\mdashboard\store.js

import { observable, computed } from 'mobx';
import http from 'libs/http';

const PADDING = 16
class Store {
  // 表格数据
  @observable records = [];

  // 是否正在请求数据
  @observable isFetching = false;

  // 数据源
  @computed get dataSource() {
    return this.records
  }

  fetchRecords = () => {
    this.isFetching = true;
    http.get('/api/mdashboard/table')
      // todo 接口格式或许会调整 
      .then(res => this.records = res)
      .finally(() => this.isFetching = false)
  };

  /* 适配相关 */
  // 盒子高度,padding 用于给顶部和底部留点空隙。
  // 由于笔者没有设计,所以先用 px 实现,之后在在将固定高度改为响应式,937 是固定高度实现后测量出的高度。
  @observable baseBoxHeight = 937 - PADDING
  @observable padding = PADDING
  // 需要用 this 调用 padding 变量,即 `this.padding`
  @observable boxHeight = window.innerHeight - this.padding * 2

  // 饼图高度比例
  @observable pieBoxRatio = 0.20

  // 柱状图高度比例
  @observable barBoxRatio = 0.23

  // “My Dashboard 我的仪表盘” 
  @computed get TitleHeight() {
    const ratio = 80 / this.baseBoxHeight
    return this.boxHeight * ratio
  }
  // 运行card高度
  @computed get todayCardHeight() {
    const ratio = 75 / this.baseBoxHeight
    return this.boxHeight * ratio
  }

  // “饼图+描述列表+柱状图” body 高度
  @computed get statisticBodyHeight() {
    const ratio = 660 / this.baseBoxHeight
    return this.boxHeight * ratio
  }

  // 居住趋势 body 的
  @computed get trendBodyBodyHeight() {
    const ratio = 385 / this.baseBoxHeight
    return this.boxHeight * ratio
  }

  // 水果信息高度
  @computed get configTableHeight() {
    const ratio = 353 / this.baseBoxHeight
    return this.boxHeight * ratio
  }

  // xys16 得用 computed 才会联动。下面这种写法不会联动
  // @observable xys16 = (16 / this.baseBoxHeight) * this.boxHeight
  @computed get xys16() {
    return (16 / this.baseBoxHeight) * this.boxHeight
  }

  @computed get xys12() {
    return (12 / this.baseBoxHeight) * this.boxHeight
  }

  @computed get xys36() {
    return (36 / this.baseBoxHeight) * this.boxHeight
  }

  @computed get xys24() {
    return (24 / this.baseBoxHeight) * this.boxHeight
  }

  @computed get xys78() {
    return (78 / this.baseBoxHeight) * this.boxHeight
  }

  @computed get pieBoxHeight() {
    return this.pieBoxRatio * this.boxHeight
  }

  @computed get barBoxHeight() {
    return this.barBoxRatio * this.boxHeight
  }

  // card 的 header 
  @computed get cardTitleStyle() {
    const cardTitleRatio = 57 / this.baseBoxHeight
    return { display: 'flex', height: this.boxHeight * cardTitleRatio, alignItems: 'center', justifyContent: 'center' }
  }
  /* /适配相关 */
}

export default new Store()
Dashboard.js
// src\pages\mdashboard\Dashboard.js


import React, {useEffect, Fragment} from 'react';
import { Row, Col, Card, Descriptions, Typography, Divider } from 'antd';
import AlarmTrend from './Trend';
import Piechart from './PieChart'
import CusTable from './Table';
import CusBarChart from './BarChart';
import Styles from './index.module.less'
import { observer } from 'mobx-react';
import store from './store'

export default observer(function () {
  // Typography排版
  const { Text } = Typography;

  useEffect(() => {
    // 响应式
    window.addEventListener("resize", function(){
      // padding,用于留点间距出来
      store.boxHeight = window.innerHeight - store.padding * 2
    }, false);
  }, [])

  return (
    // Fragment 用于包裹多个元素,却不会被渲染到 dom
    
      {/* 使用单一的一组 Row 和 Col 栅格组件,就可以创建一个基本的栅格系统,所有列(Col)必须放在 Row 内。 */}
      
        
{/* 可以省略 px */} {/* 如果将字体和padding 改为响应式,height 设置或不设置还是有差别的,设置 height 会更准确 */} My Dashboard 我的仪表盘 {/* gutter:水平垂直间距都是 响应式 16 */} {/* 24 栅格系统。 */} {/* 垂直居中 */} {/* 文字大小 */} 运行为绿色,否则为灰色: 运行 {/* Descriptions描述列表,常见于详情页的信息展示。这里总是显示两列。 */} {/* spug 中“Dashboard”的“最近30天登录”是用的就是Descriptions,缺点是不像 table 对齐。当文字长度不同,会看起来错乱。 */} {/* 样式,用于适配,即垂直居中 */} 描述列表 5个 2022-04-21 看不完整就将鼠标移上来 {/* 超A、超B、超C、超D, */} ) })
index.js
// src\pages\mdashboard\index.js

import React from 'react';
import { AuthDiv } from 'components';
import Dashboard from './Dashboard';
import styles from './index.module.less'

export default function () {
  return (
    
) }
tIndex.js
// src\pages\mdashboard\tIndex.js

// 无需权限即可访问

import React from 'react';
import Dashboard from './Dashboard';
import store from './store';
import styles from './index.module.less'

export default function () {
  return (
    
) }

重启服务,效果如下:

mydashboard-4.png

bizcharts

bizcharts 是阿里的一个图表组件库。

:spug 项目中使用的版本是 3.x。参考文档时不要搞错。

API文档

上面我们安装的其中一个依赖包 bx-tooltip 就来自这里。

bizcharts1.png

实战

实战其实就是一些 bizcharts 使用上的一些答疑。例如“内容显示不完整”,有可能就是因为 padding 的原因。

bizcharts2.png

图表示例

例如我们使用的堆叠柱状图的用法示例就参考这里:

bizcharts3.png

点击进入示例,修改左边源码,右侧显示也会同步,非常方便我们在线研究和学习:
bizcharts4.png

高度自适应

bizcharts 有宽度自适应,但没有实现高度的自适应。
bizcharts5.png

笔者高度自适应的做法:将高度全部改为百分比。
mydashboard-7.png

具体做法如下:

  1. 由于没有设计,故先用固定像素实现界面
  2. 取得浏览器的窗口高度 window.innerHeight,笔者这里是 937
  3. 将“标签盒子”、“卡片头部高度”、卡片 body 部分等全部改为百分比

核心代码如下:

// src\pages\mdashboard\store.js

const PADDING = 16
class Store {

  @observable baseBoxHeight = 937 - PADDING

  @observable padding = PADDING

  // 仪表盘盒子高度
  @observable boxHeight = window.innerHeight - this.padding * 2

  // 饼图高度比例。根据之前的效果算出来的
  @observable pieBoxRatio = 0.20

  // 柱状图高度比例
  @observable barBoxRatio = 0.23

  // “My Dashboard 我的仪表盘” 高度
  @computed get TitleHeight() {
    const ratio = 80 / this.baseBoxHeight
    return this.boxHeight * ratio
  }
  // 运行card高度
  @computed get todayCardHeight() {
    const ratio = 75 / this.baseBoxHeight
    return this.boxHeight * ratio
  }

  // “饼图+描述列表+柱状图” body 高度
  @computed get statisticBodyHeight() {
    const ratio = 660 / this.baseBoxHeight
    return this.boxHeight * ratio
  }

  // 居住趋势 body 的高度
  @computed get trendBodyBodyHeight() {
    const ratio = 385 / this.baseBoxHeight
    return this.boxHeight * ratio
  }

  // xys16 得用 computed 才会联动。下面这种写法不会联动
  @computed get xys16() {
    return (16 / this.baseBoxHeight) * this.boxHeight
  }

  // 饼状图盒子高度
  @computed get pieBoxHeight() {
    return this.pieBoxRatio * this.boxHeight
  }


  // card 的 header 比例
  @computed get cardTitleStyle() {
    const cardTitleRatio = 57 / this.baseBoxHeight
    return { display: 'flex', height: this.boxHeight * cardTitleRatio, alignItems: 'center', justifyContent: 'center' }
  }
}

问题

实现过程中出现如下两个问题:一个是折线图的 Y 轴乱序,一个是堆叠柱状图有一节空白
mydashboard-8.png

原因是不小心弄成了字符串,改为数字类型即可。

其他章节请看: