vue3手写低代码思路

29 阅读1分钟

image.png

image.png

<template>
  <div class="bg-#fff" px20px py30px h-full>
    <!-- tabs -->
    <div w-full f-c justify-between mb20px pb10px style="border-bottom: 1px solid #cccccc">
      <div flex>
        <div class="cur">Form builder</div>
      </div>
 
      <div f-c>
        <a-button type="outline">保存</a-button>
      </div>
    </div>
 
    <!-- main -->
    <div class="low-code-editor" flex>
      <Toolbox />
 
      <Canvas v-model:components="components" @select="selectedId = $event" />
 
      <div class="json-output">
        <pre>{{ JSON.stringify(components, null, 2) }}</pre>
      </div>
    </div>
  </div>
</template>
 
<script setup lang="ts">
import { ref, computed } from 'vue'
import Toolbox from './components/Toolbox.vue'
import Canvas from './components/Canvas.vue'
 
const components = ref<any[]>([]) // 存储画布中的组件
 
const selectedId = ref<string | null>(null)
</script>
 
<style scoped lang="less">
.low-code-editor {
  height: 92%;
  background-color: #f3f3f3;
  padding: 20px;
}
 
.json-output {
  width: 300px;
  padding: 10px;
  background: #f5f5f5;
  overflow-y: auto;
}
 
.cur {
  color: rgb(15, 15, 15);
  font-weight: 600;
  position: relative;
}
 
.cur::after {
  content: '';
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: -20px;
  width: 100%;
  height: 2px;
  background-color: #3874cb;
}
 
:deep(.arco-list-item) {
  &:hover {
    background: #dddddd; /* 更深的蓝色 */
  }
}
</style>

image.png

Toolbox
 
<template>
  <div h-full mr20px>
    <a-card class="card-demo" title="Form details" w-220px mb20px hoverable>
      <BaseFormItem label="Form name (json)" tooltip="表单名称不能留空">
        <a-input v-model="formData.name" />
      </BaseFormItem>
    </a-card>
 
    <a-card class="card-demo" title="Components" w-220px hoverable>
      <a-list size="small">
        <a-list-item v-for="item in toolboxItems" :key="item.type" draggable="true" @dragstart="onDragStart($event, item.type)">
          <div f-c>
            <svg width="20" height="20" viewBox="0 0 24 24" class="icon">
              <g v-html="item.icon"></g>
            </svg>
            <div ml10px>{{ item.label }}</div>
          </div>
        </a-list-item>
      </a-list>
    </a-card>
  </div>
</template>
 
<script setup lang="ts">
import BaseFormItem from '@/components/BaseFormItem/index.vue'
import { generateThreeDigitNumber } from '@/utils/tool'
import { onMounted, ref } from 'vue'
 
const formData = ref({
  name: `new_template_${generateThreeDigitNumber()}`,
})
 
const toolboxItems = [
  {
    type: 'a-switch',
    label: 'Boolean',
    icon: '<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-1 14H6v-2h12v2z" fill="currentColor"/>',
  },
  {
    type: 'a-switch',
    label: 'Multiple Choice',
    icon: '<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zM8 10l2 2 5-5m-7 7h8" fill="none" stroke="currentColor" stroke-width="2"/>',
  },
  {
    type: 'a-switch',
    label: 'Date',
    icon: '<path d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V9h14v11z" fill="currentColor"/>',
  },
  {
    type: 'a-switch',
    label: 'Date + Time',
    icon: '<path d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V9h14v11zm-7-7V9H9v4H7v2h5v-2z" fill="currentColor"/>',
  },
  {
    type: 'a-switch',
    label: 'Description Text',
    icon: '<path d="M3 4h18v2H3V4zm0 4h18v2H3V8zm0 4h12v2H3v-2zm0 4h18v2H3v-2z" fill="currentColor"/>',
  },
  {
    type: 'a-switch',
    label: 'Image',
    icon: '<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 16H5V5h14v14zm-9-8l-3 3-2-2-3 3V7h8v4z" fill="currentColor"/>',
  },
  {
    type: 'a-switch',
    label: 'Number Field',
    icon: '<path d="M3 4h18v2H3V4zm6 4h6v2H9V8zm-6 4h18v2H3v-2zm6 4h6v2H9v-2z" fill="currentColor"/>',
  },
  { type: 'a-switch', label: 'Text', icon: '<path d="M3 4h18v2H3V4zm0 4h12v2H3V8zm0 4h18v2H3v-2z" fill="currentColor"/>' },
  {
    type: 'a-switch',
    label: 'Time',
    icon: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm-1-12v5l4 2 1-1-3-2V8h-2z" fill="currentColor"/>',
  },
  {
    type: 'a-switch',
    label: 'Video',
    icon: '<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm-1 14H6V7h12v10zm-7-7l5 3-5 3V10z" fill="currentColor"/>',
  },
  {
    type: 'a-switch',
    label: 'Radio',
    icon: '<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="currentColor"/>',
  },
]
 
const onDragStart = (event: DragEvent, type: string) => {
  event.dataTransfer?.setData('componentType', type)
}
</script>
 
<style scoped>
.card-demo {
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
 
  :deep(.arco-list-content) {
    height: 390px;
    overflow-y: auto;
 
    /* 隐藏滚动条 - Webkit 浏览器(Chrome、Safari) */
    &::-webkit-scrollbar {
      width: 0;
    }
 
    /* 隐藏滚动条 - Firefox */
    scrollbar-width: none;
 
    /* 隐藏滚动条 - IE 和 Edge(旧版本) */
    -ms-overflow-style: none;
  }
}
</style>

image.png

import { generateThreeDigitNumber } from '@/utils/tool'
 
export interface ComponentConfig {
  uiType: string
  wordType: string
  props: Record<string, any>
  params:any
}
 
export const componentConfigs: Record<string, any> = {
  'a-switch': {
    uiType: 'a-switch',
    wordType: 'boolean',
    props: {
      disabled: true,
      modelValue: false
    },
    params: {
      fieldName: '',
      lable: '',
      default: false,
      readonly: false,
      required: false,
    },
  },
}
 
// 根据类型获取默认配置
export const getComponentConfig = (type: string): ComponentConfig => {
  return componentConfigs[type]
}
<template>
  <a-card title="画布" style="flex: 1; min-height: 400px">
    <div class="canvas-area" @drop="onDrop" @dragover.prevent="onDragOver" @dragenter.prevent>
      <div v-if="props.components.length === 0" class="placeholder">拖拽组件到这里</div>
 
      <div v-else>
        <VueDraggable v-model="draggableComponents" item-key="id">
          <component-renderer
            v-for="comp in props.components"
            :key="comp.id"
            :component="comp"
            :componentsArr="components"
            @remove="removeItem"
            @changeComponent="changeComponent"
          />
        </VueDraggable>
      </div>
    </div>
  </a-card>
</template>
 
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue'
import ComponentRenderer from './ComponentRenderer.vue'
import { getComponentConfig } from '../tool'
import { generateRandomNumber, generateThreeDigitNumber } from '@/utils/tool'
import { VueDraggable } from 'vue-draggable-plus'
import { ComponentType } from '@/types/humanTemplates'
 
const props = defineProps<{
  components: ComponentType[]
}>()
 
const emit = defineEmits(['update:components'])
 
const draggableComponents = computed({
  get() {
    return props.components // 返回当前组件数组
  },
  set(newValue) {
    emit('update:components', newValue) // 更新父组件
  },
})
 
const onDragOver = (event: DragEvent) => {
  event.preventDefault()
}
 
const onDrop = (event: DragEvent) => {
  event.preventDefault()
  const type = event.dataTransfer?.getData('componentType')
  if (type) {
    const config: any = getComponentConfig(type) // 获取组件配置
 
    const newComponent = {
      id: `comp${Date.now()}${generateThreeDigitNumber()}`,
      ...config, // 展开默认配置
      params: { ...config.params },
    }
 
    newComponent.params.fieldName = `field_${config.wordType}_${generateRandomNumber()}`
 
    emit('update:components', [...props.components, newComponent])
  }
}
 
const removeItem = (id: string) => {
  const updatedComponents = props.components.filter((comp) => comp.id !== id)
  emit('update:components', updatedComponents)
}
 
const changeComponent = (params: ComponentType) => {
  const updatedComponents = props.components.map((comp) => {
    if (comp.id === params.id) {
      return {
        ...comp, // 保留其他属性
        ...params, // 覆盖新的属性
      }
    }
    return comp // 如果不是目标组件,直接返回
  })
 
  emit('update:components', updatedComponents)
}
</script>
 
<style scoped>
.canvas-area {
  height: 540px;
  border: 1px solid #ccc;
}
 
.placeholder {
  text-align: center;
  color: #999;
  line-height: 400px;
}
</style>
ComponentRenderer
 
<template>
  <div class="component-wrapper" f-c justify-between>
    <!-- 动态渲染组件 -->
    <div f-c>
      <icon-drag-dot-vertical mr10px class="h-5 w-5 text-gray-500 cursor-grab" />
 
      <!-- 有lable了。使用BaseFormItem -->
      <BaseFormItem v-if="component.params.lable" :label="component.params.lable" w-300px>
        <component :is="componentType" v-bind="component.props"></component>
      </BaseFormItem>
 
      <component v-else :is="componentType" v-bind="component.props"></component>
    </div>
 
    <div f-c>
      <icon-edit text-20px mr10px @click="componentModalShow = true" />
      <icon-delete text-20px @click.stop="$emit('remove', component.id)" />
    </div>
  </div>
 
  <!-- component modal -->
  <ComponentModal v-model="componentModalShow" :component="component" @changeComponent="changeComponent" />
</template>
 
<script setup lang="ts">
import { computed, defineProps, ref, watch } from 'vue'
import { Button, Input, Switch, Checkbox, DatePicker, TimePicker, Radio } from '@arco-design/web-vue'
import ComponentModal from './ComponentModal.vue'
import BaseFormItem from '@/components/BaseFormItem/index.vue'
import { ComponentType } from '@/types/humanTemplates'
 
const props = defineProps<{
  component: ComponentType
  componentsArr: ComponentType[]
}>()
 
const emit = defineEmits(['remove', 'changeComponent'])
 
const componentModalShow = ref(false)
 
// 定义组件映射表
const componentMap: Record<string, any> = {
  'a-switch': Switch,
}
 
const componentType = computed(() => {
  return componentMap[props.component.uiType] || 'div' // 默认返回 div
})
 
const changeComponent = (params: ComponentType) => {
  emit('changeComponent', { ...params })
}
</script>
 
<style scoped lang="less">
.component-wrapper {
  margin: 8px;
  padding: 4px;
  border: 1px dashed #ccc;
  margin-bottom: 10px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
 
.component-wrapper:hover {
  border-color: #4a9dd5;
}
 
.delete-icon {
  width: 16px;
  height: 16px;
}
</style>
ComponentModal
 
<template>
  <a-modal
    v-if="modelValue"
    :width="600"
    title="EDIT COMPONENT"
    :visible="modelValue"
    :body-style="{ paddingLeft: 0, paddingRight: 0 }"
    ok-text="保存"
    @ok="handleOk"
    @cancel="cancel()"
  >
    <BaseForm ref="formRef" :model="componentParams.params" layout="vertical" px20px>
      <div f-c>
        <BaseFormItem
          label="fieldName"
          tooltip="This will populate in the human task form in your workflow definition and appear as an Input parameter."
          mr20px
        >
          <a-input v-model="componentParams.params.fieldName" />
        </BaseFormItem>
 
        <BaseFormItem
          label="lable"
          tooltip="This will populate in the human task form in your workflow definition and appear as an Input parameter."
        >
          <a-input v-model="componentParams.params.lable" />
        </BaseFormItem>
      </div>
 
      <div f-c w300px mt10px>
        <a-switch v-model="componentParams.params.default">
          <template #checked>ON</template>
          <template #unchecked>OFF</template>
        </a-switch>
 
        <div ml10px>Boolean default value</div>
      </div>
 
      <div f-c mt10px>
        <div f-c>
          <a-switch v-model="componentParams.params.readonly">
            <template #checked>ON</template>
            <template #unchecked>OFF</template>
          </a-switch>
 
          <div ml10px>Required</div>
        </div>
 
        <div f-c ml20px>
          <a-switch v-model="componentParams.params.required">
            <template #checked>ON</template>
            <template #unchecked>OFF</template>
          </a-switch>
 
          <div ml10px>Read-only</div>
        </div>
      </div>
    </BaseForm>
 
    <div class="bg-#e5e6eb" w-full h1px my20px></div>
 
    <!-- 预览 -->
    <div px20px>
      <div mb10px font-600>预览</div>
 
      <!-- a-switch -->
      <div v-if="componentParams.uiType === 'a-switch'">
        <BaseFormItem v-if="componentParams.params.lable" :label="componentParams.params.lable" w-300px>
          <a-switch v-model="componentParams.params.default" disabled>
            <template #checked>ON</template>
            <template #unchecked>OFF</template>
          </a-switch>
        </BaseFormItem>
 
        <a-switch v-else v-model="componentParams.params.default" disabled>
          <template #checked>ON</template>
          <template #unchecked>OFF</template>
        </a-switch>
      </div>
    </div>
  </a-modal>
</template>
 
<script setup lang="ts">
import BaseForm from '@/components/BaseForm/index.vue'
import BaseFormItem from '@/components/BaseFormItem/index.vue'
import { ComponentType } from '@/types/humanTemplates'
import { ref, watch } from 'vue'
 
const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false,
  },
  component: {
    type: Object,
    default: () => {},
  },
})
 
const emit = defineEmits({
  'update:modelValue': (value: boolean) => value,
  changeComponent: (data: ComponentType) => data,
})
 
const formRef = ref()
 
const componentParams = ref({} as ComponentType)
 
const cancel = () => {
  emit('update:modelValue', false)
}
 
const handleOk = async () => {
  cancel()
  const modelValue = componentParams.value.params.default
 
  const dataParams = {
    ...componentParams.value,
    props: {
      ...componentParams.value.props,
      modelValue,
    },
  }
  emit('changeComponent', { ...dataParams })
 
  console.log('🚀 ~ handleOk ~ componentParams.value:', dataParams)
}
 
watch(
  () => props.modelValue,
  (newData: boolean) => {
    if (newData) {
      componentParams.value = JSON.parse(JSON.stringify(props.component))
    } else {
      componentParams.value = JSON.parse(JSON.stringify({}))
    }
  },
  { immediate: true }
)
</script>
 
<style></style>