Vue3 动态表单支持弹框组件

大家好,我是全栈开发鹏飞,在一家 IT 人力外包公司待了十多年的老码农,主要负责前后端架构上的维护。目前正在为某知名保险平台做核心数据中台的前端架构上的升级,正好借此机会,跟大家分享下动态表单这块我的实现心得。

一说到数据维护平台这类后台管理系统,很多前端开发小伙伴都有类似的经历,要开发许多表单页面,涉及复杂的布局或交互。手写页面维护比较麻烦,有些公司会考虑一些低代码工具,或者自己封装表单配置方式。对于低代码,往往找不到合适的产品,因为这类平台往往提供云服务,要求客户系统将数据也迁过去,且升级维护都要依赖第三方。因此较多客户技术团队会采用自己封装动态表单的形式,来一定程度上简化表单的开发。

而对于我这边负责的数据中后台的前端,自然我会考虑自研一个基于 Vue3 原生机制驱动的动态表单渲染引擎,目前已经初始完成了一个支持复杂表单内字段联动的版本。本篇主要给大家介绍下动态表单中如何配置弹框组件,这里以保险项目中一个最基本的投保人、被保人和受益人信息维护为例,来介绍表单的配置用法。

表单示例

如下是一个比较简单的投保单填写表单示例,这里咱们将表单信息分了几个部分,视图和数据模型都有所体现,且视图和数据都是同步的,同投保人 开关用于控制被保人信息的显隐藏联动,受益人列表 可增删,页面效果如下:

对如上这样表单包括后续迭代的复杂需求,咱们将使用自研的 JuanForm 表单引擎,仅仅通过 schema 的配置和扩展形式就能实现复杂的表单(甚至整个路由页面)需求。下面咱们就来看下页面配置这块。

页面配置方式

目前页面引入和使用表单引擎组件,再加上 ts 的 schema 配置即可,非常简单。

ts 复制代码
<script setup lang="ts">
import JuanForm from '@/form/JuanForm.vue'
import { formSchema } from '@/views/formSchema.ts'
</script>

<template>
  <JuanForm :schema="formSchema" />
</template>

我们着重关注配置这块,这里咱们单独用一个 ts 文件来维护下,整体的配置结构如下,咱们简单理解下这里的配置:

FormSchema 最外层为:{ ... },这里的配置对象有一个层(Layer)的概念,类似于 Vue 作用域所在的层一样。可以在层配置对象上配置 dependsOn 来对这一层数据进行监听处理,页面上渲染的字段具有行列布局,位于 children 配置项下。

栅格布局

这里的栅格布局我们设计的很灵活,一行上的列宽按照行总列数的占比来计算,总列数做到行级可配置,默认为 16 栅格,也可以自行指定,比如:15、18,列(字段)上除了指定具体列数的占比,也可以设置为 auto 来自动分配剩余的宽度,而一行上都按数字来分配后发现超出栅格总列数,则自动换到下一行,来满足流式布局。

schema 结构

继续回到配置,items 中可以包含具体配置项类型,包括:与字段对应的基础控件类型、Group 组类型、Dialog 弹框类型甚至用户自定义扩展等等。

ts 复制代码
import ...

export const formSchema: FormSchema = {
  children: [
    {
      items: [
        {
          prop: 'applicant',
          label: '投保人',
          type: 'Group',
          col: 'auto',
          children: [
            {
              cols: 18,
              items: [
                {prop: 'name'...},
                {prop: 'phone'...},
                {prop: 'age'...},
              ],
            },
            {
              items: [
                {prop: 'address'...},
              ],
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          prop: 'insured',
          label: '被保人',
          type: 'Group',
          col: 'auto',
          children: [
            {
              cols: 18,
              items: [
                {prop: 'sameAsApplicant'...},
                {prop: 'name'...},
                {prop: 'age'...},
              ],
            },
          ],
        },
      ],
    },
    {
      items: [
        {
          prop: 'beneficiaries',
          label: '受益人',
          type: 'List',
          col: 'auto',
          key: 'id',
          children: [ ... ],
        },
        {label: '添加受益人'...},
      ],
    },
  ],
}

内置 Dialog 类型

本章主要给大家介绍弹框类型,其他特性的配置后续会有单独的章节来做介绍 。这在市面上一些开源的表单引擎中,schema 多半只覆盖输入控件和校验,弹框通常要在页面上再包一层组件,和表单配置是分开维护的。咱们这边把 Dialog 做成和 Group、List 同级的配置项,直接写在行的 items 里,打开、关闭也由这一层来负责,不必再单独做一套弹框页面。

封装形式

先了解下内置的 Dialog 组件类型是如何封装的,这里咱们提供了一个类似于 FormKit表单引擎 的 createInput 方法来封装任何用于表单配置的控件类型,不管是组件的模板还是属性、方法,咱们都提供了类的封装、继承、扩展方式,如下是 Dialog 控件类型的封装代码:

ts 复制代码
createInput('Dialog', {
  component: defineComponent({
    props: {
      field: { type: Object as PropType<DialogConfig>, required: true },
      layer: { type: Object as PropType<JuanLayer>, required: true },
    },
    render() {
      return h(
        ElDialog,
        {
          modelValue: true,
          title: this.field.label,
          onClose: () => this.close(),
        },
        this.paint({
          default: () => h(JuanLayerView, { layer: this.layer, rows: this.field.children }),
          footer: () =>
            h(
              ElButton,
              {
                type: 'primary',
                nativeType: 'button',
                onClick: () => {
                  this.flushData()
                  this.close()
                },
              },
              () => '确定',
            ),
        }),
      )
    },
  }),
  methods: {
    close() {
      this.layer.closeDialog(this.field.name)
    },
    flushData() {
      this.layer.flushDataDialog(this.field.name)
    },
  },
  create() {
    console.log('init')
  },
})

这里可以看到,视图这块咱们用的 Vue 的 defineComponent 形式来构造组件,模板采用 render 与 h 渲染函数,UI 直接使用 ElementPlus 的 ElDialog 组件。props 上声明了 field 和 layer,配置和这一层的数据都从这里取。插槽通过 this.paint({ default, footer }) 收集进来,default 里用 JuanLayerView 去渲染弹框自己的 children 所对应的 body 内容,footer 里是 确定 按钮,点击后先 flushData 把弹框中的数据修改同步到目标层,再 close 关闭弹框。

整体定义形式类似 Vue 的 SFC(单文件组件)结构,方法写在外面的 methods 里,初始化写在 create 里,this 上能直接调用这些方法,这就是类的形式。模板、插槽、属性、方法都在 createInput 中封装,后续扩展自己的控件类型也是这一套语法糖配置,对于存在的类型,通过继承机制,可以实现模板插槽、属性以及方法的复用、覆盖和灵活扩展。

弹框示例

再回到表单示例,现在咱们要实现投保人和受益人信息的弹框修改方式,对这样的需求无需手写页面接入弹层以及处理数据同步,咱们的 JuanForm 表单引擎已内置了这块支持,仅需页面表单配置做如下更新:

ts 复制代码
import ...

export const formSchema: FormSchema = {
  children: [
    {
      items: [
        {
          ...
          children: [
            ...
            {
              items: [
                {
                  label: '修改投保人',
                  type: 'Button',
                  col: 6,
                  props: {
                    nativeType: 'button',
                    onClick: (scope: ScopeState) => {
                      scope.$open!('editApplicant')
                    },
                  },
                },
              ],
            },
          ],
        },
        {
          name: 'editApplicant',
          type: 'Dialog',
          label: '修改投保人',
          children: [
            {
              cols: 18,
              items: [
                {prop: 'name'...},
                {prop: 'phone'...},
                {prop: 'age'...},
              ],
            },
            {
              items: [
                {prop: 'address'...},
              ],
            },
          ],
        },
      ],
    },
    ...
    {
      items: [
        {
          ...
          children: [
            {
              items: [
                ...
                {
                  label: '修改',
                  type: 'Button',
                  col: 2,
                  props: {
                    nativeType: 'button',
                    onClick: (scope: ScopeState) => {
                      scope.$open!('editBeneficiary')
                    },
                  },
                },
                ...
              ],
            },
          ],
        },
        ...
        {
          name: 'editBeneficiary',
          type: 'Dialog',
          label: '修改受益人',
          children: [
            {
              cols: 18,
              items: [
                {prop: 'name'...},
                {prop: 'relation'...},
                {prop: 'share'...},
              ],
            },
          ],
        },
      ],
    },
  ],
}

按照上面配置更新 schema 后,页面效果立马显现了,一起来看下,修改按钮都渲染出来了:

点击 修改投保人 按钮,会把对应 group 中的数据刷到弹框的控件中,注意这仅是拷贝,点 确定 关闭弹层,数据才会写回来

同样,修改受益人信息

再回到 schema 配置。Dialog 和 Group 差不多,都是 label 加上 children,弹框里的字段仍然按行来写。不同的是弹框不用 prop 去绑数据,而是用 name 来标识,页面上通过 $open 传入这个名字即可打开。这里调用弹框的按钮和要写回的数据就在同一层,所以 $open 不必再传第二个参数来指定要写回哪一层。

扩展 Dialog

除了内置弹框组件这种默认编辑并刷回数据的通用功能,还可以由用户按照实际需要,用同样的 createInput 去继承 Dialog,做出自己的弹框类型。比如投保人信息编辑这块支持把一段文本识别成姓名、手机和地址,这就不是内置弹框直接能覆盖的场景了,咱们扩展一个 Ocr 类型就可以:

ts 复制代码
createInput('Ocr', {
  extends: 'Dialog',
  slots: {
    default: {
      $el: 'div',
      attrs: { class: 'w-full' },
      children: [
        [
          {
            prop: 'ocrText',
            label: '粘贴文本',
            type: 'Textarea',
            col: 'auto',
            props: { placeholder: '粘贴姓名、手机、地址,例如:张三 13800138000 北京市朝阳区' },
          },
        ],
      ],
    },
    footer: {
      $el: 'div',
      children: [
        [
          {
            label: '清空识别',
            type: 'Button',
            col: 6,
            props: {
              nativeType: 'button',
              onClick: (scope: ScopeState) => {
                scope.ocrText = ''
                scope.name = ''
                scope.phone = ''
                scope.address = ''
              },
            },
          },
        ],
        { $base: 'footer' },
      ],
    },
  },
  methods: {
    flushData() {
      const text = (this.state.ocrText as string).trim()
      const phone = text.match(/1\d{10}/)?.[0]
      const parts = (phone ? text.replace(phone, ' ') : text).trim().split(/\s+/)
      this.state.name = parts[0]
      this.state.phone = phone
      this.state.address = parts.slice(1).join(' ')
      this.parent.flushData!()
    },
  },
})

可以看到,Ocr 通过 extends: 'Dialog' 继承了内置弹框,模板这块用 slots 来覆盖。主体对应 default 插槽,这里整段换成识别用的文本框。底部对应 footer 插槽,咱们自己加了 清空识别 按钮,同时又用 $base: 'footer' 把父类型原来的确定按钮接回来,自己扩展的按钮和原先的底部拼在一起,这就是灵活组装,不是把底部整段完全覆盖。

方法上也是子类继承父类。flushData 在子类里重写,先把文本拆到姓名、手机、地址上,再通过 this.parent.flushData() 调用父类原来的写回,识别逻辑放在子类,刷回仍然走父类,这就实现了子类扩展父类方法的目的。

页面上的用法和内置弹框一样,给这个扩展类型起个 name,用 $open 打开。弹框体在扩展里已经带好了,children 可以先空着:

ts 复制代码
{
  items: [
    {
      label: '修改投保人',
      ...
    },
    {
      label: '识别填写',
      type: 'Button',
      col: 6,
      props: {
        nativeType: 'button',
        onClick: (scope: ScopeState) => {
          scope.$open!('ocr')
        },
      },
    },
  ],
},
{
  name: 'ocr',
  type: 'Ocr',
  label: '识别填写',
  children: [],
},

按钮仍然写在投保人这一层里,所以 $open 同样只传弹框的名字。这样点 识别填写,贴上文本再确定,投保人对应字段就会被填上。

写在最后

通过以上的实践,咱们再来总结和展望动态表单的自研,核心是配置形式和扩展方式。尤其弹框这一能力,很多表单渲染组件尚不具备,咱们已经具备,并且有很好的扩展机制。弹框能够配置、能够扩展之后,表单就不再停留于字段排列,复杂交互也可以纳入同一套配置之中。

有了这种能力,就可以很容易地封装和扩展其它复杂的、可定制的组件,从而完善表单引擎,更好地服务于复杂表单,乃至整个路由页面的配置能力。后续无论是新增业务控件,还是将整个页面交由配置来表达,都建立在同一套能力之上,表单引擎才能逐步完善。

再借助于低代码拖拽以及 AI 生成平台,进一步往无码方向推进。配置和扩展使能力保留在自研引擎之中,拖拽和 AI 才具备编排与生成的基础;无码并不是更换一套云平台,而是让自研动态表单具备生成和拖拽的条件,从而更好地支撑数据中后台中数量多、结构复杂的页面。

相关推荐
溪语流沙1 小时前
【Web全栈进阶】前端对接新契约:信封解包 + 统一错误处理
前端·vue.js·python·状态模式·fastapi
Q26433650231 小时前
【有源码】基于SpringBoot+Vue的居民健康体检与慢病干预管理系统-面向智慧医疗的体检预约与健康知识服务平台
java·vue.js·spring boot·mysql·毕业设计·javaweb·课程设计
国服第二切图仔1 小时前
Chrome 插件开发实战指南:从入门到发布
前端·chrome
鬼手点金2 小时前
ReAct 循环
linux·服务器·前端·javascript·react.js·前端框架
鬼手点金2 小时前
上下文:Agent 的眼睛
java·前端·人工智能·git·深度学习
雪芽蓝域zzs2 小时前
Vue导入方法三种方式对比
前端·javascript·vue.js
定栓2 小时前
Webpack迁到Vite第八节:错误排查实录
前端·webpack·devops
haluhalu.2 小时前
深入理解 AI Agent(1.2):Harness 工程——模型之外的竞争力
前端·数据结构·人工智能·python·langchain·个人开发
data analyse 4562 小时前
服务端统计时区切换的日期归并
大数据·前端·数据库·人工智能·数据分析