最近,接手公司之前的低代码配置项目,项目是用来配置C端活动页的。使用中,发现了很多弊端,问题一大堆,惨不忍睹😂😂😂,正好组内也是有项目迁移的打算,然后就自己动手实现了一个。话不多说,开整😏😏
一、初始化项目
活动配置类的低代码项目,除了配置平台,还有一个承载页展示。我们用lerna来初始化项目,方便管理。
mkdir lowCode
cd lowCode
lerna init --independent
mkdir packages
cd packages
新建两个项目low-code-page(C端承载页)、low-code-set(配置平台)。
生成后的目录。
将项目中package.json中的
"private": true去掉,使用lerna list,便能看到lerna下的两个项目了。
1、添加依赖
low-code-set, vant用于中间中间展示配置,vuedraggable拖拽组件核心
npm i vant element-plus @element-plus/icons-vue vue-router@4 vuedraggable less -D
low-code-page
npm i vant less -D
2、项目配置
low-code-set 添加配置页面
// packages/low-code-set/src/views/pageSet/index.vue
<template>
<div class="content">
<div class="set">
<div class="left">
</div>
<div class="center">
</div>
<div class="right">
</div>
</div>
<div class="bottom">
<button @click="handleSave">保存</button>
<button @click="handlePreview">预览</button>
</div>
</div>
</template>
<script setup lang="ts">
const handleSave = () => {
};
const handlePreview = () => {
};
</script>
<style scoped lang="less">
.content {
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
background: #efefef;
.left,
.right {
flex: 1;
height: 100%;
overflow: auto;
background: #fff;
padding: 20px;
}
.center {
width:375px; // 设置宽度为普通屏幕标准750/2
height: 100%;
overflow: auto;
background: #fff;
margin: 0 20px;
position: relative;
.render-draggable {
height: 100%;
overflow: auto;
}
}
}
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border: 1px solid #efefef;
}
.left-draggable {
display: flex;
}
.set {
display: flex;
flex: 1;
overflow: auto;
}
.bottom {
text-align: center;
padding: 15px;
button {
padding: 5px 20px;
border-radius: 5px;
cursor: pointer;
&:first-child {
background: #0097ff;
color: #fff;
border: none;
}
&:not(:first-child) {
margin-left: 10px;
border: 1px solid #efefef;
}
}
}
:deep(.select-comp) {
border: 1px dashed #efefef;
}
</style>
其余项目配置不是重点,此处就不阐述了。启动项目后页面如下, 左边是组件摆放区,中间是渲染区,右侧是组件配置区。
low-code-page就很简单了,等会和渲染一起讲。
实现
组件
我们先在packages/low-code-set/src/components目录下实现1个基础组件image。
配置组件信息
// packages/low-code-set/src/components/configs.ts
const configs = [
{
groupName: '其他',
components: [
{
//渲染组件名
render: 'Image',
name: '图片',
//组件区图片
icon: 'Picture',
//配置数据
configData: {
style: 'width:100%',
url: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
}
}
]
},
]
export default configs
创建组件文件,config是右侧配置信息,render是中间渲染,index负责注册组件
// config.vue
<template>
<el-form :model="configData" label-width="80px">
<el-form-item label="样式">
<el-input v-model="configData.style" />
</el-form-item>
<el-form-item label="图片链接">
<el-input v-model="configData.url" />
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
data: any;
}>();
const configData = computed(() => props.data);
</script>
// render.vue
<template>
<img :src="configData.url" :style="configData.style" />
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
data: any;
}>();
const configData = computed(() => props.data.configData);
</script>
// index.ts
import Config from './config.vue'
import Render from './render.vue'
const install = (app:any) => {
app.component('ImageConfig',Config)
app.component('Image',Render)
}
export default install
使用组件
//packages/low-code-set/src/components/index.ts
import Image from './image'
const install = (app: any) => {
app.use(Image)
}
export default install
在packages/low-code-set/src/main.ts中引入使用
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+ import Components from './components'
import 'element-plus/dist/index.css'
import './style.css'
import 'vant/lib/index.css';
const app = createApp(App);
-app.use(router).use(ElementPlus).mount('#app')
+app.use(router).use(Components).use(ElementPlus).mount('#app')
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
有了上面的组件后,就需要实现拖拽了,这也是重点,但不是难点😄😄😄😄。
拖拽
拖拽的实现借助vuedraggable 来实现。
1、根据组件配置渲染左侧组件区。
需要注意的是,draggable一定要配置clone,不然三个数据都是指向同一个地址,会出现改一动全的bug,sort=false,防止组件区的相互拖拽
<template>
<div class="content">
<div class="set">
<div class="left">
+ <div v-for="group in componentConfig" :key="group.groupName">
+ <div>
+ {{ group.groupName }}
+ </div>
+ <draggable
+ :group="{ name: group.groupName, pull: 'clone', put: false }"
+ :list="group.components"
+ animation="300"
+ :sort="false"
+ :clone="clone"
+ @end="handleEnd"
+ class="left-draggable"
+ >
+ <template #item="{ element }">
+ <div class="item">
+ <component :is="element.icon" style="width: 20px" />
+ <span>{{ element.name }}</span>
+ </div>
+ </template>
+ </draggable>
+ </div>
</div>
<div class="center"></div>
<div class="right"></div>
</div>
<div class="bottom">
<button @click="handleSave">保存</button>
<button @click="handlePreview">预览</button>
</div>
</div>
</template>
<script setup lang="ts">
+ import draggable from "vuedraggable";
+ import componentConfig from "@/components/configs";
// 拖拽结束回调
+ const handleEnd = (evt: any) => {
+ console.log("handleEnd==>", evt);
+ };
const handleSave = () => {};
const handlePreview = () => {};
+ const clone = (obj: any) => {
+ // 深拷贝一个对象,否则三个数据指向的都是一个地址
+ const newObj = JSON.parse(JSON.stringify(obj));
+ return newObj;
+};
</script>
......
2、中间接收区
......
<div class="center">
+ <!-- 用于接收拖拽数据 -->
+ <draggable
+ :list="renderList"
+ :group="{ name: 'renderList', pull: true, put: true }"
+ animation="300"
+ class="render-draggable"
+ >
+ <template #item="{ element }">
+ <component
+ :is="element.render"
+ :data="element"
+ :class="{ 'select-comp': currComp === element }"
+ @click="() => handleSelectComponent(element)"
+ ></component>
+ </template>
+ </draggable>
</div>
......
<script setup lang="ts">
+ import { ref } from "vue";
import draggable from "vuedraggable";
import componentConfig from "@/components/configs";
//渲染区数据
+ const renderList = ref([]);
//当前选中设置组件
+ const currComp = ref({});
const handleEnd = (evt: any) => {
console.log("handleEnd==>", evt);
};
+ const handleSelectComponent = (component: any) => {
+ currComp.value = component;
+ };
const handleSave = () => {};
const handlePreview = () => {};
const clone = (obj: any) => {
// 深拷贝一个对象,否则三个数据指向的都是一个地址
const newObj = JSON.parse(JSON.stringify(obj));
return newObj;
};
</script>
......
右侧配置区
右侧配置区就很简单了,直接拿到当前选中的组件,将configData传过去,如下
......
<div class="right">
+ <div>{{ currComp.name }}</div>
+ <component
+ :is="`${currComp.render}Config`"
+ :data="currComp.configData"
+ ></component>
</div>
至此,配置端的核心已经完成了70%😄😄😄😄。
完善
配置中,少不了容器组件Container、图片热点区域组件HotArea。
Container
container/config.vue
// container/config.vue
<template>
<el-form :model="configData" label-width="80px">
<el-form-item label="样式">
<el-input v-model="configData.style" />
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
data: any;
}>();
const configData = computed(() => props.data);
</script>
container/render.vue,也是一个draggable组件,可以往里或往外拖组件。通过inject引用根组件当前的选中组件与组件点击事件。
// container/render.vue
<template>
<draggable
:list="list"
:group="{ name: 'renderList', pull: true, put: true }"
animation="300"
:style="configData.style"
>
<template #item="{ element }">
<component
:is="element.render"
:data="element"
:class="{ 'select-comp': currComp === element }"
@click.stop="() => handleSelectComponent(element)"
></component>
</template>
</draggable>
</template>
<script setup lang="ts">
import { computed, ref, inject } from "vue";
import draggable from "vuedraggable";
const currComp = inject("currComp");
const handleSelectComponent = inject("handleSelectComponent");
const props = defineProps<{
data: any;
}>();
const list = computed(() => props.data.children);
const configData = computed(() => props.data.configData);
</script>
在根组件中通过provide提供给子组件引用
// packages/low-code-set/src/views/pageSet/index.vue
import { ref,provide } from "vue";
provide("currComp", currComp);
provide("handleSelectComponent", handleSelectComponent);
container/index.ts
//container/index.ts
import Config from './config.vue'
import Render from './render.vue'
const install = (app:any) => {
app.component('ContainerConfig',Config)
app.component('Container',Render)
}
export default install
应用组件
// packages/low-code-set/src/components/configs.ts
const configs = [
+ {
+ groupName: '基础组件',
+ components: [
+ {
+ render: 'Container',
+ name: '容器',
+ icon: 'House',
+ configData: {
+ style: 'position:relative;width:100%;min-height:100px;',
+ },
+ // 子组件
+ children: []
+ }
+ ]
+ },
{
groupName: '其他',
components: [
{
render: 'Image',
name: '图片',
icon: 'Picture',
configData: {
style: 'width:100%',
url: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
}
}
]
},
]
export default configs
import Image from './image'
+import Container from './container'
const install = (app: any) => {
app.use(Image)
+ app.use(Container)
}
export default install
这样,我们就实现了Contanier组件,可以把组件拖到Contanier容器中。
HotArea
图片热点区域实现是一个难点,因为篇幅原因,会再用一章来解释图片热点的实现。
最终实现的效果如下
渲染数据如下
C端展示
上面我们已经完成了PC端的配置工作,接下来,就是C端的承载渲染。
更改启动端口
为了避免每次启动窗口不一致,我们定好启动窗口,方便PC端配置预览链接
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {resolve} from 'path'
// https://vitejs.dev/config/
export default defineConfig({
.......
+ server: {
+ port: 3030,
+ },
})
添加相应组件
组件实现上比较简单,这里就不一一赘述,直接上代码。
// Container.vue
<template>
<div :style="withStyle(configData.style)">
<component
v-for="(comp, index) in list"
:key="index"
:is="comp.render"
:data="comp"
></component>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { withStyle } from "@/utils/changePx";
const props = defineProps<{
data: any;
}>();
const list = computed(() => props.data.children);
const configData = computed(() => props.data.configData);
</script>
// HotArea.vue
<template>
<div
class="hot-area"
:style="hotAreaStyle"
@click.stop="handleHotAreaClick"
></div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { withUnit } from "@/utils/changePx";
const props = defineProps<{
data: any;
}>();
const configData = computed(() => props.data.configData);
const hotAreaStyle = computed(() => {
const { width, height, left, top } = configData.value.position;
return {
width: withUnit(width),
height: withUnit(height),
left: withUnit(left),
top: withUnit(top),
};
});
const handleHotAreaClick = () => {
alert(`你点击了热区${configData.value.no}`);
};
</script>
<style lang="less" scoped>
.hot-area {
position: absolute;
z-index: 100;
}
</style>
// Image.vue
<template>
<img :src="configData.url" :style="withStyle(configData.style)" />
</template>
<script setup lang="ts">
import { computed } from "vue";
import { withStyle } from "@/utils/changePx";
const props = defineProps<{
data: any;
}>();
const configData = computed(() => props.data.configData);
</script>
细心的小伙伴可能会发现这个方法withStyle,这个方法是干嘛的呢🤔🤔🤔🤔。下面是他的实现,从代码中我们能看出,这个方法其实是将style中的px转换成rem。我们都知道,C端是有很多屏幕宽度的,直接用px,显然不行,所以我们需要想办法将px转换成rem。
// 单位处理
export const withUnit = (val: number | string = 0) => {
// 将配置数据的px单位转换成rem, 37.5是配置页面width/10, 10是./flexible.ts下设置的屏幕切割份数
return parseInt(val + "") / 37.5 + "rem";
};
// style中px转换rem
export const withStyle = (style: string = '') => {
if (!style?.length) return ''
const styleArray = style.split(';')
const newStyleArray = styleArray.map((v) => {
if (v.indexOf('px') < 0) return v
const [key, val] = v.split(':')
const valArr = val.split(/(px| )/)
// 紧对px的数值做处理
valArr.forEach((va, index) => {
if (va !== 'px') return;
// 将配置数据的px单位转换成rem, 37.5是配置页面width/10, 10是./flexible.ts下设置的屏幕切割份数
valArr[index - 1] = (valArr[index - 1] as unknown as number) / 37.5 + ''
valArr[index] = 'rem'
})
const valStr = valArr.join('')
return `${key}:${valStr}`
})
return newStyleArray.join(';')
}
注册组件
这里使用文件读取的方式来自动注册components下的所有组件,就不用一个一个引用。
// components/index.ts
import { defineAsyncComponent } from 'vue'
const modulesFiles = import.meta.glob('./*.vue');
const modules: { [prop: string]: any } = {};
for (const path in modulesFiles) {
const nameString = path.split('/')[1]
const name = nameString.split('.')[0]
modules[name] = modulesFiles[path]
}
const install = (app: any) => {
Object.entries(modules).forEach(([key, comp]) => {
app.component(key, defineAsyncComponent(comp))
})
}
export default install
//packages/low-code-page/src/main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
+ import components from './components'
+ import 'vant/lib/index.css'; // 引用vant组件样式
+ import '@/utils/flexible'
+createApp(App).use(components).mount('#app')
渲染页面
终终终终终终终终于来到最后一步了🤗🤗🤗🤗🤗。由于只有一个页面,我们也没必要引入vue-router了,直接在App.vue里干就完事了。
//packages/low-code-page/src/App.vue
<template>
<component
v-for="(comp, index) in renderList"
:key="index"
:is="comp.render"
:data="comp"
></component>
</template>
<script setup lang="ts">
import { ref } from "vue";
// 渲染数据列表
const renderList = ref([]);
// 兼容PC端iframe发送过来的渲染数据,该渲染数据也是从服务器获取的最终数据
window.addEventListener(
"message",
function (e) {
try {
renderList.value = JSON.parse(e.data);
} catch (error) {}
},
false
);
</script>
最后,在PC端接入预览的iframe。需要注意的是,这里iframe预览的宽度与中间渲染区宽度均设置成375px,因为普遍的设计稿都是这个尺寸,C端px转换成rem也是以375为准。
<template>
...
+<!-- 预览 -->
+ <el-dialog v-model="previewVisible" title="预览" width="415px" top="0">
+ <iframe id="frame" src="http://127.0.0.1:3030/" class="preview-iframe" />
+ </el-dialog>
</template>
<script setup lang="ts">
......
+ const previewVisible = ref(false);
const handlePreview = () => {
+ previewVisible.value = true;
+ setTimeout(() => {
+ let frame = document.getElementById("frame");
+ (frame as any).contentWindow.postMessage(
+ JSON.stringify(renderList.value),
+ "http://127.0.0.1:3030/" // 按需求替换链接
+ );
+ clearTimeout(t);
+ t=null;
+ }, 500);
};
......
</script>
......
+<style>
+.preview-iframe {
+ /* 375px,已普遍的设计稿尺寸为标准,同时,中间渲染区宽度也设置为375px,C端px转换成rem也应该以这个为准 */
+ width: 375px;
+ height: 80vh;
+ border-radius: 20px;
+ border: 2px solid #afafaf;
+}
+</style>
最后效果如下:
好了,到这里,就已经实现了我们的需求了。文章较长,有些地方写的不够详尽,建议结合代码一起看哦~~~~