正常来说,编辑器应用场景应该包括:

上一篇推文,我们已经大概了解了编辑器场景。接下来,我们来看预览时的设计

点击预览按钮,执行以下逻辑:
- /** @name 预览 **/
- async handlePreview() {
- ...
- // 打开抽屉组件,并往里面放置运行时模块
- createDrawer(
- h => h(DashboardRuntime, { props: { dashboard: this.form } }),
- {
- title: '预览仪表盘',
- width: 'calc(100vw - 200px)',
- },
- );
- }
也就是说:

所以我们直接关注运行时表现
- <template>
- <HabitContext :habitKey="habitKey" @init="habitContextInit">
-
- <a-spin v-if="loading" />
- <div
- v-else
- :style="styleCSSVariable"
- >
- <background :background="themeBackground" :class="$style.background">
- <grid-layout v-bind="layoutProps">
- <dashboard-item
- v-for="field in fields"
- :key="field.pkId"
- />
- grid-layout>
- background>
- div>
- HabitContext>
- template>
这里套了一层HabitContext框架,是用来应用和记录用户习惯的(后面讲)。a-spin是加载层。紧接着和设计器差不多,局部变量样式集里面套了个背景框架和grid-layout布局。
我们再看看dashboard-item的实现:
- <template>
- <grid-item
- v-bind="layout"
- static
- >
- ...
- grid-item>
- template>
这里通过v-bind动态传入grid-item的属性(也就是拣选出来的x/y/w/h这些)。同时用static固定gird-item,使其无法缩放、拖动、被其他元素影响。
- <template>
- <grid-item
- v-bind="layout"
- static
- >
- <div
- v-if="showChart"
- >
- ...
- div>
-
- <div
- v-else
- style="height: 100%; width: 100%; display: flex; flex-direction: column"
- >
- <div>
- <span :style="titleCss">
- {{ field.name }}
- span>
- div>
- div>
- grid-item>
- template>
这里就是简单的做了一个占位
- <template>
- <grid-item
- v-bind="layout"
- static
- >
- <div
- v-if="showChart"
- >
- <div :class="$style.action">
- <template v-for="action in actions">
- <a-tooltip
- :key="action.key"
- placement="bottom"
- :mouseLeaveDelay="0"
- :title="action.name"
- >
- <x-icon
- :type="action.icon"
- @click="execAction(action)"
- />
- a-tooltip>
- template>
- div>
- <component
- :is="component"
- :field="field"
- />
- div>
-
- <div
- v-else
- style="height: 100%; width: 100%; display: flex; flex-direction: column"
- >
- <div>
- <span :style="titleCss">
- {{ field.name }}
- span>
- div>
- div>
- grid-item>
- template>
浮层按钮还有具体的图表组件
到这里,我们已经看完了编辑器功能的大概设计。接下来该写写这套系统最核心的部分,数据流设计了。

点下新增按钮后,我们传入一些系统参数【应用id,功能类别等等,在这里我们并不需要关注】储存新建仪表盘在系统的位置和属性。

在接口储存完这些系统信息后,跳转到仪表盘页面进行最为关键的仪表盘初始化数据生成。
- async handleAddForm(category) {
- // 弹窗让填写名称、图标等基础信息
- const result = await GroupForm.createModal(
- {
- data: { parentId: this.groupId, appId: this.appId, category },
- },
- {
- title: this.getCategoryName(category),
- width: '427px',
- },
- );
- // 调用接口保存
- const formId = await add(result);
- this.$message.success(this.$t('common.tips.add'));
- // 保存完毕后跳转到页面
- switch (category) {
- case FormCategoryType.DASHBOARD:
- return this.$router.push(`/dashboard-design/${formId}`);
- ...
- default:
- return this.$router.push(`/form-design/${formId}/form`);
- }
- }
这里是通过vue-router进行跳转。这里也简单贴出路由代码
- import DashboardDesign from '@/views/dashboard-design';
-
- const DashboardDesignRoutes = [
- {
- path: '/dashboard-design/:id',
- component: DashboardDesign,
- },
- ...
- ];
-
- export default DashboardDesignRoutes;
到这里结束,一个仪表盘编辑器已经创建完毕了。它只存储了系统数据,没有仪表盘的初始数据。而当我们进入仪表盘编辑器页面的时候,完成有效编辑之后,才会以正式数据存储下来。
当然这里指的是前端数据,后端还是会根据我们穿进去的系统参数生成一份默认的接口向的仪表盘数据模板(比如默认权限、默认刷新时间上面的)
先通过后端接口,拿到当前仪表盘编辑器id的接口数据
- @formDesignModule.Action init;
- async created() {
- ...
- await this.init(this.formId).then(() => {
- ...
- }
- }
大概长这样,记录一些系统信息或默认属性 。这里的init是vuex的action操作。为了是把数据保存到前端本地。更多关于本项目的vuex方法请看我另外一篇文章的介绍
看看这个init的actions做了什么?
- actions: {
- async init({ commit }, formId) {
- const form = await getFormData(formId);
- commit('saveForm', form);
- }
- }
- mutations: {
- saveForm(state, data) {
- state.form = data;
- ...
- state.loading = false;
- state.changed = false;
- }
- }
这里是通过调用接口获取当前仪表盘的数据,并把它存到当前的formDesignModule,也就是formDesign这个命名空间的仓库里。
我们刚刚看到了代码,在编辑页面created里我们执行了init。其实就是非显示地获取数据。吧获取数据的过程从页面隐式地放到了状态管理器里的actions里面。并通过state返回关注的数据。这样子无论我们在仪表盘功能里怎么去跳转页面,都不需要再重新调用接口了,而是直接从仓库里拿。
- @formDesignModule.State form;
- @formDesignModule.Action init;
- @formDesignModule.State loading;
- @formDesignModule.State selectedField;
- @formDesignModule.Getter fields;
- @formDesignModule.Mutation updateSelectedField;
- @formDesignModule.Mutation selectField;
- @formDesignModule.Mutation updateSetting;
- @formDesignModule.Mutation saveForm;
- @formDesignModule.Mutation updateDashboardConfig;
- @formDesignModule.Action save;
大概有这些属性和方法来完成编辑器的功能实现。看看就行了。紧接着我们来讲其中一些实现
有两种添加方法:
① 点击组件按钮添加
② 拖拽组件添加

点击组件
-
- handleClickAdd() {
- ...
- // 初始化layout
- const layout = getDashboardLayoutByType(type);
- const layoutList = ensureArray(this.$refs.container.layout);
- layout.x = (layoutList.length * 2) % 60;
- layout.y = layoutList.length + 60;
- field.widget.layout = layout;
-
- // 初始化风格
- field = this.initFieldStyle(field);
- }
getDashboardLayoutByType是根据你点击的组件生成默认的组件layout数据。比如图片组件定义的默认layout是:
- export function getDashboardLayoutByType(type) {
- const layout = getDashboardControlMeta(type, 'layout');
- return { x: 0, y: 0, ...(typeof layout === 'function' ? layout() : layout) };
- }

这时返回了一个初始化的layout即{w:30,h:15,minH:7,x:0,y:0}。
const layoutList = ensureArray(this.$refs.container.layout);这里是直接获取设计器组件里面的layout属性(它的data值)。这个layout目前是个空数组(因为是新建的仪表盘,里面没有组件)。
- layout.x = (layoutList.length * 2) % 60;
- layout.y = layoutList.length + 60;
- field.widget.layout = layout;
-
-
- // 更新布局
- this.$refs.container.syncLayout();
很好理解啦,我们吧初始化layout的横纵坐标调整到它应该在的位置上,并吧这个调整过的layout信息存储到新增组件的布局属性里(替换掉初始化layout)。讲讲为什么这么计算:

可以看到实例中这两个组件的x/y值并不像上面这个逻辑计算出来的。 如果按照上面那个逻辑计算出来,则应该是{x:0,y:60...}和{x:2,y:61...}。其实这个计算过程是为了保证第n+1个组件的x和y一定大于第n个。从而避免重叠出错,而至于精准的layout数据,是借助vue-layout-grid插件行自适应生成。具体怎么做,我们看代码:
- /** @name 同步layout **/
- syncLayout() {
- this.layout = ensureArray(this.fields).map(field => ({
- ...field.widget.layout,
- i: field.pkId,
- }));
- }
- <grid-layout
- ref="layout"
- :class="$style.layout"
- :layout.sync="layout"
- >
- ...
- grid-layout>
很多人看到这里就要骂了,骗人,你这不是啥都没干?只是把layout重新赋值了一遍。让我们改下代码看看:
- /** @name 同步layout **/
- async syncLayout() {
- this.layout = ensureArray(this.fields).map(field => ({
- ...field.widget.layout,
- i: field.pkId,
- }));
- console.log(this.layout);
- await this.$nextTick();
- console.log(this.layout);
- }
第一个输出:
- [
- {
- h: 10,w: 12,x: 0,y: 0
-
- },
- {
- h: 20,w: 60,x: 2,y: 61
- }
- ]
第二个输出:
- [
- {
- h: 10,w: 12,x: 0,y: 0,i: "39b19b29-c8ef-4fd3-8604-d7e168196ae6"
- },
- {
- h: 20,w: 60,x: 2,y: 10,i: "5d684834-26bd-4d35-b7ff-36d8de9d903e"
- },
- ]
可以看到此时this.layout已经变了。这是因为
由此,我们的保存仪表盘布局方法也呼之欲出了:
- save() {
- // 拿到同步后的this.layout
- const layout = this.$refs.container.layout;
- // 生成组件id和layout信息的映射表
- const layoutMap = generateMap(layout, 'i', item =>
- pick(item, 'x', 'y', 'w', 'h'),
- );
- ...
- }
先看到这里,这里要生成一份类似于:'amdous123623': {w:10,h:20,x:0,y:0...}这样的映射表,是整个仪表盘布局的储存并不是直接存储类似于girdLayout的这种数组,而是由一个个组件自身的layout属性(甚至无视组件排序)拣选出来生成this.layout。也就是说仪表盘的存储结构为Array
- save(fields) {
- const layout = this.$refs.container.layout;
- const layoutMap = generateMap(layout, 'i', item =>
- pick(item, 'x', 'y', 'w', 'h'),
- );
- this.privateUpdateFields(
- (fields || this.fields).map(field => {
- if (!layoutMap[field.pkId]) return field;
- return {
- ...field,
- widget: {
- ...field.widget,
- layout:{
- ...field.widget.layout,
- ...layoutMap[field.pkId],
- }
- },
- };
- }),
- );
- }
前面我们已经讲了拖拽添加组件的思路,和预防错位或重叠的处理。现在来讲讲具体代码实现。
之前讲过了,在control-list.vue也就是左边的组件列表拖拽出组件,触发@dragstart方法,同时往设计器里传入dragType。设计器里根据dragType找对对应的组件初始化layout
- @Watch('dragType')
- handleDragTypeChange(type) {
- this.isInChildCom = false; // 重新拖动需要重置
- if (type) {
- this.dragLayout = {
- i: 'drag',
- ...getDashboardLayoutByType(type),
- };
- } else {
- this.dragLayout = null;
- }
- }
假设此时拖拽元素已经拖拽到在设计器(也就是gird-layout)上面。触发@dragover.native="handleDrag"
- handleDrag(ev) {
- if (this.isInChildCom) return; // 进入子元素范围则无需触发
- ev.preventDefault();
- this._handleDrag(ev);
- }
- @throttle(100)
- _handleDrag(ev) {
- if (!this.dragType || !this.$el) return;
- if (
- this.dragContext.clientX === ev.clientX &&
- this.dragContext.clientY === ev.clientY
- )
- return;
- this.dragContext.clientX = ev.clientX;
- this.dragContext.clientY = ev.clientY;
- this.updateInside(ev);
- this.updateDrag(ev);
- }
_handleDrag每100秒记录一次拖拽元素的位置,当拖拽元素发生变动时,更新设计器视图。
- updateInside(ev) {
- if (!this.dragType || !this.$el) return;
- const rect = this.$el.getBoundingClientRect();
- const errorRate = 10;
- const inside =
- ev.clientX > rect.left + errorRate &&
- ev.clientX < rect.right - errorRate &&
- ev.clientY > rect.top + errorRate &&
- ev.clientY < rect.bottom - errorRate;
- if (inside && this.dragLayoutIndex === -1) {
- this.layout.push(this.dragLayout);
- }
- if (!inside && this.dragLayoutIndex !== -1) {
- this.layout.splice(this.dragLayoutIndex, 1);
- }
- }
这里是获取设计器边界的位置属性(errorRate为误差范围,你可以理解为设计器有padding),判断拖拽元素是否在设计器边界内,如果是,就往layout里面加入它(重复则不加入),如果已经超出设计器,则移除。

我们往编辑器拖拽移动,可以看到这个虚线框会一直跟随变动,可能你们就要问了,上面的代码里dragLayout一但被添加进layout,那么dragLayoutIndex就不会是-1,也就是说layout里面的dragLayout不会改变(x或y)。那这个虚框是怎么还在移动的?
其实啊,这个虚框并不由layout里的数据决定。而是由vue-grid-layout这个插件负责渲染的。在拖动的时候,this.layout是不会变的。我们只需要每100毫秒记录一次拖拽元素的当前位置this.dragLayout,直到放置生效之后,用this.dragLayout去覆盖this.layout里面的那个被拖动元素。
所以updateDrag是为了更新this.dragLayout。通过clientY/X换算成vue-grid-layout的x,y
- const dragRef = this.getDragRef();
- if (!this.dragType || !dragRef) return;
- const rect = this.$el.getBoundingClientRect();
- const dragging = {
- top: this.dragContext.clientY - rect.top,
- left: this.dragContext.clientX - rect.left,
- };
- dragRef.dragging = dragging;
- const newLayout = dragRef.calcXY(dragging.top, dragging.left);
- this.dragLayout.x = newLayout.x;
- this.dragLayout.y = newLayout.y;
- }
- getDragRef() {
- // vue-grid-layout默认在$children内存在一个组件实例了, 其实每次拖动直接取最后一个实例应该就可以了
- return this.$refs.layout.$children[this.$refs.layout.$children.length - 1];
- }
当我们放手时,触发
- async handleDrop() {
- if (this.isInChildCom) return; // 进入子元素范围则无需触发
- if (!this.dragType) return;
- ...
- }
重叠和空类型直接当做无效动作处理
- async handleDrop() {
- if (this.isInChildCom) return; // 进入子元素范围则无需触发
- if (!this.dragType) return;
- try {...}
- catch (e) {
- this.layout.splice(this.dragLayoutIndex, 1);
- throw e;
- }
- finally {
- this.$emit('update:dragType', null);
- }
- }
这个try catch我们之前已经讲过了。try里面的逻辑也很简单
- try {
- let field = createDashboardField(this.dragType);
- ...
- field.widget.layout = pick(this.dragLayout, 'x', 'y', 'w', 'h');
- ...
- // 更新布局
- this.layout.splice(this.dragLayoutIndex, 1, {
- ...field.widget.layout,
- i: field.pkId,
- });
- // 提交数据存储
- this.$emit('add', field);
- }
由插件处理,会自动更新到this.layout
由插件处理,会自动更新到this.layout
- async handleDelete(pkId) {
- const cloneFields = deepClone(this.fields);
- // 摘除删除的组件数据
- this.updateFields(
- cloneFields.filter(field => {
- return field.pkId !== pkId;
- }),
- );
- await this.$nextTick();
- this.$refs.container.syncLayout();
- }
- /** @name 同步layout **/
- async syncLayout() {
- this.layout = ensureArray(this.fields).map(field => ({
- ...field.widget.layout,
- i: field.pkId,
- }));
- await this.$nextTick();
- }
额外讲一下选中组件对组件进行修改
当我们选中组件的时候,需要在vuex里登记一下当前的选中状态
- <grid-item
- v-for="layoutItem in layout"
- ...
- @mousedown.native="handlePointerDown"
- @mouseup.native="handlePointerUp($event, layoutItem.i)"
- >
- ...
- grid-item>
加了一些位置判断,以防这个组件位置出错或已经不在布局里
- /** @name 鼠标设备按下与抬起事件处理 **/
- _pointerContext = null;
- handlePointerDown(ev) {
- this._pointerContext = {
- x: ev.clientX,
- y: ev.clientY,
- };
- }
- handlePointerUp(ev, pkId) {
- if (!this._pointerContext || !this.fieldMap[pkId]) return;
- const { x, y } = this._pointerContext;
- if (x !== ev.clientX || y !== ev.clientY) return;
- this.selectField(this.fieldMap[pkId]);
- }
- @formDesignModule.Mutation selectField;
再来看看仓库的代码
- // fromdesign.js
- selectField(state, field) {
- state.selectedField = field;
- },
如果当前组件的内容或属性发送变更,则执行
commit('selectField', newField);