低代码扫盲:Vite+vue3实现C端活动配置

1,886 阅读6分钟

最近,接手公司之前的低代码配置项目,项目是用来配置C端活动页的。使用中,发现了很多弊端,问题一大堆,惨不忍睹😂😂😂,正好组内也是有项目迁移的打算,然后就自己动手实现了一个。话不多说,开整😏😏

一、初始化项目

活动配置类的低代码项目,除了配置平台,还有一个承载页展示。我们用lerna来初始化项目,方便管理。

mkdir lowCode 
cd lowCode
lerna init --independent
mkdir packages
cd packages

新建两个项目low-code-page(C端承载页)、low-code-set(配置平台)。

image.png 生成后的目录。

image.png 将项目中package.json中的"private": true去掉,使用lerna list,便能看到lerna下的两个项目了。

image.png

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>

其余项目配置不是重点,此处就不阐述了。启动项目后页面如下, 左边是组件摆放区,中间是渲染区,右侧是组件配置区。

image.png

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负责注册组件

image.png

// 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>
......

image.png

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>

image.png 至此,配置端的核心已经完成了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

image.png 这样,我们就实现了Contanier组件,可以把组件拖到Contanier容器中。

HotArea

图片热点区域实现是一个难点,因为篇幅原因,会再用一章来解释图片热点的实现。

最终实现的效果如下

image.png 渲染数据如下

image.png

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>

最后效果如下:

image.png

image.png

好了,到这里,就已经实现了我们的需求了。文章较长,有些地方写的不够详尽,建议结合代码一起看哦~~~~

代码地址:gitee.com/JLeeChen/lo…