关于仪表盘

仪表盘是由模块a-dashboard实现的。这里主要针对二次开发用户讲解如何制作部件,因此,如果对底层的实现机制感兴趣,可以直接查看模块a-dashboard的源码

关于部件

部件是构成仪表盘的基本单元。CabloyJS中的部件充分利用了Vue组件的响应式机制,实现了非常灵活的数据绑定数据联动特性,可以让数据在仪表盘中的部件之间流动

模块test-party内置了4个部件:水果销量水果销量(折线图)水果销量(饼图)快照

其中部件水果销量(折线图)可以从部件水果销量获取数据,并且可以向部件快照提供数据,具有承上启下的作用,因此我们以部件水果销量(折线图)为例来说明如何开发一个部件

1. 创建一个Vue组件

部件本身是一个Vue组件,但是巧妙的使用了Vue的多个语法特性。为了更清晰地说明,这里先贴出全部源码,再做详细解释

src/module/test-party/front/src/kitchen-sink/components/widgets/salesLine.vue

<template>
  <f7-card>
    <f7-card-header>{{$text('Fruit Sales(Line Chart)')}}</f7-card-header>
    <f7-card-content>
      <canvas ref="chart"></canvas>
      <div class="error" v-if="errorMessage">{{errorMessage}}</div>
    </f7-card-content>
  </f7-card>
</template>
<script>
const propsSchema = {
  type: 'object',
  properties: {
    dataSource: {
      type: 'object',
      ebType: 'text',
      ebTitle: 'Data Source',
      ebBindOnly: true,
      ebClue: 'salesDataSource',
    },
    fruit: {
      type: 'string',
      ebType: 'select',
      ebTitle: 'Fruit',
      ebOptions: [
        { title: 'All', value: 'All' },
        { title: 'Apples', value: 'Apples' },
        { title: 'Pears', value: 'Pears' },
      ],
      ebOptionsBlankAuto: true,
      ebClue: 'salesFruit',
    },
  },
};

const attrsSchema = {
  type: 'object',
  properties: {
    snapshot: {
      ebTitle: 'Snapshot',
      ebClue: 'snapshot',
    },
  },
};

// export
export default {
  install,
};

// install
function install(_Vue) {
  const Vue = _Vue;
  const ebDashboardWidgetBase = Vue.prototype.$meta.module.get('a-dashboard').options.mixins.ebDashboardWidgetBase;
  return {
    meta: {
      widget: {
        schema: {
          props: propsSchema,
          attrs: attrsSchema,
        },
      },
    },
    mixins: [ebDashboardWidgetBase],
    props: {
      dataSource: {
        type: Object,
      },
      fruit: {
        type: String,
      },
    },
    data() {
      return {
        chartjs: null,
        chart: null,
        snapshot: null,
        errorMessage: null,
      };
    },
    watch: {
      dataSource() {
        this.__updateChart();
      },
      fruit() {
        this.__updateChart();
      },
    },
    mounted() {
      this.__init();
    },
    beforeDestroy() {
      if (this.chart) {
        this.chart.destroy();
      }
    },
    methods: {
      __init() {
        this.$meta.module.use('a-chartjs', module => {
          this.chartjs = module.options.utils.chartjs;
          this.__updateChart();
        });
      },
      __prepareData() {
        const fruitIndex = this.dataSource.cols.findIndex(item => item === this.fruit);
        if (fruitIndex === -1) throw new Error();
        const chartData = {
          labels: this.dataSource.rows,
          datasets: [{
            fill: false,
            backgroundColor: this.dataSource.colors[fruitIndex],
            data: this.dataSource.dataset.map(item => item[fruitIndex]),
          }, ],
        };
        return chartData;
      },
      __prepareOptions() {
        const chartOptions = {
          maintainAspectRatio: false,
          responsive: true,
          animation: {
            onComplete: () => {
              this.__createSnapshot();
            }
          },
          title: {
            display: true,
            position: 'top',
            text: this.fruit,
            fontColor: 'rgba(128, 128, 128, 0.6)',
          },
          legend: {
            display: false,
          },
          scales: {
            xAxes: [{
              gridLines: {
                display: false,
              },
              ticks: {
                fontColor: 'rgba(128, 128, 128, 0.6)',
              },
            }],
            yAxes: [{
              gridLines: {
                display: true,
              },
              ticks: {
                fontColor: 'rgba(128, 128, 128, 0.6)',
                stepSize: 200,
              },
            }],
          },
        };
        return chartOptions;
      },
      __createSnapshot() {
        const image = this.chart.toBase64Image();
        this.snapshot = {
          title: this.$text('Fruit Sales(Line Chart)'),
          image,
        };
      },
      __clearChart() {
        if (this.chart) {
          this.chart.clear();
        }
      },
      __updateChart() {
        try {
          if (!this.dataSource || !this.fruit) {
            this.__clearChart();
            this.errorMessage = this.$text('Please set data source');
            return;
          }
          const chartData = this.__prepareData();
          const chartOptions = this.__prepareOptions();
          if (!this.chart) {
            // canvas
            const chartCanvas = this.$refs.chart.getContext('2d');
            // fill
            this.chart = new this.chartjs(chartCanvas, {
              type: 'line',
              data: chartData,
              options: chartOptions,
            });
          } else {
            this.chart.data = this.__prepareData();
            this.chart.options = this.__prepareOptions();
            this.chart.update();
          }
          this.errorMessage = null;
          return;
        } catch (err) {
          this.__clearChart();
          this.errorMessage = this.$text('There may be a binding error');
        }
      },
    },
  };

}

</script>
<style lang="less" scoped>
.error {
  position: absolute;
  bottom: 6px;
  right: 6px;
  font-size: smaller;
}

</style>

1.1 export default

首先,我们并不像一般的Vue组件那样直接导出Options对象,而是像Vue插件一样导出一个带install方法的对象

1.2 mixins: [ebDashboardWidgetBase]

ebDashboardWidgetBase是模块a-dashboard提供的部件基类,所有部件都必须mixin这个基类

这里我们就可以解释为什么不能直接导出Options对象。因为模块a-dashboard是异步加载的模块,而所有部件又必须继承ebDashboardWidgetBase。如果直接导出Options对象,那么在编译期是获取不到ebDashboardWidgetBase的,所以mixin必然失败

1.3 meta

meta用于存储Vue组件的元信息

1.3.1 meta.widget.schema.props

schema.props是部件props对应的JSON Schema,用于渲染部件的属性表单

关于JSON Schema,请参见:表单验证

名称 默认值 说明
type 属性值的类型
ebType 用于渲染的组件类型
ebTitle 属性的标题
ebBindOnly false 只允许动态绑定
ebClue 属性值的线索。在部件之间进行数据绑定时,只有ebClue相同的属性才允许绑定,从而简化用户的选择,减少无关部件和数据的干扰

1.3.2 meta.widget.schema.attrs

schema.attrs是部件data属性对应的JSON Schema,在进行数据绑定时作为数据源

也就是说,并不是部件所有的data属性都可以作为数据源。要想成为数据源,必须在schema.attrs中进行定义

1.4 watch

为了实时响应数据源的变化,我们可以使用Vue的另一个语法特性watch,监测schema.props中所有属性的变更,从而做出响应

2. 引用Vue组件

为了让系统可以找到此Vue组件,需要在组件清单中引用

src/module/test-party/front/src/components.js

import widgetSalesLine from './kitchen-sink/components/widgets/salesLine.vue';

export default {
  widgetSalesLine,
};

3. 后端功能定义

在一个复杂的系统中,会涉及到数据授权的问题,我们需要对部件进行权限控制。那么,我们需要在后端定义与部件对应的功能,从而可以对部件进行授权

3.1 功能定义

src/module/test-party/backend/src/meta.js

...
widgetSalesLine: {
  title: 'Fruit Sales(Line Chart)',
  component: 'widgetSalesLine',
  menu: 3,
  public: 1,
},
...
名称 说明
title 部件的标题
component 部件所对应的Vue组件
menu 常量3:对应部件的类别
public 是否公开。如果是公开就不需要进行授权,所有用户均可访问

3.2 功能初始化

src/module/test-party/backend/src/service/version.js

      if (options.version === 5) {
        // roleFunctions: widgets
        const roleFunctions = [
          { roleName: null, name: 'widgetSales' },
          { roleName: null, name: 'widgetSalesLine' },
          { roleName: null, name: 'widgetSalesPie' },
          { roleName: null, name: 'widgetSnapshot' },
        ];
        await this.ctx.meta.role.addRoleFunctionBatch({ roleFunctions });
      }

我们需要在模块数据版本初始化时,向数据库中添加部件的授权记录。因为部件的publictrue,所以,这里的roleNamenull

  1. 关于模块数据版本的概念,请参见:模块数据版本
  2. 部件的功能定义,请参见:功能与菜单
  3. 部件的功能授权,请参见:功能授权