使用element+vuedraggable+vue3来实现仿钉钉动态化表单

效果如下:

最近有需求需要实现一个这样的动态化创建,为了快速和方便,使用vuedraggable和element、vue3来实现

目前只实现了简单的示例,右侧属性部分可根据不同组件类型自行构建其他参数,反正大致就是这样,后续可以进行扩展,基本没什么难度了。

整个面板分为左中右结构,左边是基础控件库,中间是动态创建面板,右边是属性参数设置

因为整个都采用element组件,所以只需给dragList配置一个对象设置一个默认参数就可以,参数都是可扩展的,自己根据需要随时增加,下面代码我就实现了一个基本框架,有需要可以参考一下。

代码如下:

<template>
  <div class="designer">
    <div class="header">
      <div class="tabD">
        <div class="tabs">
          <div class="tab">
            <span class="roundNumber">1</span><span>基础设置</span>
          </div>
          <div class="tab active">
            <span class="roundNumber">2</span><span>表单设计</span>
          </div>
          <div class="tab">
            <span class="roundNumber">3</span><span>流程设计</span>
          </div>
          <div class="tab">
            <span class="roundNumber">4</span><span>高级设置</span>
          </div>
        </div>
      </div>
    </div>
    <div class="content">
      <div class="left">
        <div class="title">基础组件</div>
        <draggable class="items" :list="dragList" :force-fallback="true" @start="onStart" @end="onEnd" :clone="cloneDefaultField" :group="{ name: 'list', pull: 'clone',put:false }" :sort="false" itemKey="id">
          <template #item="{ element }">
            <div class="item">
              <div class="unit">
                <i class="iconfont" :class="element.icon"></i>
                <span>{{ element.props.label }}</span>
              </div>
            </div>
          </template>
        </draggable>
      </div>
      <div class="panl">
        <el-form readonly label-width="120px">
          <draggable :list="widgetList" id="panl" class="drag-content" @start="onStart2" @end="onEnd2" ghost-class="ghost" itemKey="id" :force-fallback="true" group="list" :fallback-class="true" :fallback-on-body="true">
            <template #item="{ element, index }">
              <el-alert class="itemx" :active="element.active" @click="changeActive(element, index)" v-if="element.componentName === 'ShuoMingField'" :title="element.props.defaultValue" :closable="false" type="warning" />
              <el-form-item v-else :active="element.active" @click="changeActive(element, index)" :required="element.props.required" class="item" :label="element.props.label">
                <el-input v-if="element.componentName === 'TextField'" v-model="element.props.defaultValue" :placeholder="element.props.placeholder" />
                <el-input v-if="element.componentName === 'TextAreaField'" type="textarea" v-model="element.props.defaultValue" :placeholder="element.props.placeholder" />
                <el-input-number v-if="element.componentName === 'NumberField'" v-model="element.props.defaultValue" :placeholder="element.props.placeholder" />
                <el-radio-group v-if="element.componentName === 'RadioField'" v-model="element.props.defaultValue">
                  <el-radio v-for="(item,index) in element.props.options" :key="index" :label="item.value">{{item.label}}</el-radio>
                </el-radio-group>
                <el-checkbox-group v-if="element.componentName === 'CheckboxField'" v-model="element.props.defaultValue">
                  <el-checkbox v-for="(item,index) in element.props.options" :key="index" :label="item.value">{{item.label}}</el-checkbox>
                </el-checkbox-group>
                <el-date-picker v-if="element.componentName === 'DatePickerField'" v-model="element.props.defaultValue" :placeholder="element.props.placeholder" />
                <el-date-picker v-if="element.componentName === 'DateRangePickerField'" v-model="element.props.defaultValue" type="daterange" range-separator="-" :start-placeholder="element.props.startPlaceholder" :end-placeholder="element.props.endPlaceholder" />
                <el-input v-if="element.componentName === 'TextCardField'" v-model="element.props.defaultValue" :placeholder="element.props.placeholder" />
                <el-input v-if="element.componentName === 'TextPhoneField'" v-model="element.props.defaultValue" :placeholder="element.props.placeholder" />
                <el-upload v-if="element.componentName === 'UploadFileField'" v-model:file-list="element.props.defaultValue" class="upload-demo" action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15" multiple>
                  <el-button type="primary" plain>
                    <el-icon style="vertical-align: middle">
                      <Search />
                    </el-icon>
                    添加
                  </el-button>
                </el-upload>
                <el-upload v-if="element.componentName === 'UploadImageField'" v-model:file-list="element.props.defaultValue" action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15" list-type="picture-card">
                  <el-icon>
                    <Plus />
                  </el-icon>
                </el-upload>
                <!-- <div class="btn">
                  <el-icon>
                    <CopyDocument />
                  </el-icon>
                  <el-icon>
                    <Delete />
                  </el-icon>
                </div> -->
              </el-form-item>

            </template>
          </draggable>
        </el-form>
        <div class="drag">
          <img src="../assets/drag.png" alt="">
          点击或拖拽左侧控件至此处
        </div>
      </div>
      <div class="right" v-if="widgetList.length>0">
        <div class="title">
          <i class="iconfont" :class="widgetList[IndexActive].icon"></i>
          <span>{{widgetList[IndexActive].name}}</span>
        </div>
        <div class="list">
          <div class="item">
            <div class="label">
              <span>标题</span>
              <span>最多50字</span>
            </div>
            <el-input maxlength="50" v-model="widgetList[IndexActive].props.label" placeholder="" />
          </div>
          <div class="item">
            <div class="label">
              <span>提示文字</span>
              <span>最多50字</span>
            </div>
            <el-input maxlength="50" v-model="widgetList[IndexActive].props.placeholder" placeholder="" />
          </div>
          <div class="item">
            <div class="label">
              <span>默认值</span>
              <span></span>
            </div>
            <el-input maxlength="50" v-model="widgetList[IndexActive].props.defaultValue" placeholder="" />
          </div>
          <div class="item">
            <div class="label">
              <span>必填</span>
              <span></span>
            </div>
            <el-switch v-model="widgetList[IndexActive].props.required" />
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import draggable from "vuedraggable";

interface option {
  [prop: string]: string | number;
}

interface module {
  name: string;
  componentName: string;
  icon: string;
  active: boolean;
  props: {
    label: string;
    placeholder: string;
    id: string;
    required: boolean;
    defaultValue: string | [] | number;
    [prop: string]: string | number | boolean | Array<option>;
  };
}
const dragList: module[] = reactive<module[]>([
  {
    componentName: "TextField",
    icon: "icon-xianxing",
    active: false,
    name: "单行输入框",
    props: {
      label: "单行输入框",
      placeholder: "请输入",
      id: "",
      required: true,
      defaultValue: "",
    },
  },
  {
    componentName: "TextAreaField",
    icon: "icon-dh",
    active: false,
    name: "多行输入框",
    props: {
      label: "多行输入框",
      placeholder: "请输入",
      id: "",
      required: true,
      defaultValue: "",
    },
  },
  {
    componentName: "NumberField",
    icon: "icon-sz",
    active: false,
    name: "数字输入框",
    props: {
      label: "数字输入框",
      placeholder: "请输入",
      id: "",
      required: true,
      defaultValue: "",
    },
  },
  {
    componentName: "RadioField",
    icon: "icon-danxuankuang",
    active: false,
    name: "单选框",
    props: {
      label: "单选框",
      placeholder: "请选择",
      id: "",
      required: true,
      defaultValue: "",
      options: [
        {
          label: "默认参数",
          value: 0,
        },
      ],
    },
  },
  {
    componentName: "CheckboxField",
    icon: "icon-dxk",
    active: false,
    name: "多选框",
    props: {
      label: "多选框",
      placeholder: "请选择",
      id: "",
      required: true,
      defaultValue: "",
      options: [
        {
          label: "默认参数",
          value: 0,
        },
      ],
    },
  },
  {
    componentName: "DatePickerField",
    icon: "icon-riqi",
    active: false,
    name: "日期",
    props: {
      label: "日期",
      placeholder: "请选择",
      id: "",
      required: true,
      defaultValue: "",
    },
  },
  {
    componentName: "DateRangePickerField",
    icon: "icon-rqqj",
    active: false,
    name: "日期区间",
    props: {
      label: "日期区间",
      placeholder: "请选择",
      id: "",
      required: true,
      defaultValue: "",
      startPlaceholder: "开始日期",
      endPlaceholder: "结束日期",
    },
  },
  {
    componentName: "ShuoMingField",
    icon: "icon-shuoming",
    active: false,
    name: "说明文字",
    props: {
      label: "说明文字",
      placeholder: "请输入",
      id: "",
      required: true,
      defaultValue: "请输入说明文字",
    },
  },
  {
    componentName: "TextCardField",
    icon: "icon-sfz",
    active: false,
    name: "身份证",
    props: {
      label: "身份证",
      placeholder: "请输入",
      id: "",
      required: true,
      defaultValue: "",
    },
  },
  {
    componentName: "TextPhoneField",
    icon: "icon-shouji",
    active: false,
    name: "电话",
    props: {
      label: "电话",
      placeholder: "请输入",
      id: "",
      required: true,
      defaultValue: "",
    },
  },
  {
    componentName: "UploadFileField",
    icon: "icon-fujian",
    active: false,
    name: "附件",
    props: {
      label: "附件",
      placeholder: "",
      id: "",
      required: true,
      defaultValue: [],
    },
  },
  {
    componentName: "UploadImageField",
    icon: "icon-tp",
    active: false,
    name: "图片",
    props: {
      label: "图片",
      placeholder: "",
      id: "",
      required: true,
      defaultValue: [],
    },
  },
]);

const widgetList: module[] = reactive<module[]>([]);

let IndexActive = ref(0);
const changeActive = (item: module, index: number) => {
  widgetList.forEach((model: module) => {
    model.active = false;
  });
  item.active = true;
  IndexActive.value = index;
};

const getKeyRandom = () => {
  var text = "";
  var possible =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  for (var i = 0; i < 10; i++)
    text += possible.charAt(Math.floor(Math.random() * possible.length));
  return text;
};

const cloneDefaultField = (e: module) => {
  e.props.id = getKeyRandom()
  return JSON.parse(JSON.stringify(e));
};

//拖拽开始的事件
const onStart = (e: CustomEvent) => {
  console.log(e);
  console.log("开始拖拽");
};

//拖拽结束的事件
const onEnd = (e: CustomEvent) => {
  console.log(e);
  console.log("结束拖拽");
};

//拖拽开始的事件
const onStart2 = (e: CustomEvent) => {
  console.log(e);
  console.log("开始拖拽");
};

//拖拽结束的事件
const onEnd2 = (e: CustomEvent) => {
  console.log(e);
  console.log("结束拖拽");
};
</script>


<style scoped lang="scss">
.designer {
  height: 100vh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  .header {
    z-index: 101;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: space-between;
    height: 54px;
    background: #fff;
    box-shadow: 0 1px 4px 0 rgba(17, 31, 44, 0.06);
    .tabD {
      position: absolute;
      text-align: center;
      left: 0;
      right: 0;
      .tabs {
        position: relative;
        display: inline-block;
        align-items: center;
        justify-content: center;
        .tab {
          position: relative;
          display: inline-block;
          padding: 16px 16px;
          text-decoration: none;
          font-size: 14px;
          color: rgba(17, 31, 44, 0.56);
          cursor: pointer;
          .roundNumber {
            display: inline-block;
            color: #979797;
            background: #fff;
            border: 1px solid #979797;
            width: 14px;
            height: 14px;
            line-height: 14px;
            font-size: 12px;
            margin-right: 6px;
            border-radius: 50%;
          }
        }
        .tab.active {
          color: #0091ff;
          .roundNumber {
            font-weight: normal;
            color: #fff;
            background: #0091ff;
            border: 1px solid #0091ff;
          }
        }
      }
    }
  }
  .content {
    width: 100%;
    height: 100%;
    background: #fff;
    .left {
      width: 255px;
      background: #f5f6f6;
      border-right: 1px solid #ecedef;
      position: relative;
      padding: 0 12px;
      padding-top: 12px;
      height: 100%;
      display: inline-block;
      vertical-align: top;
      .title {
        display: flex;
        align-items: center;
        padding-bottom: 8px;
        font-weight: 500;
        padding-left: 2px;
        margin-right: 6px;
        font-family: PingFangSC-Medium;
        font-size: 12px;
        color: #171a1d;
      }
      .items {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        justify-content: space-between;
        .item {
          position: relative;
          width: 120px;
          height: 40px;
          display: flex;
          align-items: center;
          margin-bottom: 12px;
          border: 1px solid transparent;
          border-radius: 8px;
          cursor: grab;
          background-color: #fff;
          .unit {
            display: flex;
            align-items: center;
            width: 100%;
            height: 100%;
            i {
              margin: 0 8px;
            }
            span {
              display: flex;
              align-items: center;
              line-height: 16px;
              font-size: 12px;
              color: #111f2c;
            }
          }
        }
        .item:hover {
          border-color: #0089ff;
          box-shadow: 0 4px 8px 0 rgba(17, 31, 44, 0.08);
        }
      }
    }
    .panl {
      position: relative;
      width: calc(100% - 580px - 48px);
      max-height: calc(100vh - 200px);
      overflow-y: auto;
      display: inline-block;
      padding: 24px 24px 100px;
      vertical-align: top;
      .drag {
        display: flex;
        align-items: center;
        justify-content: center;
        padding-top: 4px;
        font-size: 14px;
        color: rgba(23, 26, 29, 0.4);
        img {
          height: 13px;
          margin-right: 6px;
        }
      }
      .item {
        position: relative;
        margin-bottom: 10px;
        border-left: 3px solid #fff;
        transition: 0.3s all ease;
        background: #fff;
        padding: 10px;
        .btn {
          position: absolute;
          right: -50px;
          display: none;
          z-index: 100;
          width: 50px;
          text-align: center;
          i:first-child {
            margin-right: 5px;
          }
          i {
            font-size: 15px;
            cursor: pointer;
          }
        }
      }
      .itemx {
        border-left: 3px solid #fff;
        margin-bottom: 10px;
      }
      .item::before {
        content: "";
        z-index: 99;
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        background: transparent;
      }
      .item:hover,
      .itemx:hover {
        z-index: 999;
        border-left: 3px solid #bfc1c2;
        box-shadow: 0 1px 10px 0 rgba(226, 226, 226, 0.5);
        cursor: grab;
      }
      .item[active="true"],
      .itemx[active="true"] {
        z-index: 999;
        border-left: 3px solid #0089ff !important;
        box-shadow: 0 1px 10px 0 rgba(226, 226, 226, 0.5);
        border-radius: 0;
        // padding-right: 50px;
        .btn {
          display: inline;
        }
      }
    }
    .right {
      width: 299px;
      background: #fff;
      border-left: 1px solid #ecedef;
      display: inline-block;
      vertical-align: top;
      height: 100%;
      .title {
        display: flex;
        align-items: center;
        height: 48px;
        padding: 0 12px;
        border-bottom: 1px solid #f0f0f0;
        i {
          margin-right: 4px;
        }
        span {
          font-size: 14px;
          color: #191f25;
          line-height: 22px;
        }
      }
      .list {
        padding: 12px;
        .item {
          margin-bottom: 24px;
          .label {
            display: flex;
            align-items: center;
            flex-wrap: wrap;
            margin-bottom: 6px;
            span {
              margin-right: 8px;
              font-size: 14px;
              display: inline-flex;
              align-items: center;
            }
            span:last-child {
              font-size: 12px;
              color: rgba(25, 31, 37, 0.4);
              line-height: 18px;
            }
          }
        }
      }
    }
  }
}
</style>

<style lang="scss">
.drag-content {
  min-height: 400px;
  // max-height: calc(100vh - 200px);
  // overflow-y: auto;
}
// .ghost {
//   height: 2px;
//   border-left: 2px solid #0089ff !important;
//   background: #0089ff !important;
//   padding: 0 !important;
//   margin-bottom: 10px !important;
//   *,
//   .el-form-item__label {
//     display: none !important;
//   }
// }
// .ghost:hover {
//   border-left: 2px solid #0089ff !important;
// }
</style>

如果工作不忙,后续有可能会更新,仓库地址:

GITHUB仓库