目录
- 背景
- 技术&文档
- 二开优化方案
- 1. 优化侧边栏
- 2. 优化图片插入
- 3. 新增可插入画布的组件
- 4. 解决组件鼠标默认事件冲突的问题
- 数据保存对接&页面生成预览
- 保存对接
- 生成预览
- 源码下载
背景
接到一个需求做一个拖拽模板低代码生成界面(如上图),就是可以自定义界面元素拖拽生成页面,该页面需要可以存储,并且一比一还原。
因此得研究实现一个拖拽生成低代码平台,通过查询了各种资料,找到了以下比较合适的开源的低代码平台:
- visual-drag-demo:https://github.com/woai3c/visual-drag-demo
- 拖拽大屏:https://gitee.com/gist006/vue-visual-drag
- 专题制作工具:https://gitee.com/Maxfengyan/visual-drag
- GoView低代码数据可视化:https://www.mtruning.club/
- 鲁班H5:https://ly525.gitee.io/luban-h5/zh/
- quark-h5: https://github.com/huangwei9527/quark-h5
根据自己的需求,选择了visual-drag-demo为模板进行了二开。
在线预览:
预览地址:https://qkongtao.gitee.io/visual-drag-demo
最后集成在后台系统中:
技术&文档
使用到的技术功能点:
- 编辑器
- 自定义组件(文本、图片、矩形、圆形、直线、星形、三角形、按钮、表格、组合)
- 接口请求(通过接口请求组件数据)
- 组件联动
- 拖拽
- 删除组件、调整图层层级
- 放大缩小
- 撤消、重做
- 组件属性设置
- 吸附
- 预览、保存代码
- 绑定事件
- 绑定动画
- 拖拽旋转
- 复制粘贴剪切
- 多个组件的组合和拆分
- 锁定组件
- 网格线
可以参考原作者大大的文档:
可视化拖拽组件库一些技术要点原理分析(一):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. 优化侧边栏
修改侧边栏的样式
src\components\ComponentList.vue
<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>
src\components\RealTimeComponentList.vue
<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. 优化图片插入
插入图片时的大小优化(插入图片分辨率过大时自定义缩放图片)
src\components\Toolbar.vue
修改 handleFileChange(e) 方法
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). 在 src\custom-component 目录下新建需要新增的组件文件夹
2). 在该文件夹下面新建两个vue文件Component.vue、Attr.vue(示例浏览器):
Component.vue
组件的具体代码内容
<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
组件的侧边栏动态功能(修改浏览器链接、上传文件等)
<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). 在 src\custom-component\component-list.js组件列表中添加对应的组件信息
注意组件名称等信息要对应刚刚建立组件的名称
// 编辑器左侧组件列表
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;
在src\components\Editor\index.vue 页面组件列表展示设置对应组件的样式 pointer-events: none;进行控制组件是否可以拖拽。
在src\components\Editor\ComponentWrapper.vue 预览或者导出的时候需要设置对应组件的样式style:pointer-events: auto; 恢复该组件原有的鼠标事件
src\components\Editor\index.vue
... ...
... ...
... ...
<!--页面组件列表展示-->
<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>
src\components\Editor\ComponentWrapper.vue
<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;
保存示例如下:
{
"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即可。
生成预览
本项目中有一个页面预览的封装组件
src\components\Editor\Preview.vue
预览的方案就是先根据画布数据(canvasStyleData)新建一个总container,然后在该container中遍历组件数据(componentData),然后通过component组件和is属性实现动态组件的渲染还原。
源码下载
源码链接:https://gitee.com/qkongtao/visual-drag-demo