大家好,我是全栈小卷。前面写过一篇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。数据量更大的字典表同样适用。
基本表格
页面引入:
<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 条记录。
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 里面可以再嵌套组列,支持任意层级,下面一级、二级、三级只是演示。各列其余配置与上一节相同,这里只写出结构。
一级嵌套
把性别、年龄、电话放到个人信息下面,表头会多一行:
columns: [
{ prop: 'id' },
{ prop: 'name' },
{
label: '个人信息',
children: [
{ prop: 'gender' },
{ prop: 'age' },
{ prop: 'phone' },
],
},
]
表头变为:个人信息下面是性别、年龄、电话。
二级嵌套
接着在个人信息里面加一层基本,性别、年龄放到基本下面,电话仍在个人信息下面:
columns: [
{ prop: 'id' },
{ prop: 'name' },
{
label: '个人信息',
children: [
{
label: '基本',
children: [
{ prop: 'gender' },
{ prop: 'age' },
],
},
{ prop: 'phone' },
],
},
]
表头变为:基本下面是性别、年龄,电话仍在个人信息下面。
三级嵌套
第三层按两两组合:把姓名和性别放到姓名这一组里,一组里不要只放一个字段。写法仍是 label 加 children,没有新的配置项。再加层继续嵌套 children,没有层级上限:
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:
{
prop: 'hideAge',
label: '隐藏年龄',
type: 'Switch',
}
{
prop: 'age',
hidden: (scope) => scope.$parent?.hideAge === true,
}
打开开关以后,年龄从列头到数据列一起去掉,数据模型里的 age 也不再出现。年龄原来和姓名共同放在基本下面;少了一列,基本只剩下一组,不能再组成一组,基本这一层组头拆掉,个人信息下面直接是姓名和电话。有没有数据,列头都按这个结果变化。打开隐藏年龄,看列头和数据模型:
自定义列
操作列用来做增删改。JuanForm 也支持在单元格里自定义 HTML 和组件,下面用姓名拆分做例子。
姓名拆分编辑列
前面姓名列是默认的可编辑列,配置只有 prop、label、type:
{ prop: 'name', label: '姓名', type: 'Input' }
单元格里是一个输入框,值和数据模型里的 name 直接对应。
自定义编辑列仍是这一列 name,prop、label、type 都留着,再写一项 custom。有了 custom,单元格不再渲染外层那个 Input,改为渲染 custom.items 里的内容。数据模型仍然只认 name;items 里的名、姓只留在单元格内层,不会变成行数据上的独立字段。
单元格里要两个输入框:左边名、右边姓,中间用 Html 放分隔线。数据是整段 name 加载进来的,两个输入框不会自动填。先加一段 dependsOn:读这一行的 name,按空格拆开,写到 firstName 和 lastName。加载完成后,两个输入框才有内容。
{
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 改成带空格的,拆开后左边是张、右边是三:
const names = ['张 三', '李 四', '王 五']
点加载数据,姓名列两个输入框分别是张和三:
此时修改两个输入框,数据模型里的 name 还不会动。编辑要另加一段:注入 schema,把名和姓拼回字符串,setValue 写到这一列上。两边都空就写成空字符串,有内容就中间补一个空格。
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 从数组里删掉这一行。删完以后,表格和数据模型里都少这一条。
columns: [
..., // 前面的 id、个人信息各列,与上面相同,这里省略
{
label: '操作',
custom: {
items: [
{
label: '删除',
type: 'Button',
props: {
nativeType: 'button',
onClick: {
inject: ['list'],
handle: (scope, list) => {
list.delete(scope.$index)
},
},
},
},
],
},
},
]
点删除,这一行从表格和数据模型里去掉:
再加编辑。编辑不在单元格里改,是打开一个弹框。按钮注入 dialog,open 里写弹框的 name。弹框和表格写在同一层,不是写在列上。弹框里的姓名、电话、性别与前面列的字段相同,确定后写回这一行。
{
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,这一列只剩下删除。
{
label: '编辑',
type: 'Button',
hidden: (scope) => !scope.name,
..., // 其余 props、onClick 打开 editContact 弹框,与上面相同,这里省略
}
清空姓名,编辑按钮消失:
mode 开关
前面表格默认是控件态:姓名、性别、电话都是输入框,可以直接修改。加一个开关,打开以后数据列变成纯文本,关掉再回到控件。操作列不受这个开关影响,编辑、删除仍在。
开关和加载数据放在同一层。表格上写 mode:tableText 为 true 就是 'text',否则是 'control'。
{
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,拼在号码前面。没有区号就只显示号码。
{
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,三行一轮:短的一行、中等三行、长的六行。列加在个人信息后面、操作列前面。
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 调大,表头高度不受影响。
{
prop: 'contacts',
type: 'VTable',
props: { estimatedRowHeight: 80 },
..., // 其余 mode、columns、同一层弹框,与上面相同,这里省略
}
点加载数据,看描述列:短行矮、长行高,删除也不影响:
弹框里同步加一个描述,改 HTML 写回这一行,行高跟着变。
{
name: 'editContact',
type: 'Dialog',
label: '编辑受益人',
children: [
{
items: [
..., // 姓名、电话、性别,与上面相同,这里省略
{
prop: 'desc',
label: '描述',
type: 'Textarea',
props: { placeholder: '请输入 HTML' },
},
],
},
],
}
点编辑改描述,确定后这一行高度跟着变:
行嵌套
前面都是平铺的行。行上带 children 数组,就是子行。表格写 expandColumnKey: 'id',id 这一列出现展开箭头。加载改成树:20 组,每组 9 个下级,每个下级再 10 条。
{
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,展开后前缀仍然显示。
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 后面。
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' 把确定按钮接回来。
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 先空着。
{
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 是女。
张 三丰 13800138000 0 28 <p>描述</p>
新增一级节点
表格上面放一个新增按钮,注入 list,调用 add,往数组末尾追加一条空记录。
{
items: [
{
label: '新增',
type: 'Button',
col: 3,
props: {
nativeType: 'button',
onClick: {
inject: ['list'],
handle: (_scope, list) => {
list.add({})
},
},
},
},
],
}
点新增,表底部出现一条空行,并滚动到这一行。姓名是空的,用前面的识别把字段填上。
新增子节点
操作列再加一个新增。一级调用 list.add,子节点往当前行的 children 里追加一条空记录。现有节点不用改结构。
{
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 组件的细节。