面向 schema 编程,轻松搞定 Element 虚拟化表格复杂应用场景

大家好,我是全栈小卷。前面写过一篇JuanForm vs FormCreate!Vue3 表单引擎联动配置 PK!。FormCreate 面向独立的小型表单,覆盖 PC 和移动端多种 UI;JuanForm 面向 PC 中后台的页面级配置,UI 上先整合 Element Plus。

中后台页面用 schema 来配,开发人员和 AI 只需要配置说明,不必再处理 vue 和 UI 组件的细节。有渲染引擎之后,复杂页面可以按 schema 来写。

JuanForm 对齐 vue 的响应式和监听,兼顾大表单和复杂页面的性能,并保留扩展口。第三方 UI 的特性可以接到引擎上,再用 schema 暴露出去。

本篇用 Element Plus 的虚拟化表格 TableV2 做说明。虚拟滚动、不定高、树形展开、自定义单元格在 TableV2 上要分开配置,业务还要叠加弹框和增删。下面用保险受益人列表,把这些能力写成 schema。数据量更大的字典表同样适用。

基本表格

页面引入:

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

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

vTableSchema.ts 先写到表格能显示。虚拟表格本身也是字段,type 写成 VTable,列写在 columns 里,每一列仍是 prop、label、type。用按钮模拟一次数据请求,往 contacts 写入 5000 条记录。

ts 复制代码
import '@/form/builtin.ts'
import type { FormSchema, ScopeState } from '@/form/types.ts'

const genderOptions = [
  { label: '男', value: '0' },
  { label: '女', value: '1' },
]

export const vTableSchema: FormSchema = {
  children: [
    {
      items: [
        {
          label: '加载数据',
          type: 'Button',
          col: 3,
          props: {
            nativeType: 'button',
            onClick: {
              handle: (scope: ScopeState) => {
                const names = ['张三', '李四', '王五']
                scope.contacts = Array.from({ length: 5000 }, (_, i) => ({
                  id: `g${i + 1}`,
                  name: names[i % 3],
                  phone: String(13800000000 + i + 1),
                  gender: i % 2 === 0 ? '0' : '1',
                  age: 20 + (i % 40),
                }))
              },
            },
          },
        },
      ],
    },
    {
      items: [
        {
          prop: 'contacts',
          label: '受益人',
          type: 'VTable',
          key: 'id',
          columns: [
            {
              prop: 'id',
              label: 'id',
              type: 'Text',
              width: 120,
            },
            {
              prop: 'name',
              label: '姓名',
              type: 'Input',
              width: 160,
            },
            {
              prop: 'gender',
              label: '性别',
              type: 'Radio',
              width: 120,
              props: { options: genderOptions },
            },
            {
              prop: 'age',
              label: '年龄',
              type: 'Input',
              width: 80,
              props: { type: 'number' },
            },
            {
              prop: 'phone',
              label: '电话',
              type: 'Input',
              width: 180,
            },
          ],
        },
      ],
    },
  ],
}

点加载数据,滚动列表,视口里始终是固定数量的行:

虚拟列表只渲染视口里的 DOM。滚动时节点数量不变,内容换成新行。在单元格里改内容,下面的数据模型跟着更新:

分组表头

数据不用改,只改 columns。组列只有 label 和 children,不会进入数据模型;真正进入数据模型的,还是最里层带 prop 的那一列。children 里面可以再嵌套组列,支持任意层级,下面一级、二级、三级只是演示。各列其余配置与上一节相同,这里只写出结构。

一级嵌套

把性别、年龄、电话放到个人信息下面,表头会多一行:

ts 复制代码
columns: [
  { prop: 'id' },
  { prop: 'name' },
  {
    label: '个人信息',
    children: [
      { prop: 'gender' },
      { prop: 'age' },
      { prop: 'phone' },
    ],
  },
]

表头变为:个人信息下面是性别、年龄、电话。

二级嵌套

接着在个人信息里面加一层基本,性别、年龄放到基本下面,电话仍在个人信息下面:

ts 复制代码
columns: [
  { prop: 'id' },
  { prop: 'name' },
  {
    label: '个人信息',
    children: [
      {
        label: '基本',
        children: [
          { prop: 'gender' },
          { prop: 'age' },
        ],
      },
      { prop: 'phone' },
    ],
  },
]

表头变为:基本下面是性别、年龄,电话仍在个人信息下面。

三级嵌套

第三层按两两组合:把姓名和性别放到姓名这一组里,一组里不要只放一个字段。写法仍是 label 加 children,没有新的配置项。再加层继续嵌套 children,没有层级上限:

ts 复制代码
columns: [
  { prop: 'id' },
  {
    label: '个人信息',
    children: [
      {
        label: '基本',
        children: [
          {
            label: '姓名',
            children: [
              { prop: 'name' },
              { prop: 'gender' },
            ],
          },
          { prop: 'age' },
        ],
      },
      { prop: 'phone' },
    ],
  },
]

表头变为:姓名和性别在一组里,年龄单独一列。

columns 里的 width 是各列在版面里的分配比例,表格会按当前窗口把各列拉满。窗口变窄以后,单元格可以折行,行高跟着内容变高。虚拟滚动按不定高计算每一行的高度。拉窄窗口可以看到折行和行高:

隐藏列

列上写 hidden,和表单字段是同一套写法。函数返回 true 时,这一列不再显示,数据模型里也不再保留这个字段。

年龄列读的是当前行的数据,开关 hideAge 在表单上,所以要写成 scope.$parent?.hideAge:

ts 复制代码
{
  prop: 'hideAge',
  label: '隐藏年龄',
  type: 'Switch',
}

{
  prop: 'age',
  hidden: (scope) => scope.$parent?.hideAge === true,
}

打开开关以后,年龄从列头到数据列一起去掉,数据模型里的 age 也不再出现。年龄原来和姓名共同放在基本下面;少了一列,基本只剩下一组,不能再组成一组,基本这一层组头拆掉,个人信息下面直接是姓名和电话。有没有数据,列头都按这个结果变化。打开隐藏年龄,看列头和数据模型:

自定义列

操作列用来做增删改。JuanForm 也支持在单元格里自定义 HTML 和组件,下面用姓名拆分做例子。

姓名拆分编辑列

前面姓名列是默认的可编辑列,配置只有 prop、label、type:

ts 复制代码
{ prop: 'name', label: '姓名', type: 'Input' }

单元格里是一个输入框,值和数据模型里的 name 直接对应。

自定义编辑列仍是这一列 name,prop、label、type 都留着,再写一项 custom。有了 custom,单元格不再渲染外层那个 Input,改为渲染 custom.items 里的内容。数据模型仍然只认 name;items 里的名、姓只留在单元格内层,不会变成行数据上的独立字段。

单元格里要两个输入框:左边名、右边姓,中间用 Html 放分隔线。数据是整段 name 加载进来的,两个输入框不会自动填。先加一段 dependsOn:读这一行的 name,按空格拆开,写到 firstName 和 lastName。加载完成后,两个输入框才有内容。

ts 复制代码
{
  prop: 'name',
  label: '姓名',
  type: 'Input',
  custom: {
    items: [
      {
        type: 'Html',
        el: 'div',
        children: [
          {
            items: [
              { prop: 'firstName', label: '名', type: 'Input' },
              { type: 'Html', el: 'span', text: '-' },
              { prop: 'lastName', label: '姓', type: 'Input' },
            ],
          },
        ],
      },
    ],
    dependsOn: {
      handle: (scope) => {
        const name = `${scope.$parent?.name ?? ''}`
        const i = name.indexOf(' ')
        scope.firstName = i < 0 ? name : name.slice(0, i)
        scope.lastName = i < 0 ? '' : name.slice(i + 1)
      },
    },
  },
}

前面加载数据还是 张三、李四、王五,中间没有空格,按空格拆不开。把 names 改成带空格的,拆开后左边是张、右边是三:

ts 复制代码
const names = ['张 三', '李 四', '王 五']

点加载数据,姓名列两个输入框分别是张和三:

此时修改两个输入框,数据模型里的 name 还不会动。编辑要另加一段:注入 schema,把名和姓拼回字符串,setValue 写到这一列上。两边都空就写成空字符串,有内容就中间补一个空格。

ts 复制代码
dependsOn: [
  ..., // 第一段 dependsOn:从 name 按空格拆到 firstName、lastName,与上面相同,这里省略
  {
    inject: ['schema'],
    handle: (scope, schema) => {
      const left = `${scope.firstName ?? ''}`
      const right = `${scope.lastName ?? ''}`
      schema.setValue(left === '' && right === '' ? '' : `${left} ${right}`)
    },
  },
]

改名或姓,数据模型里的 name 跟着拼回去:

操作列

姓名拆分那一列有 prop: 'name',数据模型要的是这一列的值。操作列没有 prop,也不写 type,只写 label 和 custom。单元格里只放按钮,这一列不会在数据模型里多出字段。

先加删除。按钮注入 list,用当前行的 $index 从数组里删掉这一行。删完以后,表格和数据模型里都少这一条。

ts 复制代码
columns: [
  ..., // 前面的 id、个人信息各列,与上面相同,这里省略
  {
    label: '操作',
    custom: {
      items: [
        {
          label: '删除',
          type: 'Button',
          props: {
            nativeType: 'button',
            onClick: {
              inject: ['list'],
              handle: (scope, list) => {
                list.delete(scope.$index)
              },
            },
          },
        },
      ],
    },
  },
]

点删除,这一行从表格和数据模型里去掉:

再加编辑。编辑不在单元格里改,是打开一个弹框。按钮注入 dialog,open 里写弹框的 name。弹框和表格写在同一层,不是写在列上。弹框里的姓名、电话、性别与前面列的字段相同,确定后写回这一行。

ts 复制代码
{
  items: [
    {
      prop: 'contacts',
      type: 'VTable',
      // 表格其余配置与上面相同,这里只写出 columns 和同一层的弹框
      columns: [
        ..., // 前面的 id、个人信息各列,与上面相同,这里省略
        {
          label: '操作',
          custom: {
            items: [
              {
                label: '编辑',
                type: 'Button',
                props: {
                  nativeType: 'button',
                  onClick: {
                    inject: ['dialog'],
                    handle: (scope, dialog) => {
                      dialog.open('editContact')
                    },
                  },
                },
              },
              ..., // 删除按钮:注入 list,按 $index 删掉当前行,与上面相同,这里省略
            ],
          },
        },
      ],
    },
    {
      name: 'editContact',
      type: 'Dialog',
      label: '编辑受益人',
      children: [
        {
          items: [
            { prop: 'name', label: '姓名', type: 'Input' },
            { prop: 'phone', label: '电话', type: 'Input' },
            { prop: 'gender', label: '性别', type: 'Radio' },
          ],
        },
      ],
    },
  ],
}

点编辑打开弹框,确定后这一行写回:

姓名为空时不给编辑,和前面隐藏列同一套 hidden。写在编辑按钮上:name 没有值就返回 true,这一列只剩下删除。

ts 复制代码
{
  label: '编辑',
  type: 'Button',
  hidden: (scope) => !scope.name,
  ..., // 其余 props、onClick 打开 editContact 弹框,与上面相同,这里省略
}

清空姓名,编辑按钮消失:

mode 开关

前面表格默认是控件态:姓名、性别、电话都是输入框,可以直接修改。加一个开关,打开以后数据列变成纯文本,关掉再回到控件。操作列不受这个开关影响,编辑、删除仍在。

开关和加载数据放在同一层。表格上写 mode:tableText 为 true 就是 'text',否则是 'control'。

ts 复制代码
{
  items: [
    {
      label: '加载数据',
      type: 'Button',
      ..., // 点击后写入 contacts,与上面相同,这里省略
    },
    {
      prop: 'tableText',
      label: '文本',
      type: 'Switch',
    },
  ],
}

{
  prop: 'contacts',
  type: 'VTable',
  mode: (scope) => (scope.tableText === true ? 'text' : 'control'),
  ..., // 其余 key、columns、同一层弹框,与上面相同,这里省略
}

打开文本开关,数据列变成文字,操作列仍是按钮;关掉后回到输入框:

文本列格式化

控件态里电话仍是输入框,改的是数据模型里的号码。打开文本开关以后,这一列走 formatter,可以自己拼展示文案,号码本身不变。

先在和加载数据同一层加一个区号。电话列加上 formatter:从这一行的 $parent 取出 phonePrefix,拼在号码前面。没有区号就只显示号码。

ts 复制代码
{
  items: [
    ..., // 加载数据、隐藏年龄、文本开关,与上面相同,这里省略
    {
      prop: 'phonePrefix',
      label: '区号',
      type: 'Select',
      defaultValue: '+86',
      props: {
        options: [
          { label: '中国 +86', value: '+86' },
          { label: '美国 +1', value: '+1' },
          { label: '英国 +44', value: '+44' },
        ],
      },
    },
  ],
}

{
  label: '个人信息',
  children: [
    ..., // 基本、姓名各组,与上面相同,这里省略
    {
      prop: 'phone',
      label: '电话',
      type: 'Input',
      formatter: (row, _col, cellValue) => {
        const prefix = row.$parent?.phonePrefix
        const phone = cellValue ?? ''
        return prefix ? `${prefix} ${phone}` : String(phone)
      },
    },
  ],
}

打开文本开关,再切换区号,电话列前面的前缀跟着变,数据模型里的 phone 还是原来的号码:

不定高

前面折行是单元格变窄、内容换行后行高跟着变。这一列放一段 HTML,长短差很多,行高按内容来。type 写成 HtmlText,单元格按 HTML 渲染,不受上面文本开关影响。

加载数据时给每行一段 desc,三行一轮:短的一行、中等三行、长的六行。列加在个人信息后面、操作列前面。

ts 复制代码
handle: (scope) => {
  const names = ['张 三', '李 四', '王 五']
  scope.contacts = Array.from({ length: 5000 }, (_, i) => ({
    id: `g${i + 1}`,
    name: names[i % 3],
    phone: String(13800000000 + i + 1),
    gender: i % 2 === 0 ? '0' : '1',
    age: 20 + (i % 40),
    desc:
      i % 3 === 0
        ? '<span>短描述</span>'
        : i % 3 === 1
          ? '<p>中等描述</p><p>第二行</p><p>第三行</p>'
          : '<p>较长描述,在这一列里会折行。</p><p>再来一段。</p><p>第三段。</p><p>第四段。</p><p>第五段。</p><p>第六段,比中等高一截。</p>',
  }))
}

columns: [
  ..., // 前面的 id、个人信息各列,与上面相同,这里省略
  {
    prop: 'desc',
    label: '描述',
    type: 'HtmlText',
    width: 280,
  },
  ..., // 操作列,与上面相同,这里省略
]

滚动时先按估计高度占位,测到实际高度后再调整。默认是 40。描述列普遍偏高,在这张 VTable 的 props 上把 estimatedRowHeight 调大,表头高度不受影响。

ts 复制代码
{
  prop: 'contacts',
  type: 'VTable',
  props: { estimatedRowHeight: 80 },
  ..., // 其余 mode、columns、同一层弹框,与上面相同,这里省略
}

点加载数据,看描述列:短行矮、长行高,删除也不影响:

弹框里同步加一个描述,改 HTML 写回这一行,行高跟着变。

ts 复制代码
{
  name: 'editContact',
  type: 'Dialog',
  label: '编辑受益人',
  children: [
    {
      items: [
        ..., // 姓名、电话、性别,与上面相同,这里省略
        {
          prop: 'desc',
          label: '描述',
          type: 'Textarea',
          props: { placeholder: '请输入 HTML' },
        },
      ],
    },
  ],
}

点编辑改描述,确定后这一行高度跟着变:

行嵌套

前面都是平铺的行。行上带 children 数组,就是子行。表格写 expandColumnKey: 'id',id 这一列出现展开箭头。加载改成树:20 组,每组 9 个下级,每个下级再 10 条。

ts 复制代码
{
  prop: 'contacts',
  type: 'VTable',
  expandColumnKey: 'id',
  ..., // 其余 mode、props、columns、同一层弹框,与上面相同,这里省略
}

handle: (scope) => {
  const names = ['张 三', '李 四', '王 五']
  const row = (id, i) => ({
    id,
    name: names[i % 3],
    phone: String(13800000000 + i + 1),
    gender: i % 2 === 0 ? '0' : '1',
    age: 20 + (i % 40),
    ..., // desc 长短 HTML,与上面相同,这里省略
  })
  let n = 0
  scope.contacts = Array.from({ length: 20 }, (_, g) => ({
    ...row(`g${g + 1}`, n++),
    children: Array.from({ length: 9 }, (_, p) => ({
      ...row(`g${g + 1}-${p + 1}`, n++),
      children: Array.from({ length: 10 }, (_, c) =>
        row(`g${g + 1}-${p + 1}-${c + 1}`, n++),
      ),
    })),
  }))
}

子行的 $parent 是上一行,不是表单。区号在表单上,电话 formatter 要改成读 $root,展开后前缀仍然显示。

ts 复制代码
formatter: (row, _col, cellValue) => {
  const prefix = row.$root?.phonePrefix
  const phone = cellValue ?? ''
  return prefix ? `${prefix} ${phone}` : String(phone)
}

点 id 列的展开箭头看子行。打开文本开关后,子行电话仍带区号:

层级联动扩展

展开之后要看出当前是第几层。加一列 level,type 写成 Text,值用 expression 计算:从这一行顺着 $parent 往上数,碰到 $root 停下。顶层是 1,子行是 2,再下级是 3。这一列不用填写,由表达式得出。

列放在 id 后面。

ts 复制代码
columns: [
  ..., // id 列,与上面相同,这里省略
  {
    prop: 'level',
    label: '层级',
    type: 'Text',
    width: 80,
    expression: (scope) => {
      let n = 1
      let p = scope.$parent
      while (p && p !== scope.$root) {
        n++
        p = p.$parent
      }
      return n
    },
  },
  ..., // 其余列,与上面相同,这里省略
]

新增节点

为了后面改节点更方便,先用表单引擎扩展组件的能力,接一个识别弹框。往里面贴一段文本,当前行的姓名、电话、性别、年龄、描述一次写回。

前面编辑用的是内置 Dialog,弹框里按字段逐项修改。识别不是通用能力,格式由这个页面自己定,所以不写进引擎,在用户配置里用 createInput 继承 Dialog,扩展成 Fill。主体换成大文本;确定时按空格拆开,按顺序写入当前行,再调用父类原来的写回。底部放清空按钮,用 $base: 'footer' 把确定按钮接回来。

ts 复制代码
createInput('Fill', {
  extends: 'Dialog',
  slots: {
    default: {
      $el: 'div',
      attrs: { class: 'w-full' },
      children: [
        [
          {
            prop: 'ocrText',
            label: '粘贴文本',
            type: 'Textarea',
            col: 'auto',
            props: {
              placeholder: '张 三 13800138000 0 28 <p>描述</p>',
              class: 'min-lg',
            },
          },
        ],
      ],
    },
    footer: {
      $el: 'div',
      children: [
        [
          {
            label: '清空识别',
            type: 'Button',
            col: 6,
            props: {
              nativeType: 'button',
              onClick: {
                handle: (scope) => {
                  scope.ocrText = ''
                  scope.name = ''
                  scope.phone = ''
                  scope.gender = ''
                  scope.age = ''
                  scope.desc = ''
                },
              },
            },
          },
        ],
        { $base: 'footer' },
      ],
    },
  },
  methods: {
    flushData() {
      const parts = (this.state.ocrText as string).trim().split(/\s+/)
      this.state.name = `${parts[0]} ${parts[1]}`
      this.state.phone = parts[2]
      this.state.gender = parts[3]
      this.state.age = parts[4]
      this.state.desc = parts.slice(5).join(' ')
      this.parent.flushData!()
    },
  },
})

操作列加一个识别按钮,打开方式和编辑一样:注入 dialog,open 里写弹框的 name。弹框和表格写在同一层,type 写成 Fill。主体已经在扩展里带好了,children 先空着。

ts 复制代码
{
  label: '操作',
  custom: {
    items: [
      ..., // 编辑按钮:注入 dialog,打开 editContact,与上面相同,这里省略
      {
        label: '识别',
        type: 'Button',
        props: {
          nativeType: 'button',
          link: true,
          type: 'primary',
          onClick: {
            inject: ['dialog'],
            handle: (_scope, dialog) => {
              dialog.open('fill')
            },
          },
        },
      },
      ..., // 删除按钮:注入 list,按 $index 删掉当前行,与上面相同,这里省略
    ],
  },
}

{
  name: 'fill',
  type: 'Fill',
  label: '识别填写',
  children: [],
}

点识别,把下面这段贴进去,确定后当前行的姓名、电话、性别、年龄、描述都会写入。性别 0 是男,1 是女。

css 复制代码
张 三丰 13800138000 0 28 <p>描述</p>

新增一级节点

表格上面放一个新增按钮,注入 list,调用 add,往数组末尾追加一条空记录。

ts 复制代码
{
  items: [
    {
      label: '新增',
      type: 'Button',
      col: 3,
      props: {
        nativeType: 'button',
        onClick: {
          inject: ['list'],
          handle: (_scope, list) => {
            list.add({})
          },
        },
      },
    },
  ],
}

点新增,表底部出现一条空行,并滚动到这一行。姓名是空的,用前面的识别把字段填上。

新增子节点

操作列再加一个新增。一级调用 list.add,子节点往当前行的 children 里追加一条空记录。现有节点不用改结构。

ts 复制代码
{
  label: '操作',
  custom: {
    items: [
      ..., // 编辑按钮:注入 dialog,打开 editContact,与上面相同,这里省略
      {
        label: '新增',
        type: 'Button',
        props: {
          nativeType: 'button',
          link: true,
          type: 'success',
          onClick: {
            handle: (scope) => {
              scope.children.push({})
            },
          },
        },
      },
      ..., // 识别按钮:注入 dialog,打开 fill,与上面相同,这里省略
      ..., // 删除按钮:注入 list,按 $index 删掉当前行,与上面相同,这里省略
    ],
  },
}

点某一行的新增,这一行下面出现一条空的子行,并滚动到那一行。父行如果尚未展开,会先展开再滚动过去。空行用前面的识别填写。

新建的一级上再加二级:

树上已有、尚未展开的父行再加子行:

写在最后

TableV2 的虚拟滚动、不定高、树形展开、自定义单元格需要分开配置,业务还要叠加弹框、识别和增删。本篇将这些能力接到 JuanForm:表格作为字段,列仍用 prop、label、type。分组表头、隐藏列、自定义编辑、操作列、文本开关、不定高、行嵌套、层级和新增,都用同一套 schema。页面里不必再单独拼 TableV2 的配置。页面自身的格式,例如识别,用 createInput 继承弹框就可以,不必改引擎。

其他虚拟化滚动场景也按 schema 配置。开发人员和 AI 按配置说明来写,不必处理 vue 和 UI 组件的细节。

相关推荐
仨孩子的爹2 小时前
Lodop、hiprint 与 vue-print-designer:打印控件选型对比
vue.js
ruanno_top5 小时前
四个 canvas 组件,在 2 倍屏上一直是糊的
vue.js
ruanno_top5 小时前
一个脏数据能让整条折线消失:图表组件的四种数据契约
vue.js
FlyWIHTSKY1 天前
vue2和vue3有什么区别,用法上有哪些区别
前端·javascript·vue.js
ruanno_top1 天前
能点,但 Tab 不到:两个组件把「可操作」做了一半
vue.js
爱吃烧烤的技术渣1 天前
别再手写 CRUD 了:我把 Element Plus 的工作量压缩成一份列 schema
前端·vue.js
徐小夕1 天前
万字长文!DeepSeek Super-agent × 企业知识库架构深度拆解与落地实践
前端·vue.js·github
SL_staff2 天前
私有化低代码不是减配版开发:JVS双模式建模如何重建IT交付信任
java·低代码·spring cloud
ruanno_top2 天前
倒计时该用 deadline 还是每秒减一?我把两种写法都跑了一遍
vue.js