VisualDrag低代码拖拽模板

2022-11-22 17:37:53 浏览数 (1)

背景

接到一个需求做一个拖拽模板低代码生成界面(如上图),就是可以自定义界面元素拖拽生成页面,该页面需要可以存储,并且一比一还原。

因此得研究实现一个拖拽生成低代码平台,通过查询了各种资料,找到了以下比较合适的开源的低代码平台:

  1. visual-drag-demo:https://github.com/woai3c/visual-drag-demo
  2. 拖拽大屏:https://gitee.com/gist006/vue-visual-drag
  3. 专题制作工具:https://gitee.com/Maxfengyan/visual-drag
  4. GoView低代码数据可视化:https://www.mtruning.club/
  5. 鲁班H5:https://ly525.gitee.io/luban-h5/zh/
  6. quark-h5: https://github.com/huangwei9527/quark-h5

根据自己的需求,选择了visual-drag-demo为模板进行了二开。

在线预览: 预览地址:https://qkongtao.gitee.io/visual-drag-demo

最后集成在后台系统中:

技术&文档

使用到的技术功能点:

  1. 编辑器
  2. 自定义组件(文本、图片、矩形、圆形、直线、星形、三角形、按钮、表格、组合)
  3. 接口请求(通过接口请求组件数据)
  4. 组件联动
  5. 拖拽
  6. 删除组件、调整图层层级
  7. 放大缩小
  8. 撤消、重做
  9. 组件属性设置
  10. 吸附
  11. 预览、保存代码
  12. 绑定事件
  13. 绑定动画
  14. 拖拽旋转
  15. 复制粘贴剪切
  16. 多个组件的组合和拆分
  17. 锁定组件
  18. 网格线

可以参考原作者大大的文档: 可视化拖拽组件库一些技术要点原理分析(一):https://github.com/woai3c/Front-end-articles/issues/19 可视化拖拽组件库一些技术要点原理分析(二):https://github.com/woai3c/Front-end-articles/issues/20 可视化拖拽组件库一些技术要点原理分析(三):https://github.com/woai3c/Front-end-articles/issues/21 可视化拖拽组件库一些技术要点原理分析(四):https://github.com/woai3c/Front-end-articles/issues/22

在作者的这几篇文章中把技术点介绍的很详细,虽然还是有很多不懂的,,,

二开优化方案

由于个人的能力有限,只能在作者的基础上优化成满足自己需求的拖拽模板

1. 优化侧边栏

修改侧边栏的样式 srccomponentsComponentList.vue

代码语言:javascript复制
<template>
  <div class="component-list" @dragstart="handleDragStart">
    <div
      v-for="(item, index) in componentList"
      :key="index"
      class="list"
      draggable
      :data-index="index"
    >
      <span class="iconfont" :class="'icon-'   item.icon"></span>
      <span class="btn_name">{{ item.label }}</span>
    </div>
  </div>
</template>

<script>
import componentList from "@/custom-component/component-list";

export default {
  data() {
    return {
      componentList,
    };
  },
  methods: {
    handleDragStart(e) {
      e.dataTransfer.setData("index", e.target.dataset.index);
    },
  },
};
</script>

<style lang="scss" scoped>
.component-list {
  width: 200px;
  height: 55%;
  margin: 10px auto 0;
  display: grid;
  grid-gap: 10px 30px;
  grid-template-columns: repeat(auto-fill, 66px);
  grid-template-rows: repeat(auto-fill, 56px);

  .list {
    width: 66px;
    height: 56px;
    border: 1px solid #ddd;
    cursor: grab;
    text-align: center;
    color: #333;
    // background-color: #f3f3f3;
    border-radius: 8px;
    box-shadow: rgb(168 168 168 / 30%) 0px 2px 4px 0px;
    padding: 2px 5px;
    margin-left: 20px;

    &:active {
      cursor: grabbing;
    }

    .iconfont {
      display: block;
      font-size: 24px;
      margin-top: 3px;
      margin-bottom: 0px;
    }

    .icon-wenben,
    .icon-biaoge {
      font-size: 24px;
    }

    .icon-tupian {
      font-size: 24px;
    }
    .btn_name {
      font-size: 10px;
      line-height: 20px;
      color: rgb(31, 62, 104);
      width: 56px;
      padding: 0px 5px;
      word-break: keep-all;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
  }
}
</style>

srccomponentsRealTimeComponentList.vue

代码语言:javascript复制
<template>
  <div class="real-time-component-list">
    <div
      v-for="(item, index) in componentData"
      :key="index"
      class="list"
      :class="{ actived: transformIndex(index) === curComponentIndex }"
      @click="onClick(transformIndex(index))"
    >
      <span class="iconfont" :class="'icon-'   getComponent(index).icon"></span>
      <span class="label">{{ getComponent(index).label }}</span>
      <div class="icon-container">
        <span
          class="iconfont icon-shangyi"
          @click="upComponent(transformIndex(index))"
        ></span>
        <span
          class="iconfont icon-xiayi"
          @click="downComponent(transformIndex(index))"
        ></span>
        <span
          class="iconfont icon-shanchu"
          @click="deleteComponent(transformIndex(index))"
        ></span>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState } from "vuex";

export default {
  computed: mapState(["componentData", "curComponent", "curComponentIndex"]),
  methods: {
    getComponent(index) {
      return this.componentData[this.componentData.length - 1 - index];
    },

    transformIndex(index) {
      return this.componentData.length - 1 - index;
    },

    onClick(index) {
      this.setCurComponent(index);
    },

    deleteComponent() {
      setTimeout(() => {
        this.$store.commit("deleteComponent");
        this.$store.commit("recordSnapshot");
      });
    },

    upComponent() {
      setTimeout(() => {
        this.$store.commit("upComponent");
        this.$store.commit("recordSnapshot");
      });
    },

    downComponent() {
      setTimeout(() => {
        this.$store.commit("downComponent");
        this.$store.commit("recordSnapshot");
      });
    },

    setCurComponent(index) {
      this.$store.commit("setCurComponent", {
        component: this.componentData[index],
        index,
      });
    },
  },
};
</script>

<style lang="scss" scoped>
.real-time-component-list {
  height: 45%;

  .list {
    height: 50px;
    cursor: grab;
    text-align: center;
    color: #333;
    background-color: #f3f3f3;
    display: flex;
    align-items: center;
    font-size: 12px;
    padding: 0 15px;
    position: relative;
    user-select: none;

    &:active {
      cursor: grabbing;
    }

    &:hover {
      background-color: #d2d2d2;

      .icon-container {
        display: block;
      }
    }

    .label {
      font-size: 16px;
      margin-left: 5px;
    }
    .iconfont {
      margin-right: 5px;
      font-size: 30px;
    }

    .icon-shangyi,
    .icon-xiayi,
    .icon-shanchu {
      font-size: 28px;
      margin-right: 0px;
    }

    .icon-wenben,
    .icon-tupian {
      font-size: 28px;
    }

    .icon-container {
      position: absolute;
      right: 10px;
      display: none;

      .iconfont {
        cursor: pointer;
      }
    }
  }

  .actived {
    background: #ecf5ff;
    color: #409eff;
  }
}
</style>

2. 优化图片插入

插入图片时的大小优化(插入图片分辨率过大时自定义缩放图片) srccomponentsToolbar.vue 修改 handleFileChange(e) 方法

代码语言:javascript复制
handleFileChange(e) {
      const file = e.target.files[0];
      if (!file.type.includes("image")) {
        toast("只能插入图片");
        return;
      }

      const reader = new FileReader();
      reader.onload = (res) => {
        const fileResult = res.target.result;
        const img = new Image();
        img.onload = () => {
          const component = {
            ...commonAttr,
            id: generateID(),
            component: "Picture",
            label: "图片",
            icon: "",
            propValue: {
              url: fileResult,
              flip: {
                horizontal: false,
                vertical: false,
              },
            },
            style: {
              ...commonStyle,
              top: 0,
              left: 0,
              width:
                img.width > 1000
                  ? img.width * 0.3
                  : img.width < 300
                  ? img.width
                  : img.width * 0.5,
              height:
                img.width > 1000
                  ? img.height * 0.3
                  : img.width < 300
                  ? img.height
                  : img.height * 0.5,
            },
          };

          // 根据画面比例修改组件样式比例
          changeComponentSizeWithScale(component);

          this.$store.commit("addComponent", { component });
          this.$store.commit("recordSnapshot");

          // 修复重复上传同一文件,@change 不触发的问题
          $("#input").setAttribute("type", "text");
          $("#input").setAttribute("type", "file");
        };

        img.src = fileResult;
      };

      reader.readAsDataURL(file);
    },

3. 新增可插入画布的组件

可以在通过自定义封装组件,插入画布,因为在demo中,新增了几个常用的组件:

  • 音频
  • 视频
  • 浏览器

新增步骤如下: 1). 在 srccustom-component 目录下新建需要新增的组件文件夹 2). 在该文件夹下面新建两个vue文件Component.vue、Attr.vue(示例浏览器): Component.vue 组件的具体代码内容

代码语言:javascript复制
<template>
  <div style="overflow: hidden">
    <div class="iframe-container">
      <iframe
        name="myiframe"
        id="myiframe"
        :src="propValue.url"
        align="center"
        frameborder="0"
        allowfullscreen
      >
        <p>你的浏览器不支持iframe标签</p>
      </iframe>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    propValue: {
      type: Object,
      require: true,
      default: "",
    },
    element: {
      type: Object,
      default: () => {},
    },
  },
  methods: {},
};
</script>

<style lang="scss" scoped>
.iframe-container {
  width: 100%;
  height: 100%;
  position: relative;
}
.iframe-container iframe {
  // pointer-events: none;
  position: absolute;
  left: 0;
  top: 0;
  margin: 0px;
  width: 100%;
  height: 100%;
}
</style>

Attr.vue 组件的侧边栏动态功能(修改浏览器链接、上传文件等)

代码语言:javascript复制
<template>
  <div class="attr-list">
    <CommonAttr></CommonAttr>
    <el-form>
      <el-form-item label="网址链接">
        <el-input
          v-model="curComponent.propValue.url"
          type="textarea"
          :rows="3"
          style="clear: both"
        />
      </el-form-item>
    </el-form>
  </div>
</template>

<script>
import CommonAttr from "@/custom-component/common/CommonAttr.vue";

export default {
  components: { CommonAttr },
  computed: {
    curComponent() {
      return this.$store.state.curComponent;
    },
  },
};
</script>

3). 在 srccustom-componentcomponent-list.js组件列表中添加对应的组件信息 注意组件名称等信息要对应刚刚建立组件的名称

代码语言:javascript复制
// 编辑器左侧组件列表
const list = [{
    ... ...
    ... ...
    ... ...
{
        component: 'Browser',
        label: '浏览器',
        icon: 'hulianwang',
        propValue: {
            url: "https://mytab.qkongtao.cn/",
            flip: {
                horizontal: false,
                vertical: false,
            },
        },
        style: {
            width: 325,
            height: 560,
        },
 },
    ... ...
    ... ...
    ... ...
 }]

4). 封装完成后,即可以在页面中看到新增的组件

4. 解决组件鼠标默认事件冲突的问题

在插入audio 和 iframe等组件时,在画布上的拖拽失效,原因时鼠标事件和audio、iframe标签的原有事件冲突,外部无法对iframe内部进行操作。因此采用默认禁止有鼠标事件冲突组件的鼠标事件,等到预览展示时恢复鼠标事件。

使用css禁止元素的鼠标事件 pointer-events: none;

在srccomponentsEditorindex.vue 页面组件列表展示设置对应组件的样式 pointer-events: none;进行控制组件是否可以拖拽。 在srccomponentsEditorComponentWrapper.vue 预览或者导出的时候需要设置对应组件的样式style:pointer-events: auto; 恢复该组件原有的鼠标事件

srccomponentsEditorindex.vue

代码语言:javascript复制
    ... ...
    ... ...
    ... ...

<!--页面组件列表展示-->
    <Shape
      v-for="(item, index) in componentData"
      :key="item.id"
      :default-style="item.style"
      :style="getShapeStyle(item.style)"
      :active="item.id === (curComponent || {}).id"
      :element="item"
      :index="index"
      :class="{ lock: item.isLock }"
    >
      <component
        :is="item.component"
        v-if="item.component.startsWith('SVG')"
        :id="'component'   item.id"
        :style="getSVGStyle(item.style)"
        class="component"
        :prop-value="item.propValue"
        :element="item"
        :request="item.request"
      />
      <component
        :is="item.component"
        v-else-if="item.component == 'VText'"
        :id="'component'   item.id"
        class="component"
        :style="getComponentStyle(item.style)"
        :prop-value="item.propValue"
        :element="item"
        :request="item.request"
        @input="handleInput"
      />

      <component
        :is="item.component"
        v-else-if="item.component == 'Video'"
        :id="'component'   item.id"
        class="component"
        :style="getComponentStyle(item.style)"
        :prop-value="item.propValue"
        :element="item"
        :request="item.request"
      />

      <component
        :is="item.component"
        v-else
        :id="'component'   item.id"
        class="component"
        :style="getComponentStyle(item.style)"
        :prop-value="item.propValue"
        :element="item"
        :request="item.request"
        style="pointer-events: none"
      />
    </Shape>

srccomponentsEditorComponentWrapper.vue

代码语言:javascript复制
<template>
  <div @click="onClick" @mouseenter="onMouseEnter">
    <component
      :is="config.component"
      v-if="config.component.startsWith('SVG')"
      ref="component"
      class="component"
      :style="getSVGStyle(config.style)"
      :prop-value="config.propValue"
      :element="config"
      :request="config.request"
      :linkage="config.linkage"
    />
    <component
      :is="config.component"
      v-if="
        config.component.startsWith('Music') ||
        config.component.startsWith('Browser')
      "
      ref="component"
      class="component"
      :style="getSVGStyle(config.style)"
      :prop-value="config.propValue"
      :element="config"
      :request="config.request"
      :linkage="config.linkage"
      style="pointer-events: auto !important"
    />

    <component
      :is="config.component"
      v-else
      ref="component"
      class="component"
      :style="getStyle(config.style)"
      :prop-value="config.propValue"
      :element="config"
      :request="config.request"
      :linkage="config.linkage"
    />
  </div>
</template>

数据保存对接&页面生成预览

保存对接

本项目中在记录和传递数据中频繁的使用vuex,最后保存的数据为: 画布数据:this.$store.state.canvasStyleData; 画布内容数据:this.$store.state.componentData; 保存示例如下:

代码语言:javascript复制
{
    "canvasStyleData": {
        "width": 1280,
        "height": 720,
        "scale": 90,
        "color": "#000",
        "opacity": 1,
        "background": "#fff",
        "fontSize": 14
    },
    "componentData": [{
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [{
                "id": "",
                "label": "",
                "event": "",
                "style": [{
                    "key": "",
                    "value": ""
                }]
            }]
        },
        "component": "Picture",
        "label": "图片",
        "icon": "charutupian",
        "propValue": {
            "url": "http://localhost:8000/api/files/getImage/5865ef7d990e40a88a08ceca3e7c118c",
            "flip": {
                "horizontal": false,
                "vertical": false
            }
        },
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 270,
            "height": 180,
            "borderRadius": "",
            "top": 89,
            "left": 72
        },
        "id": "fcn3XAGtR50D_JcImnBbc"
    }, {
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [{
                "id": "",
                "label": "",
                "event": "",
                "style": [{
                    "key": "",
                    "value": ""
                }]
            }]
        },
        "component": "Video",
        "label": "视频",
        "icon": "shipin",
        "propValue": {
            "url": "https://qiniu.qkongtao.cn/2022/10/20221016134256839.mp4?_u003d1",
            "flip": {
                "horizontal": false,
                "vertical": false
            }
        },
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 360,
            "height": 270,
            "top": 89,
            "left": 722
        },
        "id": "1HqDupYn-KA-1Xl4gorHA"
    }, {
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [{
                "id": "",
                "label": "",
                "event": "",
                "style": [{
                    "key": "",
                    "value": ""
                }]
            }]
        },
        "component": "Browser",
        "label": "浏览器",
        "icon": "hulianwang",
        "propValue": {
            "url": "https://qkongtao.cn/",
            "flip": {
                "horizontal": false,
                "vertical": false
            }
        },
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 397,
            "height": 265,
            "top": 352,
            "left": 63
        },
        "id": "DyIYmOGLRgUt1iKCuoloC"
    }, {
        "animations": [],
        "events": {},
        "groupStyle": {},
        "isLock": false,
        "collapseName": "style",
        "linkage": {
            "duration": 0,
            "data": [{
                "id": "",
                "label": "",
                "event": "",
                "style": [{
                    "key": "",
                    "value": ""
                }]
            }]
        },
        "component": "CircleShape",
        "label": "圆形",
        "propValue": "u0026nbsp;",
        "icon": "24gl-circle",
        "style": {
            "rotate": 0,
            "opacity": 1,
            "width": 180,
            "height": 180,
            "fontSize": "",
            "fontWeight": 400,
            "lineHeight": "",
            "letterSpacing": 0,
            "textAlign": "center",
            "color": "rgba(213, 148, 27, 1)",
            "borderColor": "rgba(110, 204, 17, 1)",
            "borderWidth": 10,
            "backgroundColor": "rgba(186, 104, 104, 1)",
            "borderStyle": "solid",
            "borderRadius": "",
            "verticalAlign": "middle",
            "top": 188,
            "left": 497
        },
        "id": "hOIKf550JqWwA1uM3KGtD"
    }]
}

如果需要对接后端,记录canvasStyleData、componentData即可。

生成预览

本项目中有一个页面预览的封装组件 srccomponentsEditorPreview.vue 预览的方案就是先根据画布数据(canvasStyleData)新建一个总container,然后在该container中遍历组件数据(componentData),然后通过component组件和is属性实现动态组件的渲染还原。

源码下载

源码链接:https://gitee.com/qkongtao/visual-drag-demo

0 人点赞