带你实现一个表单系统-具体实现篇

承接接上篇需求和实现思路的分析,本篇开始将讲述如何具体实现。

目录结构

组件/文件 作用
index.vue 主页
render.vue 中间区域用来渲染表单
config.vue 右边配置区域
data 表单项配置数据
config文件夹 选中后展示的配置组件
control文件夹 render.vue中用到的表单控件
logic文件夹 配置逻辑相关的内容

如果不理解,往后看就可以。

布局的实现

两侧固定,中间自适应的布局,是常见的三栏布局,实现的方式有多种,选择最简单的flex来实现,在index.vue中,完成三栏布局。

vue 复制代码
<div class="container">
 <!-- 左侧 -->
     <section class="left-box">
     </section>
 <!-- 中间 -->
     <section class="center-box">
     </section>
 <!-- 右侧 -->
     <section class="right-box">
     </section>
<div>

.container {
  display: flex;
  width: 100%;
  height: 100%;
  .left-box {
    width: 328px;
    height: 100%;
  }
  .center-box {
    flex: 1;
    height: 100%;
  }
  .right-box {
    width: 300px;
    height: 100%;
  }
}

在data文件夹中定义好你的表单项数据

js 复制代码
import { generateUUID } from "@/utils/tool";
// 相关组件的配置
export const formComponent = [
  {
    name: '文本',
    components: [
      {
        title: '单行文本', // 题目名称
        configName: '文本',
        type: 'text', // 类型
        icon: 'single-text',// 图标
        isAllowedConfig:true, // 是否允许配置
      },
      {
        title: '多行文本',
        configName: '文本',
        type: 'textArea',
        icon: 'multi-text',
        isAllowedConfig:true,
      },
    ]
  },
  {
    name: '数值',
    components: [
      {
        title: '数字',
        configName: '数值',
        type: 'number',
        icon: 'number',
        isAllowedConfig:true,
      },
    ]
  },
  {
    name: '选项',
    components: [
      {
        title: '单选',
        configName: '选项',
        type: 'radio',
        icon: 'circle-check',
        optionList: [
          {
            optionId: generateUUID(),
            optionName: '选项1'
          },
          {
            optionId: generateUUID(),
            optionName: '选项2'
          },
        ],
        isAllowedConfig:true,
      },
      {
        title: '下拉单选',
        configName: '选项',
        type: 'selectInput',
        icon: 'circle-check',
        optionList: [
          {
            optionId: generateUUID(),
            optionName: '选项1'
          },
          {
            optionId: generateUUID(),
            optionName: '选项2'
          },
        ],
        isAllowedConfig:true // 是否允许配置
      },
      {
        title: '多选',
        configName: '选项',
        type: 'checkBox',
        icon: 'more-check',
        optionList: [
          {
            optionId: generateUUID(),
            optionName: '选项1'
          },
          {
            optionId: generateUUID(),
            optionName: '选项2'
          },
        ],
        isAllowedConfig:true,
      }
    ]
  },
  {
    name: '日期',
    components: [
      {
        title: '日期',
        configName: '日期',
        type: 'date',
        icon: 'calendar',
        isAllowedConfig:true,
      },
      {
        title: '日期区间',
        configName: '日期',
        type: 'dateRange',
        icon: 'calendar',
        isAllowedConfig:true,
      }
    ]
  },
  {
    name: '上传文件',
    components: [
      {
        title: '图片上传',
        configName: '上传文件',
        type: 'imageUpload',
        icon: 'file',
        isAllowedConfig:true,
      }
    ]
  }
]
字段 含义
title 表单项默认名称
configName 配置显示的分类名称
type 表单项类型
icon 图标名称
isAllowedConfig 是否允许配置
optionList 选项列表

本人项目中,就实现了一些常用的集中表单控件:单行文本、多行文本、多选、单选、数值,日期,日期范围、图片上传。人力和时间关系,没法做到像雪梨表单那样多样,并且有些地方一开始设计的时候没有研究充分导致数据定义上可能存在不合理的地方,欢迎大家提出建议。

完成index.vue组件左侧区域的表单项渲染

js 复制代码
   <!-- 左侧 -->
<section class="left-box">
<div class="components" v-for="(group, i) in formComponent" :key="i">
            <p class="title-name">{{ group.name }}</p>
            <ul>
             <draggable class="drag-components" :list="group.components"               :options="{ sort: false }"
                :item-key="(item) => item.name" :group="{ name: 'form', pull: 'clone', put: false }"
                @start="isStart = true" @end="isStart = false" :clone="clone">
                <template #item="{ element: cp }">
                  <li class="component-item flex f-ac" @click="addFormItem(cp)" :key="cp.name">
                    <div class="flex f-ac">
                      <svg-icon :name="cp.icon" size="1rem" />
                      <span class="ml-5 title">{{ cp.title }}</span>
                    </div>
                    <el-icon>
                      <plus />
                    </el-icon>
                  </li>
                </template>
              </draggable>
            </ul>
          </div>
  </section>
js 复制代码
import { formComponent } from "./data/controlData.ts";
import draggable from 'vuedraggable'

const forms = ref([]),// 表单数组
const selectedFormItem = ref({}),// 选中的表单项
const selectedFormIndex = ref(0),// 选中表单项下标,进来默认是0

// 获得一个随机唯一id
const getId = () => {
  return (
    "field" +
    (Math.floor(Math.random() * (99999 - 10000)) + 10000).toString() +
    new Date().getTime().toString().substring(5)
  );
}
// 拖拽到中间区域就克隆一项出来
const clone = (obj) => {
  obj.id = getId();
  console.log(obj)
  return JSON.parse(JSON.stringify(obj));
}
// 校验配置
const validateFormProps = () => {
  // 返回的是个Promise
  return formConfigRef.value
    ? formConfigRef.value.validateFun()
    : Promise.resolve();
}
// 点击新增一项到中间区域
const addFormItem = (cp) => {
  let comp = JSON.parse(JSON.stringify(cp));
  let temp = { ...comp, id: getId() };

  validateFormProps()
    .then((res) => {
      forms.value.push(temp);
      selectedFormIndex.value = forms.value.length;
      selectedFormItem.value = temp;
      // 获取新增问题的DOM元素
      nextTick(() => {
        itemRefs.value[selectedFormItem.value.id].scrollIntoView({ behavior: 'smooth', block: 'end' });
      });
    })
    .catch((err) => {
      msg.err(err.msg, 1000);
    });
}

引入controlData中的数据进行渲染,这里的重点是vuedraggable中文文档。 拖拽的功能就是由它来实现的。特别是这个group配置项,在上面的代码中pull设置了一个clone函数,put设置为false不允许拖入到左侧。
forms就是第一篇文章中提到的雪梨表单那个questions,存放所有表单项的数据,整个表单系统的功能都是对这份数据的维护。

clone会在左边的表单项拖拽到中间区域的group中执行。 addFormItem是点击表单项后在中间区域添加一项。做了些增加体验的交互,新增后的项会scrollIntoView到该项的位置,并且自动选中该项,即给selectedFormIndex还有selectedFormItem赋值。

js 复制代码
//设置方式一,直接设置组名 group:'itxst' 
//设置方式,object,也可以通过自定义函数function实现复杂的逻辑 
group:{ name:'itxst',//组名为itxst 
pull: true|false| 'clone'|array|function,//是否允许拖出当前组   
put:true|false|array|function,//是否允许拖入当前组 
}

最终呈现的效果如下图:

在control文件夹中定义好你的表单项组件

其中最简单的对应单行文本的Text.vue组件的代码如下,至于为什么这么写,先放着,往后面看。

js 复制代码
<template>
  <el-form-item>
    <el-input size="default" clearable :placeholder="config.placeholder" show-word-limit :maxlength="config.maxlength"
      disabled />
  </el-form-item>
</template>
<script setup>
// 把表单配置传过来渲染
const props = defineProps({
  config: {
    type: Object,
    default: () => {
      return {}
    }
  }
});

</script>

完成render.vue组件

js 复制代码
<template>
  <component :is="dynamicComponent" :config="config"></component>
</template>

<script setup>
import { computed } from 'vue';
import Text from "./control/Text";
import TextArea from "./control/TextArea.vue"
import Number from "./control/Number.vue";
import Radio from "./control/Radio.vue"
import SelectInput from "./control/SelectInput.vue";
import CheckBox from "./control/CheckBox.vue";
import Date from "./control/Date.vue"
import DateRange from "./control/DateRange.vue"
import ImageUpload from "./control/ImageUpload.vue"

// 选中的表单项
const props = defineProps({
  config: {
    type: Object,
    default: () => ({}),
  },
})

// 创建一个映射对象来匹配 config.type 和对应的组件
const componentMap = {
  text: Text,
  textArea: TextArea,
  number: Number,
  radio: Radio,
  selectInput: SelectInput,
  checkBox: CheckBox,
  date: Date,
  dateRange: DateRange,
  imageUpload: ImageUpload
};

// 计算属性,根据当前 config.type 返回相应的组件
const dynamicComponent = computed(() => {
  return componentMap[props.config.type] || null;
});
</script>

<style lang="scss" scoped></style>

在render组件中,props传入的其实就是之前在controlData中定义的其中一种表单项数据。根据其中的type那么渲染对应的在control中引入的组件。

完成index.vue组件中间区域的表单项渲染

js 复制代码
  <!-- 中间 -->
  <section class="center-box">
   <draggable class="drag-from" :list="forms" group="form" :options="{
            animation: 300,
            chosenClass: 'choose',
            sort: true,
          }" :item-key="(item) => item.id" @start="drag = true" @end="drag = false">
            <template #item="{ element: cp, index: id }">
              <div class='form-item'
                :class="[(selectedFormItem && selectedFormItem.id) === cp.id ? 'cur-select-item' : '']"
                @click="selectFormItem(cp)" :ref="(el) => formItemRefs(el, cp.id)">
                <div class="form-header">
                  <p>
                    <span v-if="cp && cp.required">*</span>{{ getFormItemOrder(id + 1, id) + ".&nbsp;" + cp.title }}
                  </p>
                  <div class="option" @click.stop="delFormItem(cp, id)" v-if="cp.isAllowedConfig">
                    <el-icon>
                      <DeleteFilled />
                    </el-icon>
                  </div>
                  <formRender :config="cp"></formRender>
                </div>
              </div>
            </template>
          </draggable>
</section>
js 复制代码
import formRender from "./render.vue";
// 选中某表单项
const selectFormItem = (cp, index) => {
  validateFormProps()
    .then((res) => {
      selectedFormItem.value = cp
      selectedFormIndex.value = index
    })
    .catch((err) => {
      msg.err(err.msg, 1000);
    });

}
// 删除某表单项
onst delFormItem = (cp, index) => {
  window.$confirm(
    "删除组件将会连带删除包含该组件的条件以及相关设置,是否继续?",
    "提示",
    {
      confirmButtonText: "确 定",
      cancelButtonText: "取 消",
      type: "warning",
    }
  ).then(() => {
    forms.value.splice(index, 1);
    if (cp.id === selectedFormItem.value.id) {
      selectedFormItem.value = {};
    }
  });
}

中间区域的group写法不一样,其实意义是一样的,这么写相当于把name提取出来赋值给group,剩下的配置都放在options,其中 sort: true就是允许拖拽排序。为什么这样写?可能只是想体验一下不同写法罢了根据selectedFormItem 以及selectedFormItem设置选中的样式,这两个变量之前左侧区域就提过。formRender组件,现在回过头去看render.vue组件,应该就能明白了,就是用来渲染forms中的表单项,:config="cp"传入的就是对应的表单项,根据type区分,是单选就渲染单选,单行文本就渲染单行文本,都是和control文件夹中定义好组件一一对应的。

最终呈现的效果如下图:

在config文件夹中定义好配置组件

其中单选对应的配置组件的代码如下,config文件夹中的我称为表单项配置组件,如果你看明白之前control中的表单项组件以及render.vue组件之间的关系,那么表单项配置组件和config.vue的实现思路是一样的,但是又稍微有点区别,先接着看下去吧。

js 复制代码
<template>
  <el-form-item label="默认提示">
    <el-input size="default" v-model="form.placeholder" placeholder="请设置提示语" />
  </el-form-item>
  <el-form label-position="top">
    <el-form-item label="选项设置" class="optionList">
      <div slot="label" class="option-item-label flex">
        <span>选项</span>
        <el-button size="default" @click="onAdd()" class="mr-7" type="primary">新增选项</el-button>
      </div>
      <draggable :list="form.optionList" group="option" :optionList="{ animation: 300, sort: true }"
        handler=".el-icon-rank" :item-key="(item, index) => index">
        <template #item="{ element: cp, index }">
          <div class="option-item flex f-ac">
            <el-icon :size="18" class="cursor mr-5">
              <Rank />
            </el-icon>
            <el-input v-model="form.optionList[index].optionName" size="default" placeholder="请设置选项值" clearable>
              <template #append>
                <el-button type="danger" size="default" @click="form.optionList.splice(index, 1)">删除</el-button>
              </template>
            </el-input>
          </div>
        </template>
      </draggable>
    </el-form-item>
  </el-form>

</template>

<script setup>
import draggable from "vuedraggable";
import { Rank } from "@element-plus/icons-vue";
import { generateUUID } from "@/utils/tool";

// 把表单传过来配置
const props = defineProps({
  form: {
    type: Object,
    default: () => {
      return {}
    }
  }
});

const onAdd = () => {
  props.form.optionList.push({
    optionId: generateUUID(),
    optionName: "新选项"
  })
}

</script>

完成config.vue组件

js 复制代码
<template>
  <div v-if="form">
    <el-form v-if="form.isAllowedConfig" label-width="90px" label-position="top" ref="formRef" :model="form"
             :rules="{ title: [{ required: true, message: '标题不能为空', trigger: 'blur' }] }">

      <el-form-item v-if="form.title != undefined" label="标题:" prop="title">
        <el-input size="default" clearable v-model="form.title" show-word-limit maxlength="30" />
      </el-form-item>

      <!-- 使用动态组件进行配置 -->
      <component :is="componentName" v-if="componentName" :form="form"></component>

      <!-- 必填选项 -->
      <el-form-item label="" label-position="right" class="required-box">
        <el-switch v-model="form.required"></el-switch>
        <div class="label-text-box" style="margin-left: 5px">必填</div>
      </el-form-item>

      <!-- 逻辑设置,仅限单选和多选 -->
      <el-form-item v-if="['selectInput', 'radio'].includes(form.type)" label="逻辑设置">
        <div class="logic-box flex between">
          <div>已有{{ form.logicList.length }}条规则</div>
          <el-button size="default" @click="onShowLogicDrawer" class="mr-7" type="primary">去设置</el-button>
        </div>
      </el-form-item>

    </el-form>
    <el-empty v-else description="当前组件不支持配置"></el-empty>
    <Drawer v-model:isShowDrawer="isShowDrawer" />
  </div>
</template>

<script setup>
import { inject, computed, ref } from 'vue';
import textConfig from "./config/textConfig.vue";
import textAreaConfig from "./config/textAreaConfig.vue";
import numberConfig from "./config/numberConfig.vue";
import selectConfig from "./config/selectConfig.vue";
import dateConfig from "./config/dateConfig.vue";
import dateRangeConfig from "./config/dateRangeConfig.vue";
import Drawer from "./logic/drawer.vue";

const selectFormItemFn = inject("selectedFormItemFn");

const isShowDrawer = ref(false);
const formRef = ref(null);

const form = computed(() => {
  const currentForm = selectFormItemFn.get();
  if (['selectInput', 'radio'].includes(currentForm.type)) {
    currentForm.logicList = currentForm.logicList || [];
  }
  return currentForm;
});

// 组件名称映射关系
const componentMappings = {
  text: textConfig,
  textArea: textAreaConfig,
  number: numberConfig,
  selectInput: selectConfig,
  checkBox: selectConfig,
  radio: selectConfig,
  date: dateConfig,
  dateRange: dateRangeConfig
};

// 根据form.type获取对应的组件名称
const componentName = computed(() => componentMappings[form.value.type] || null);

const onShowLogicDrawer = () => {
  isShowDrawer.value = true;
};

const validateFun = () => new Promise((resolve, reject) => {
  if (!formRef.value) {
    resolve({ status: true, msg: "通过" });
  } else {
    formRef.value.validate(valid => {
      valid ? resolve({ status: true, msg: "通过" }) : reject({ status: false, msg: "标题不能为空" });
    });
  }
});

defineExpose({ validateFun });
</script>

其中,type为checkBox多选,下拉单选selectInput、单选radio都是用同一个配置组件selectConfig,因为对这些表单项的配置几乎都是选项的增删改查。

完成index.vue组件右侧区域的表单项配置组件渲染

js 复制代码
 <!-- 右侧 -->
    <section class="right-box">
      <div class="r-title" v-if="selectedFormItem">
        <span>{{ selectedFormItem.configName }}</span>
      </div>
      <div v-if="!Object.keys(selectedFormItem).length || forms.length === 0" class="r-tip">
        选中控件后在这里进行编辑
      </div>
      <div class="t-content" v-else>
        <formConfig ref="formConfigRef"></formConfig>
      </div>
    </section>
js 复制代码
import formConfig from "./config.vue"
import { provide } from 'vue'

const  formConfigRef = ref(null),
// 值要传递给formConfig组件进行配置
provide('selectedFormItemFn', {
  get: () => {
    return selectedFormItem.value
  }
})
provide('formsFn', {
  get: () => {
    return forms.value
  }
})

使用provide将当前选中的表单项selectedFormItem传递给config组件。至此一整个表单系统已经完成了,最后只需要将forms保存到数据库。

最终呈现的效果如下图:

最后总结一下

  1. 准备一份controlData那样的数据,渲染在左侧。
  2. 定义一个数组如(forms)存放从左边拖拽或者点击新增的表单项
  3. 准备一份和controlData数据一一对应的表单项组件,在中间区域渲染对应的表单项
  4. 准备一份能和controlData中表单项对应的配置组件,在右侧区域渲染和配置
  5. 得到最终的一份数据forms。

我的需求中与雪梨表单不一样的地方是我的需求是拿到这份数据到自己另外一个移动端的项目中进行渲染,然后收集用户的信息。雪梨表单的表单配置信息收集是一体的,用同一套组件ant-design,只不过做了移动端的适配。怎么选择,自然是按照实际业务场景来。下一篇文章将会带大家实现一下逻辑表单。

相关推荐
信创DevOps先锋1 分钟前
本土化突围:Gitee如何重新定义企业级项目管理工具价值
前端·gitee·jquery
圣光SG13 分钟前
Java类与对象及面向对象基础核心详细笔记
java·前端·数据库
Jinuss22 分钟前
源码分析之React中的useImperativeHandle
开发语言·前端·javascript
ZC跨境爬虫34 分钟前
CSS核心知识点与定位实战全解析(结合Playwright爬虫案例)
前端·css·爬虫
Jinuss36 分钟前
源码分析之React中的forwardRef解读
前端·javascript·react.js
mengsi5538 分钟前
Antigravity IDE 在浏览器上 verify 成功但本地 IDE 没反应 “开启Tun依然无济于事” —— 解决方案
前端·ide·chrome·antigravity
Можно1 小时前
pages.json 和 manifest.json 有什么作用?uni-app 核心配置文件详解
前端·小程序·uni-app
hzhsec1 小时前
钓鱼邮件分析与排查
服务器·前端·安全·web安全·钓鱼邮件
#做一个清醒的人1 小时前
Electron 保活方案:用子进程彻底解决原生插件崩溃问题
前端·electron·node.js