表单相关组件封装方案
与对外文档的关系
- 架构与类型约定以本文为主;
ElFormLayout/ElFormLayoutItem见 组件文档 · FormLayout;ElFormQuery见 组件文档 · FormQuery;ElFormRenderer/registerControl见 组件文档 · FormRenderer;ElFormBuilder(含dependencies/visible联动)见 组件文档 · FormBuilder。 - 代码以仓库
packages/components/*、packages/utils/*为准;本文随实现迭代同步。
一、命名规范
| 类型 | 规范 | 示例 |
|---|---|---|
| 组件(不与 EP 冲突) | el-xxx | <el-form-layout> |
| 组件(与 EP 冲突) | 对外导出加 Lab 等区分;目录 config-provider | ElLabConfigProvider |
| TypeScript 类型 | EllXxx | EllFormSchema |
| Composable | useXxx | useGridLayout、useConfigProvider |
| InjectionKey | 语义化 + _INJECTION_KEY | CONFIG_PROVIDER_INJECTION_KEY |
二、整体架构
2.1 组件层级
config-provider(ElConfigProvider) ← 全局配置(网格等,可扩展)
│
├── el-form-layout ← 纯 Grid 布局层(供 builder 等使用)
│ └── el-form-layout-item ← 布局单元(封装 el-form-item)
│
├── el-form-renderer ← 控件渲染引擎(插件注册;编辑 / 禁用 / 预览)
│
├── el-form-query ← 查询表单:自管 el-form + Grid + 控件区
│ ├── 不内嵌 el-form-layout(见下说明)
│ ├── 独立布局实现 + dense 折叠算法(操作区占折叠后末行末列)
│ └── el-form-renderer 渲染表单项控件
│
└── el-form-builder ← 完整表单(新建/编辑)
├── el-form + el-form-layout 提供布局
├── el-form-group 提供分组(复用已有组件)
└── el-form-renderer 渲染控件职责对照(最初设计)
| 组件 | 职责 |
|---|---|
el-form-layout / el-form-layout-item | 基于 Grid 的布局;el-form-layout-item 内封装 el-form-item。 |
el-form-renderer | 按 schema 渲染控件;插件式注册;支持展示、禁用、预览等模式。 |
el-form-query | 封装 el-form,自带 Grid 布局(不组合 el-form-layout),表单项由 el-form-renderer 渲染。 |
el-form-builder | 封装 el-form,布局交给 el-form-layout,控件交给 el-form-renderer。 |
config-provider / ElLabConfigProvider | 注册默认 grid 等全局配置(可扩展服务其它组件)。 |
useFormDialog / useFormDrawer | 在现有 useDialog / useDrawer 上接入 el-form-builder。 |
el-cru-page | 组合 el-simple-page-header 与 el-form-builder。 |
为何 el-form-query 不集成 el-form-layout
查询表单在折叠时需要让操作按钮(查询 / 重置等)落在当前折叠区域的最后一行、最后一列,与纯「字段网格」的布局目标不同,必须配合 dense 布局算法(如折叠态下 simulateDenseFill 等)单独演进。因此 el-form-query 不在组件树中组合 el-form-layout,仅在 utils 层复用断点、列数解析、useGridLayout 等工具能力(或与专用 useFormQueryLayout 共享底层);dense 与操作区落位逻辑只存在于查询表单一侧。
2.2 共享层目录
packages/
├── utils/src/
│ ├── breakpoints.ts ← 断点常量 + 工具函数
│ ├── form-builder-control-registry.ts ← FORM_BUILDER_FIELD_REGISTRY_KEY、EllFormBuilderFieldRegistry
│ ├── form-builder-field-path.ts ← EllFormBuilderFieldRefSpec、路径归一化(点分 / address[2])
│ ├── form-schema.ts ← EllFormSchema 类型 + parseSchema
│ ├── grid-layout.ts ← 预设解析、`resolveColCountFromGridLayout`
│ ├── use-grid-layout.ts ← 列数 + `grid-template-columns`(`ResizeObserver`;可选 **`fixedColCount`**)
│ ├── simulate-dense-fill.ts ← `simulateDenseFillCollapsed` / `simulateDenseFillExpanded`、折叠·展开装箱
│ └── form-label-overflow.ts ← 标签溢出测量(FormQuery / FormLayoutItem 共用)
│
└── components/
│
│ ── 基础表单组件 ──
├── config-provider/
│ ├── __tests__/
│ ├── src/
│ │ └── types.ts
│ └── index.ts ← config-provider(ElLabConfigProvider)
│
├── form-layout/
│ ├── __tests__/
│ ├── src/
│ │ ├── form-layout.vue
│ │ ├── form-layout-item.vue
│ │ └── types.ts
│ └── index.ts ← el-form-layout + el-form-layout-item
│
├── form-renderer/
│ ├── __tests__/
│ ├── src/
│ │ ├── form-renderer.vue
│ │ ├── controls/ ← 内置预览组件
│ │ │ ├── text-preview.vue
│ │ │ ├── select-preview.vue
│ │ │ └── date-preview.vue
│ │ └── types.ts
│ └── index.ts ← el-form-renderer
│
├── form-query/
│ ├── __tests__/
│ ├── src/
│ │ ├── form-query.vue
│ │ └── types.ts
│ └── index.ts ← el-form-query
│
├── form-builder/
│ ├── __tests__/
│ ├── src/
│ │ ├── form-builder.vue
│ │ └── types.ts
│ └── index.ts ← el-form-builder(分组渲染使用已有的 el-form-group)
│
│ ── 组合式 ──
├── hooks/
│ └── use-form/
│ ├── __tests__/
│ ├── src/
│ │ ├── types.ts
│ │ └── use-form.ts ← useForm()
│ └── index.ts
│
│ ── 业务复合组件 ──
├── form-dialog/
│ ├── __tests__/
│ ├── src/
│ │ └── types.ts
│ └── index.ts ← useFormDialog()
│
├── form-drawer/
│ ├── __tests__/
│ ├── src/
│ │ └── types.ts
│ └── index.ts ← useFormDrawer()
│
└── cru-page/
├── __tests__/
├── src/
│ ├── cru-page.vue
│ └── types.ts
└── index.ts ← el-cru-page文档目录(示例与演示):
docs/content/components/
├── ... ← 已有组件文档
└── business/ ← 业务组件文档
├── form-dialog.md
├── form-drawer.md
└── cru-page.md三、Schema 类型设计
3.1 类型标记
通过显式的 type 字段区分 schema 项,避免用 children 存在与否做隐式判断:
type EllSchemaItemType = 'field' | 'group' | 'custom'
/**
* 字段级标签配置:**`el-form-builder` / `el-form-layout-item`** 与 **`label`、顶层 `labelWidth`** 配合使用。
* 除 **`EllFormLayoutItemLabelConfig`** 已有项外,纳入查询侧常用的 **`description` / `renderLabel`**。
*/
interface EllFormFieldLabelConfig extends EllFormLayoutItemLabelConfig {
/**
* 字段说明;有值时标签旁默认 **`QuestionFilled` + Tooltip**(与 **`el-form-query`** 一致)。
* **`renderLabel`** 存在时不自动展示该图标。
*/
description?: string
/** 自定义标签渲染(覆盖 **`description`** 的默认图标逻辑) */
renderLabel?: (label: string) => VNodeChild
}
// 字段项(type 默认为 'field',可省略)
interface EllFieldSchema {
type?: 'field'
field: string
/** 表单项标签文案(顶层快捷,与 **`ElFormLayoutItem`** 一致) */
label?: string
/**
* 当前字段标签列宽(顶层快捷,映射为内层 **`labelConfig.labelWidth`** 的缺省值)。
* 与 **`labelConfig.labelWidth`** 同时存在时 **以 `labelConfig` 为准**。
*/
labelWidth?: number | string
/**
* 除 **`label`**、**`labelWidth`** 外**所有标签相关**(位置、**`justify`**、溢出、`autoLabelWidth`、**`description`**、**`renderLabel`** 等)。
* 若与 **`formItemConfig.formItemConfig`**(内层透传包)或 custom 块 **`layoutFormItem`** 中的标签相关键同键冲突:**本字段的 `labelConfig`** 优先(实现时与 **`ElFormLayoutItem`** 合并规则对齐)。
*/
labelConfig?: EllFormFieldLabelConfig
controlType?: EllControlType | string
// ── 控件配置 ──────────────────────────────────────────────
/** 透传给控件的 props(内层同名属性覆盖外层) */
controlConfig?: Record<string, any>
/** 控件插槽(如 el-input 的 prefix/suffix) */
slots?: Record<string, () => VNodeChild>
/** 控件事件 */
events?: Record<string, Function>
// ── 选项 ──────────────────────────────────────────────────
/**
* 选项数据(静态数组或异步函数)。
* 异步函数接收 { model },可在函数内部读取关联字段值。
* 选项函数重算依赖 **`schema.options`** 解析链中对 **`model`** 的订阅;需细化联动键时在字段上使用 **`dependencies.triggerFields`**(见声明式联动)。
*/
options?: EllOption[] | EllOptionsResolver
// ── 校验 ──────────────────────────────────────────────────
rules?: FormItemRule | FormItemRule[]
required?: boolean
// ── 联动 ──────────────────────────────────────────────────
/**
* 函数式显隐(**已实现**):与 **`dependencies.show`** 为 **且** 关系;返回 **`false`** 时不挂载表单项。
* **`payload.tab`** 来自 **`ElFormBuilder` / `ElFormQuery`** 的 **`activeTab`**;**`index`** 为当前层列表下标(根级 **`schema` / `fields` 块 / `group.children` 各算各的)。
* **`payload.schema`** 为当前字段项数据形态(**`EllFieldSchemaBase`**),不含本回调自身。
*/
visible?: (payload: EllFieldVisiblePayload) => boolean
/** 函数式联动:控制禁用状态(**未实现**,请使用 **`dependencies.disabled`**) */
disabled?: (model: Record<string, any>) => boolean
/**
* 声明式联动(**已实现**,与仓库 **`EllFieldSchemaDependencies`** / **`evaluateFieldDependencies`** 对齐)。
* - **`show`**:`false` 时不挂载表单项(**不**参与校验)。
* - **`rules` / `required`**:与 **`formItemConfig`** 静态配置合并,动态返回值优先。
* - **`disabled` / `controlConfig`**:在 **`ElFormRenderer`** 编辑态合并;**`controlConfig`** 动态键覆盖静态同名键。
* - **`triggerFields`**:可选;不传时依赖 **`model` 所有顶层键** 触发重算。
*/
dependencies?: {
triggerFields?: string[]
show?: (values: Record<string, unknown>) => boolean
disabled?: (values: Record<string, unknown>) => boolean
required?: (values: Record<string, unknown>) => boolean
rules?: (values: Record<string, unknown>) => unknown
controlConfig?: (values: Record<string, unknown>) => Record<string, unknown>
}
// ── 值处理 ──────────────────────────────────────────────────
default?: any
modifiers?: EllModelModifier[]
/** 提交时对值做转换,不修改原始 model */
transform?: (value: any, model: Record<string, any>) => any
/** 日期区间字段映射,如 ['startDate', 'endDate'] */
rangeField?: [string, string]
// ── 展示 ──────────────────────────────────────────────────
/**
* 自定义带选项控件(如 pd-select)的选项渲染。
* 优先级高于 options 属性。
*/
renderOption?: (option: EllOption, schema: EllFormSchema) => VNodeChild
/** 自定义整个字段渲染(最高优先级,覆盖 controlType) */
render?: (payload: EllRenderPayload) => VNodeChild
// ── 样式 ──────────────────────────────────────────────────
/** CSS 类名 */
class?: string
/** CSS 样式 */
style?: string | Record<string, any>
// ── 布局(由 el-form-layout-item 使用)─────────────────────
/**
* 网格中占据列数(列合并),与 `ElFormLayoutItemProps.span` 同语义。
* - `el-form-query`:参与 `simulateDenseFillCollapsed` / `simulateDenseFillExpanded`。
* - `el-form-layout`:可作为 `el-form-layout-item` 的便捷 `span` 入口(亦可写在 `formItemConfig.span`)。
*/
span?: number
/**
* 布局与 **`el-form-item`** 透传(**不含** **`label` / `labelWidth` / `labelConfig`**,请用顶层 **`label*`**)。
* 典型:**`span`**、**`gridColumn`**、**`prop`**(可与顶层 **`field`** 二选一由实现定夺)、**`rules`**、**`message`**、**`formItemConfig`**、**`class`**、**`style`**。
* 同名属性内层(**`formItemConfig`**)覆盖外层(**`el-form-layout`** / **builder** 全局)。
*/
formItemConfig?: Omit<Partial<ElFormLayoutItemProps>, 'label' | 'labelWidth' | 'labelConfig'>
}
// 分组项(type: 'group' 是必须的)
interface EllGroupSchema {
type: 'group'
/**
* 分组 ID,必填。
* 用于构造 portal 插槽名:
* 分组外部:${uid}-${field}-prepend / append
* el-form-group 插槽:${uid}-${field}-group-desc / group-extra
* 分组内部主体:${uid}-${field}-body-prepend / body / body-append
*/
field: string
label?: string
/**
* 分组标题旁的描述信息(区别于 EllFieldSchema 中的 description)。
* 显示在 el-form-group 标题旁边。
*/
description?: string
/**
* 分组内的表单字段(可选)。
* 有 children → 渲染 el-form-layout 包裹这些字段;
* 无 children → body 由 render 函数或 portal 注入。
*/
children?: EllFieldSchema[]
/**
* 自定义分组内容(render 函数方式)。
* 优先级:render > children > portal 注入(${uid}-${field}-body)
*/
render?: (payload: { uid: string; schema: EllGroupSchema }) => VNodeChild
/**
* 布局配置,透传给分组内的 el-form-layout。
* 同名属性内层(layoutConfig 内)覆盖外层(el-form-builder 全局配置)。
*/
layoutConfig?: Partial<ElFormLayoutProps>
groupConfig?: EllFormGroupConfig
}
// 自定义内容项(type: 'custom')
interface EllCustomSchema {
type: 'custom'
/**
* 自定义块 ID,必填。
* 用于构造 portal 插槽名:${uid}-${field}-prepend / append
*/
field: string
/**
* 自定义内容渲染(render 函数方式)。
* 与 EllGroupSchema.render 不同:custom 没有 el-form-group 包裹。
*/
render?: (payload: { uid: string; schema: EllCustomSchema }) => VNodeChild
/** **表单项 + 栅格**占位(custom 块外层 **`ElFormLayoutItem`**;与字段项顶层 **`formItemConfig`** 区分命名,因 custom 可含 **`label` / `labelConfig`**) */
layoutFormItem?: Partial<ElFormLayoutItemProps>
}
// 联合类型
type EllFormSchema = EllFieldSchema | EllGroupSchema | EllCustomSchema
// 工具函数
export function isGroupSchema(schema: EllFormSchema): schema is EllGroupSchema {
return schema.type === 'group'
}
export function isCustomSchema(schema: EllFormSchema): schema is EllCustomSchema {
return schema.type === 'custom'
}
export function isFieldSchema(schema: EllFormSchema): schema is EllFieldSchema {
return !schema.type || schema.type === 'field'
}实现位置(与本节对齐):
EllFormLayoutItemLabelConfig、EllFormSchema联合、isGroupSchema/isCustomSchema/isFieldSchema、parseSchema等均定义在@element-plus-lab/utils(types/form-layout-label.ts、types/form-schema.ts)。EllFieldSchema仍为查询/渲染栈已落地的字段子集(Omit键见该文件);EllFormFieldLabelConfig等为完整 §3.1 预留。element-plus-lab主包对白名单export type再导出联合与各子类型,运行时parseSchema请自@element-plus-lab/utils引用。
3.2 parseSchema:混合 Schema 处理
将扁平的 schema 数组处理为渲染单元序列。核心规则:
- 相邻散落字段合并为同一个
el-form-layout渲染块 - 分组项各自独立,不与相邻分组合并
- 处理后的序列保持原始顺序
type ParsedBlock =
| { type: 'fields'; items: EllFieldSchema[] } // 一组相邻散落字段
| { type: 'group'; item: EllGroupSchema } // 单个分组
/**
* 将混合 schema 数组解析为渲染块序列。
*
* 输入示例:
* [field-A, field-B, group-1, field-C, group-2, group-3, field-D]
*
* 输出示例:
* [
* { type: 'fields', items: [field-A, field-B] },
* { type: 'group', item: group-1 },
* { type: 'fields', items: [field-C] },
* { type: 'group', item: group-2 },
* { type: 'group', item: group-3 },
* { type: 'fields', items: [field-D] },
* ]
*/
type ParsedBlock =
| { type: 'fields'; items: EllFieldSchema[] }
| { type: 'group'; item: EllGroupSchema }
| { type: 'custom'; item: EllCustomSchema }
/**
* 将混合 schema 数组解析为渲染块序列。
*
* 规则:
* - 相邻 field 项合并为同一个 fields 块(共用一个 el-form-layout)
* - group 和 custom 各自独立成块,不与相邻项合并
*
* 输入:[field-A, field-B, group-1, custom-X, field-C, group-2]
* 输出:
* { type:'fields', items:[field-A, field-B] }
* { type:'group', item: group-1 }
* { type:'custom', item: custom-X }
* { type:'fields', items:[field-C] }
* { type:'group', item: group-2 }
*/
export function parseSchema(schema: EllFormSchema[]): ParsedBlock[] {
const blocks: ParsedBlock[] = []
let currentFields: EllFieldSchema[] = []
const flushFields = () => {
if (currentFields.length) {
blocks.push({ type: 'fields', items: [...currentFields] })
currentFields = []
}
}
for (const item of schema) {
if (isGroupSchema(item)) {
flushFields()
blocks.push({ type: 'group', item })
} else if (isCustomSchema(item)) {
flushFields()
blocks.push({ type: 'custom', item })
} else {
currentFields.push(item as EllFieldSchema)
}
}
flushFields()
return blocks
}四、Portal 设计
4.1 Portal 插槽命名规范
所有 portal 名称都以组件实例的 uid 为前缀,避免多实例冲突。
分组级 Portal
层次示意:
[${uid}-${field}-prepend] ← 分组外部上方(当前分组容器之前)
el-form-group
标题 + [${uid}-${field}-group-desc] ← el-form-group 的描述插槽
[${uid}-${field}-group-extra] ← el-form-group 的额外操作插槽(标题行右侧)
├─ [${uid}-${field}-body-prepend] ← 内部主体顶部(表单第一行上方,如 banner)
├─ 表单字段 / render / body portal
└─ [${uid}-${field}-body-append] ← 内部主体底部(表单最后一行下方)
[${uid}-${field}-append] ← 分组外部下方(当前分组容器之后)| 插槽名 | 位置 | 说明 |
|---|---|---|
${uid}-${field}-prepend | 分组外部上方 | 整个分组容器之前,用于分组间插入内容 |
${uid}-${field}-group-desc | 分组标题旁描述区 | 对应 el-form-group 的 description 插槽,可替代 groupConfig.description 实现富文本描述 |
${uid}-${field}-group-extra | 分组标题行右侧 | 对应 el-form-group 的 extra 插槽,用于在标题行放置操作按钮等 |
${uid}-${field}-body-prepend | 分组内部主体顶部 | 表单第一行上方,适合插入 banner、提示条等 |
${uid}-${field}-body | 分组内部主体 | 当无 children 且无 render 时,内容完全由此 portal 注入 |
${uid}-${field}-body-append | 分组内部主体底部 | 表单最后一行下方 |
${uid}-${field}-append | 分组外部下方 | 整个分组容器之后,用于分组间插入内容 |
操作按钮 Portal
按钮的 to 目标由用户通过 actions 配置传入,组件只负责发出,不预设接收位置:
interface EllActionConfig {
/** action 标识 */
key: 'submit' | 'save' | 'reset' | string
/** portal 目标名(用户定义的 portal-target name) */
to: string
/** 按钮文字 */
text?: string
/** 按钮 props(透传给 el-button) */
buttonProps?: Record<string, any>
/** 自定义渲染(优先级高于默认按钮) */
render?: () => VNodeChild
}字段级 Portal(el-form-builder 内)
⚠️ 职责待明确,暂不实现,保留记录供后续讨论。
| 插槽名 | 位置 ||--------|------|| ${uid}-${field}-prepend | 字段前 || ${uid}-${field}-append | 字段后 || ${uid}-${field}-prefix | 控件前缀 || ${uid}-${field}-suffix | 控件后缀 |
4.2 uid 生成
import { ulid } from 'ulid'
// 在组件 setup 中
const uid = ulid()
// 通过 provide 向下传递,子组件(如 el-form-group)可以 inject 使用
provide(ELL_FORM_UID_KEY, uid)4.3 操作按钮 Portal 使用示例
组件内部(el-form-builder):
<!-- el-form-builder 内部 -->
<template v-for="action in resolvedActions" :key="action.key">
<portal :to="action.to">
<!-- 自定义 render -->
<component v-if="action.render" :is="action.render" />
<!-- 默认按钮 -->
<template v-else>
<el-button
v-if="action.key === 'submit'"
type="primary"
@click="handleSubmit"
>
{{ action.text ?? '提交' }}
</el-button>
<el-button
v-else-if="action.key === 'save'"
type="primary"
@click="handleSave"
>
{{ action.text ?? '保存' }}
</el-button>
<el-button
v-else-if="action.key === 'reset'"
@click="handleReset"
>
{{ action.text ?? '重置' }}
</el-button>
</template>
</portal>
</template>用户侧(页面):
<!-- 页面中,头部接收保存按钮 -->
<el-simple-page-header title="供应商入库申请">
<template #extra>
<portal-target :name="`${formUid}-toolbar-save`" />
</template>
</el-simple-page-header>
<!-- el-form-builder,配置 actions -->
<el-form-builder
:uid="formUid"
:schema="schema"
:actions="[
{ key: 'save', to: `${formUid}-toolbar-save` },
{ key: 'reset', to: `${formUid}-toolbar-reset` },
]"
/>注意:
uid需要在父组件 setup 里生成并同时传给<el-form-builder>和<el-simple-page-header>侧的portal-target,保证两者使用同一个 uid。
五、config-provider(ElLabConfigProvider)
5.1 Props(当前实现)
interface ElLabConfigProviderProps {
grid?: EllGridPresetName | EllGridLayoutConfig
}(历史方案中的更多字段可作为后续扩展,以代码为准。)
5.2 Inject Keys
export const CONFIG_PROVIDER_INJECTION_KEY: InjectionKey<
ComputedRef<ConfigProviderState>
> = Symbol('element-plus-lab:config-provider')六、断点系统
6.1 内置断点预设
export const BREAKPOINT_PRESETS = {
'element-plus': { xs: 0, sm: 768, md: 992, lg: 1200, xl: 1920 },
'bootstrap': { xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 },
'antd': { xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1600 },
} as const6.2 ColCount 类型
type ColCountProp = number | Partial<Record<string, number>>
const DEFAULT_COL_COUNT: ColCountBreakpoints = {
/** lg 起 4 列:断点按容器 clientWidth;视口 1920 减去外层 padding 后常仍小于 xl 阈值 1920,故 lg 也设为 4 */
xs: 1, sm: 2, md: 3, lg: 4, xl: 4,
}6.3 useBreakpoint
export function useBreakpoint(options: {
colCount?: ColCountProp
breakpoints?: BreakpointPreset | BreakpointConfig
}) {
// setup 阶段同步计算初始值,避免首帧闪烁
const currentColCount = ref(calcInitialColCount())
// ResizeObserver 监听容器宽度变化
// window.innerWidth 匹配 EP 官方断点语义
return { currentColCount }
}七、Grid 布局核心(use-grid-layout.ts)
7.0 与组件的关系
el-form-layout:useGridLayout(containerRef, { grid, fixedColCount? })输出currentColCount、gridStyle(与ElFormQuery同源工具)。el-form-query:不作为子组件使用el-form-layout,但复用useGridLayout(含props.fixedColCount,与ElFormLayout.fixedColCount语义一致);折叠 + dense + 操作区占末行末列 由useFormQueryGrid+simulate-dense-fill.ts完成,不塞进通用el-form-layout。
7.1 查询折叠核心算法(目标实现,与参考代码对齐)
折叠时:simulateDenseFillCollapsed(fieldColSpans, colCount, maxRows)(defaultRowsNumber 作为 maxRows)
- 可用字段格为
maxRows * colCount - 1,右下角一格固定为操作区;装不下的项在fieldCells中为null,对应表单项display: none(由useFormQueryGrid.fieldStyle打样式)。 - 未传
defaultRowsNumber:不启用折叠网格,始终按展开态排布,且不展示「展开 / 收起」按钮。
窄屏单列(已实现):当 R × C - 1 < 1(例如响应式 colCount === 1 且 maxRows === 1)时,若不做处理则无任何字段格,折叠态会只剩操作按钮。simulateDenseFillCollapsed 在装箱前 while (R * C - 1 < 1) R++ 增加行数,使至少保留一个字段槽,操作区落在下一行,从而满足「一列表单 + 按钮换行」。
展开时:simulateDenseFillExpanded(与 dense 等价的先行后列首个可放位)+ 显式 grid-row / grid-column
- 操作区落在首个空列后,横向合并该行剩余列,再在合并区域内右对齐;
forceWrap时操作区独占字段下方一整行。
7.2 返回值(实现映射)
| 方案字段 | 当前实现位置 |
|---|---|
gridTemplateColumns / gridAutoFlow | useFormQueryGrid → gridContainerStyle(折叠 row + 显式行高;展开 row + 全量显式落位) |
actionStyle | useFormQueryGrid → actionStyle(折叠右下角格;展开由 simulateDenseFillExpanded 给出合并列范围 + justify-content: flex-end) |
syncLayout | 由 colCount / schema / collapsed 的响应式依赖隐式触发,无需单独命令式 API(后续若需可再包一层 recompute) |
needToggle | 已传 defaultRowsNumber 且 simulateDenseFillCollapsed().hasOverflow |
currentColCount | useGridLayout(ResizeObserver + grid.breakpoints;可选 fixedColCount 固定列数) |
// 方案中的聚合形态(逻辑上等价,非单一函数返回值)
{
gridTemplateColumns,
gridAutoFlow,
actionStyle,
syncLayout,
needToggle,
currentColCount,
}7.3 操作区对齐(实现约定)
- 折叠态:操作区对应
simulateDenseFillCollapsed的右下角单格(cellToGridStyle)。容器为横向 flex,justify-content: flex-end,重置 / 查询 / 展开 在格内靠右排列;flex-wrap: wrap(窄格时可换行)。 - 展开态:操作区
grid-row/grid-column由simulateDenseFillExpanded给出——默认先在末行从首个空列横向合并至行尾(colSpan = C - col),再在该合并区域内右对齐;容器width: 100%、justify-self: stretch、justify-content: flex-end。forceWrap === true时操作区为字段下方新行且占满列宽,仍整行内flex-end;flex-wrap为wrap,默认false时为nowrap(按钮不换行)。
八、el-form-layout + el-form-layout-item
与仓库实现一致(packages/components/form-layout):外层 grid + useGridLayout,内层 el-form-item 合并 labelConfig / formItemConfig。用户文档见 /components/basic/form-layout。
8.1 el-form-layout Props
interface ElFormLayoutProps {
/** 断点列数 + 可选 colGap/rowGap;未传则继承 ElLabConfigProvider,再无则内置 element-plus 预设 */
grid?: EllGridPresetName | EllGridLayoutConfig
/** 固定列数(≥1):不随容器变;不传则按 grid.breakpoints + ResizeObserver 解析列数 */
fixedColCount?: number
}8.2 el-form-layout-item Props
interface EllFormLayoutItemLabelConfig {
labelWidth?: number | string
/** left / right / top / '' 透传 el-form-item;justify 映射为 left + 组件内字间撑满 */
labelPosition?: 'left' | 'right' | 'top' | 'justify' | ''
autoLabelWidth?: boolean
/** 仅允许 true;与 labelOverflowType 并存时以 labelOverflowType 为准;仅 true 时等价 wrap */
labelOverflow?: true
/** 需非 autoLabelWidth 且非 justify;语义与 ElFormQuery.labelOverflowType 一致 */
labelOverflowType?: 'wrap' | 'nowrap' | 'ellipsis'
}
interface ElFormLayoutItemProps {
span?: number
gridColumn?: string
label?: string
prop?: FormItemProp
rules?: Arrayable<FormItemRule>
message?: string
labelConfig?: EllFormLayoutItemLabelConfig
/** 其余 el-form-item 属性(camelCase);与 labelConfig 冲突时 labelWidth/labelPosition 以 labelConfig 为准 */
formItemConfig?: Record<string, unknown>
class?: unknown
style?: StyleValue
}8.3 标签溢出与 justify(与 ElFormQuery 对齐要点)
| 策略 | 要点 |
|---|---|
ellipsis | 启用后对本项始终应用单行省略样式;label-row 限制 max-width:100% + min-width:0,避免插槽外层被文案撑开导致 text-overflow: ellipsis 不触发;溢出时 el-tooltip 可看全文。 |
wrap | 仅实测溢出项加高行类;自动换行、不使用 line-clamp 截断为 「…」。 |
nowrap | 在已配置 labelWidth 时不再向 el-form-item 传固定 label-width,标签随内容伸展,内容区收缩。 |
justify | 与 ellipsis / wrap / nowrap 互斥场景同 form-query:勿与 autoLabelWidth、溢出策略混用。实现:标签插槽内逐字 span + 内层 display:flex; justify-content:space-between;.ell-form-layout-item__label-row--justify 需 width/max-width:100%,使字间撑满以 Element Plus 标签列宽 为基准。文案较长时须保证 labelWidth 不小于逐字 flex:0 0 auto 的最小总宽,否则视觉上仍会挤出内层(flex 行为,非未生效)。 |
8.4 Slots
// el-form-layout
slots: {
default: () // 放置 el-form-layout-item
}
// el-form-layout-item
slots: {
default: () // 控件内容
label: () // 自定义标签(自定义时不走内置 justify/ellipsis 插槽)
error: (scope) // 同 el-form-item#error
}九、el-form-renderer
9.1 控件注册
interface EllControlItem {
component: Component
defaultProps?: Record<string, unknown>
modelProp?: string
modelEvent?: string
previewComponent?: Component
formatPreview?: (value: unknown, schema: EllFieldSchema) => string
}
export function registerControl(type: string, item: EllControlItem): void内置 controlType(懒注册;可被 registerControl 同名覆盖):input、textarea、input-number、switch、checkbox、select(controlConfig.options:{ label, value }[]);日期时间:date-picker、datetime-picker、daterange-picker、time-picker、time-select(均为 Element Plus 对应组件,v-model 为 modelValue)。
9.2 渲染模式
type EllFormRenderMode = 'edit' | 'disabled' | 'preview'| 模式 | 渲染方式 |
|---|---|
edit | 正常控件 |
disabled | 控件 + 合并 disabled: true |
preview | previewComponent → formatPreview → 内置按类型纯文本;不注册控件 ref |
9.3 Props
interface ElFormRendererProps {
schema: EllFieldSchema
modelValue?: unknown
fieldRegistryKey?: string
mode?: EllFormRenderMode // 默认 'edit'
}控件透传见 schema.controlConfig(与 EllControlItem.defaultProps 合并)。
9.4 事件增强(EllFormRendererEventPayload)
ElFormBuilder/ElFormQuery向子树provideFORM_RENDERER_ENHANCE_CONTEXT_KEY(@element-plus-lab/utils/ 主包同名导出):model()、schema()、getSchemaByField(field)。- 字段
schema.events(如change/blur/update:modelValue)与controlConfig中的onXxx/onUpdate:modelValue在挂载到子组件时,若为函数,则包装为handler(...原参数, payload)。 payload:EllFormRendererEventPayload:{ model, schema, options, control, getSchema };其中schema与model与 provide 同源,建议父级schema使用reactive([...])以便在回调或getSchema中取到可改写的字段项引用;getSchema与getSchemaByField等价。schema.options:静态{ label, value }[],或(ctx) => []/async (ctx) => [],ctx为EllFieldSchemaOptionsContext:{ schema, model, control }(schema为整表EllFormSchema[],与 provide 同源)。select会解析后覆盖controlConfig.options;payload.options为同步快照(async在事件触发瞬间可能仍为[])。defineExpose:getSchemaByField、getSchema(别名),与getControlRef的field语义对齐。- 无 provide(单独使用
ElFormRenderer)时:payload.model仅含当前field键;payload.schema为[当前项];getSchema仅能命中当前字段。
十、el-form-query
10.0 实现要点
- 对外封装
el-form,表单项为el-form-item+el-form-renderer。 - 不在内部使用
el-form-layout;布局为 query 专用实现,以便接入 dense 折叠 并使 操作按钮在折叠态下占据末行末列(见 §二、§七)。 - 已实现 §7.1:折叠 / 展开均按 schema 的
span(默认 1)做列合并装箱;折叠为simulateDenseFillCollapsed+ 右下角操作格(必须传入defaultRowsNumber才启用折叠 UI);展开为simulateDenseFillExpanded。span与ElFormLayoutItemProps.span语义一致(见 §三EllFieldSchema、§8.2)。操作区右对齐与列合并见 §7.3、§10.2。
10.1 使用方式与布局
配置方式:el-form-query 只支持通过 schema 配置表单项,不提供在组件内用默认插槽手写 el-form-item 的用法(与 el-form-renderer / 控件注册表配合渲染)。
布局方式(列数 / 断点 + §7.1 折叠 dense):
- 列合并:schema 项上的
span(与el-form-layout-item的span同语义)指定占几列;折叠、展开两种布局均参与装箱。 - 网格配置(已实现):使用
grid传命名预设(element-plus/bootstrap/antd)或自定义EllGridLayoutConfig(breakpoints: { name, min, cols }[]);亦可由ElLabConfigProvider注入默认grid。单档固定列数可写:grid="{ breakpoints: [{ name: 'all', min: 0, cols: 3 }] }",或与ElFormLayout一致使用fixed-col-count(props.fixedColCount),列数不随容器变、colGap/rowGap仍来自grid及col-gap/row-gap/compact覆盖。(历史方案中的独立colCount已收敛为grid+ 可选fixedColCount。)
<!-- 响应式列数(断点) -->
<el-form-query
v-model="query"
:schema="querySchema"
:grid="{ breakpoints: [{ name: 'all', min: 0, cols: 3 }] }"
@search="load"
/><!-- 固定 4 列(与 ElFormLayout 对齐) -->
<el-form-query
v-model="query"
:schema="querySchema"
:fixed-col-count="4"
@search="load"
/>10.2 操作区对齐与列合并
- 按钮顺序(从左到右):重置 → 查询 → 展开/收起(与 DOM 顺序一致;在操作区内整体靠右)。实现上三个默认按钮经 portal-vue 投递到同一
PortalTarget(名称见 §10.6),通过order与自定义投递内容混排;5/10/15由组件占用,自定义内容勿使用这三个order。 - 折叠:操作区只占折叠网格最后一格(右下角),按钮在该格内
justify-content: flex-end右对齐。 - 展开(默认
force-wrap=false):先用simulateDenseFillExpanded把当前行从第一个空列到最后一列合并为操作区占位,再在该区域内右对齐;避免「只占一列却在列内靠右」导致左侧大段空白未并入操作区的问题。 - 展开(
force-wrap=true):操作区在所有字段之后另起一行并占满列宽,仍在该行内右对齐;按钮允许换行。 - 宽度:
min-width作用于表单根节点,小于父级宽度时由外层.ell-form-query__viewport横向滚动;max-width封顶宽度并margin: 0 auto整体居中,不再随父级变宽而拉伸。
10.3 Props
interface ElFormQueryProps {
/** 默认 **`v-model`**(`modelValue`) */
modelValue: Record<string, any>
/** 查询项配置(必填);仅支持 schema 驱动渲染 */
schema: EllFieldSchema[]
/** 实例 id;未传则内部生成。与操作区 **`PortalTarget`** 名称 **`` `${uid}-actions` ``** 同源(见 §10.6) */
uid?: string
grid?: EllGridPresetName | EllGridLayoutConfig
/** 固定列数(≥1);不传则按 grid 断点响应式解析,语义同 ElFormLayout.fixedColCount */
fixedColCount?: number
colGap?: number | string
rowGap?: number | string
collapsed?: boolean // 受控
defaultCollapsed?: boolean
/** 未传则不启用折叠网格,且不显示「展开/收起」 */
defaultRowsNumber?: number
/** 仅当已传 `defaultRowsNumber` 且字段溢出时生效 */
showCollapseToggle?: boolean
/**
* 展开态:`false`(默认)末行合并剩余列后与字段同排,操作区内右对齐,按钮 `nowrap`;
* `true` 时操作区独占字段下方整行并右对齐,按钮 `wrap`。
*/
forceWrap?: boolean
showSearch?: boolean
searchText?: string
showReset?: boolean
resetText?: string
/** `true` 时绑定内置「查询」按钮的 **`loading`**,防止重复点击 */
loading?: boolean
/** 若传入:校验通过后改为调用本函数,**不** `emit('search')`(含 `immediate` / `immediateDebounce`) */
searchHandler?: (model: Record<string, unknown>, formRef: FormInstance | undefined) => void
/** 若传入:`resetFields()` 之后改为调用本函数,**不** `emit('reset')` */
resetHandler?: (model: Record<string, unknown>, formRef: FormInstance | undefined) => void
/** 若传入:折叠态变更时改为调用本函数,**不** `emit('collapseChange')` */
toggleHandler?: (collapsed: boolean) => void
/** 透传 Element Plus `el-form` `scroll-to-error` */
scrollToError?: boolean
/** 透传 `el-form` `scroll-into-view-options` */
scrollIntoViewOptions?: ScrollIntoViewOptions | boolean
/** 挂载后 nextTick 触发一次 `search`(载荷同手动查询) */
immediate?: boolean
/** 正数:对 model 深度 watch,防抖毫秒后触发 `search` */
immediateDebounce?: number
labelWidth?: number | string
labelPosition?: 'left' | 'right' | 'top' | 'justify'
autoLabelWidth?: boolean
/** 仅允许字面量 `true`;与 `labelOverflowType` 配合见 §10.3.1 */
labelOverflow?: true
labelOverflowType?: 'wrap' | 'nowrap' | 'ellipsis'
compact?: boolean
/** 小于父级可用宽度时外层横向滚动;数字为 px */
minWidth?: number | string
/** 宽度封顶并在父级内水平居中 */
maxWidth?: number | string
size?: 'large' | 'default' | 'small'
disabled?: boolean
}校验失败滚动:不单独实现「滚到第一项错误」;沿用 Element Plus el-form 的 scroll-to-error / scroll-into-view-options,通过上表 props 透传配置即可。
标签与密度(已实现):label-width / label-position / size / disabled 透传根节点 el-form(与 Element Plus 一致);auto-label-width 为 true 时等价 label-width="auto"(优先于数值 label-width);compact 在未单独传 col-gap / row-gap 时把网格间距默认改为 8px,并为标签加上略小字号样式。label-position="justify" 时 el-form 仍为 left;标签逐字包 span,外层 flex + justify-content: space-between 撑满标签宽(见 掘金:表单标签两端对齐)。
自动查询(已实现):immediate 为 true 时挂载后 nextTick 触发一次 search(载荷同手动查询);immediateDebounce 为正数毫秒时对 v-model(表单对象)深度监听,变更后防抖触发 search。二者可组合(首屏拉数 + 改条件防抖请求)。overflowBehavior 已从本接口移除(未纳入实现路线)。
10.3.1 标签溢出 labelOverflow / labelOverflowType(已实现)
在固定 labelWidth 且非 autoLabelWidth、非 labelPosition: 'justify' 时,可通过 labelOverflowType 启用策略(单独传入即生效);labelOverflow?: true 且未传 labelOverflowType 时等价 wrap。勿使用可选 boolean false 表达「关闭」——Vue 会与「未传」混淆;关闭时请不传 labelOverflowType。
| 取值 | 行为要点 |
|---|---|
wrap | 仅对实测会溢出的表单项打标;自动换行、无尾部省略号,line-height: 1.25;溢出项用 flex align-items: center 与控件垂直对齐。网格内所有 .el-form-item__content 统一 column + align-items: flex-start;含 textarea 的项表单项行内亦为 flex-start。未溢出项不套 wrap 样式。测量在 requestAnimationFrame + 容器 ResizeObserver 下重算。 |
nowrap | 已向 el-form 传固定 label-width 时改为不传,标签按内容伸展,内容区 flex:1; min-width:0 收缩。 |
ellipsis | 启用 labelOverflowType="ellipsis" 时,所有字段应用单行省略与 label-row 宽度约束(避免 Tooltip 触发器撑开宽度导致无 …);el-tooltip 仅在该项实测溢出时启用。 |
renderLabel 字段不参与自动测量与打标(自定义标签自行处理)。
10.4 Events / Expose
emit('search', model)
emit('reset')
emit('collapseChange', collapsed)
// v-model:collapsed 由 defineModel 提供 update:collapsed事件与命令式回调:若传入 searchHandler / resetHandler / toggleHandler 中任一,则对应操作不再 emit search / reset / collapseChange,由回调完全接管(immediate、immediateDebounce 触发的查询同样走 searchHandler)。
expose({
model, // 与 v-model 同源(见 §11.5.1 与 ElFormBuilder 对齐的 ref API)
formRef, // Element Plus FormInstance
uid, // 解析后的实例 id(与 props.uid 或自动 ulid 一致),与操作区 Portal 名称同源
collapsed, // 与 v-model:collapsed 同步
needToggle,
toggleCollapse,
currentColCount,
getControlRef, // 同 §11.5.1:`EllFormBuilderFieldRefSpec` → 控件实例(`ElFormRenderer` 注册的根控件)
getFormItemRef, // 同 §11.5.1:`el-form-item` 包装实例(模板 `ref` 回调注册)
formItemRef, // Proxy:点分键读取表单项,与 `getFormItemRef(spec)` 归一化键一致
})10.5 插槽
| 插槽 | 作用域参数 | 说明 |
|---|---|---|
默认插槽(无 name) | uid、actionsPortalName | 在 </el-form> 之后、内置三个操作按钮 <portal> 之前插入(仍在 ElFormQuery 根内)。便于作为子节点书写 <portal :to="actionsPortalName" :order="…">,与内置按钮共用同一 PortalTarget;最终左右顺序由 order 决定,与插槽相对 DOM 先后无关。模板中作用域属性 actions-portal-name(kebab) 解构为 actionsPortalName。 |
actions | model(与 v-model 同源)、formRef(el-form 实例 ref) | 整块操作区由用户完全自定义;组件只在网格中留出操作格,并通过作用域参数提供 model、formRef,便于自行排版按钮并做校验、重置、触发自定义查询等。未覆盖该插槽时,默认内容为 PortalTarget(`${uid}-actions`、multiple、slot-props),与 §10.6 内置 <portal>(重置 / 查询 / 展开收起)及 #default 里自定义 portal 共用同一落点并按 order 混排。 |
10.6 portal-vue 操作区(已实现)
- 单一目标名:
`${uid}-actions`(uid为 §10.4 中解析结果,与根节点data-ell-form-query-uid一致)。所有操作区按钮(含内置与自定义)均<portal :to="…">到该名称。 - 排序:在同一
PortalTarget上依赖 portal-vue 的order(数值越大越靠后,与 flexorder语义一致)。组件内置三个入口固定为:order: 5— 重置(showReset)order: 10— 查询(showSearch;先validate再search)order: 15— 展开/收起(showCollapseToggle && needToggle)
- 自定义:仅可使用 除 5、10、15 以外 的
order(例如1–4放在重置前,6–9在重置与查询之间,11–14在查询与展开之间,≥16在展开之后),避免与内置按钮覆盖或顺序错乱。 - 查询 / 重置 / 折叠:若在操作区外自定义按钮,需自行调用
ElFormQuery的expose(如formRef.validate()+emit('search')或searchHandler等价逻辑、formRef.resetFields()、toggleCollapse)或继续使用向${uid}-actions的portal投递。
历史草案中曾列举
action-prefix/action-suffix等插槽名,已废弃;以 §10.5、§10.6(#default/#actions+ 单PortalTarget+order)为唯一约定。
凡使用 portal-vue 与表单、工具栏等对齐时,uid 须在共同父级统一生成,并与各 portal-target 的 name、el-form-query / el-form-builder 实例约定一致。
十一、el-form-builder
11.0 定位(与 el-form-query 的差异)
- 操作区:不提供内置「提交 / 保存 / 重置」等按钮;由用户通过
actions配置portal :to="…",并在布局中显式放置同名portal-target(页面头、抽屉脚等),无默认落点。 loading:不提供专用于表单提交防重按的loadingprop(与el-form-query仅绑定「查询」按钮的loading区分);页面级加载由头部等兄弟组件自行处理。- 校验失败滚动:不在
el-form-builder层重复实现;在透传给el-form的配置(如业务里的formConfig)中使用 Element Plus 自带的scroll-to-error/scroll-into-view-options即可。 - 已实现 · 结构:
parseSchema驱动fields/group/custom块序渲染;group使用ElFormGroup+ 可选groupConfig,子内容为children→ 内层ElFormLayout,或与文档一致优先render;custom外层为ElFormLayout+ElFormLayoutItem(v-bind="layoutFormItem"),内层走render或${uid}-${field}-body占位。fields/ 分组子字段通过ElFormLayoutItem消费label/labelWidth/labelConfig(含description/renderLabel)与formItemConfig(与 §3.1 合并规则一致)。uid?** 未传时ulid()(与 query 同策略)。defineExpose({ mergedGridLayout, parsedBlocks, uid, formRef, formHeaderPortalName, formFooterPortalName })。 - 已实现 · portal-vue:
el-form内首、尾为${uid}-form-header、${uid}-form-footer(表级注入;EllFieldSchema所在fields块不在前后挂 portal)。group/custom为${uid}-${field}-…(prepend、append 及分组 group-desc、group-extra、body-* 等,见 §11.1)。各PortalTargetslot-props:{ model, formRef, uid }。根#default插槽透出uid/formRef/model/formHeaderPortalName/formFooterPortalName(后两者为目标名字符串,与defineExpose同源)。
11.1 分组渲染流程(含 custom)
schema(混合数组)
↓ parseSchema()
ParsedBlock 序列
↓ 渲染(el-form 内)
┌─────────────────────────────────────────────────────────────┐
│ <portal-target :name="`${uid}-form-header`" /> │
├─────────────────────────────────────────────────────────────┤
│ block: 'fields'(无块级 portal;字段 schema 不定义前后插入)│
│ → <el-form-layout :layoutConfig="mergedLayoutConfig"> │
│ <el-form-layout-item │
│ v-for="field" │
│ v-bind="fieldItemProps(field)" ← label/labelConfig/ │
│ formItemConfig 合并(§3.1) │
│ /> │
├─────────────────────────────────────────────────────────────┤
│ block: 'group' │
│ → <portal-target :name="`${uid}-${field}-prepend`" /> │
│ → <el-form-group :title="label"> │
│ description slot: │
│ <portal-target :name="`${uid}-${field}-group-desc`"/>│
│ extra slot: │
│ <portal-target :name="`${uid}-${field}-group-extra`"/>│
│ body(优先级:render > children > portal): │
│ <portal-target :name="`${uid}-${field}-body-prepend`"/>│
│ render() │
│ OR <el-form-layout v-bind="group.layoutConfig"> │
│ + children 字段(复用已有 el-form-group 组件) │
│ OR <portal-target :name="`${uid}-${field}-body`" /> │
│ <portal-target :name="`${uid}-${field}-body-append`"/>│
│ → <portal-target :name="`${uid}-${field}-append`" /> │
├─────────────────────────────────────────────────────────────┤
│ block: 'custom' │
│ → <portal-target :name="`${uid}-${field}-prepend`" /> │
│ → <el-form-layout> │
│ <el-form-layout-item v-bind="layoutFormItem"> │
│ render() │
│ OR <portal-target :name="`${uid}-${field}-body`"/> │
│ </el-form-layout-item> │
│ </el-form-layout> │
│ → <portal-target :name="`${uid}-${field}-append`" /> │
├─────────────────────────────────────────────────────────────┤
│ <portal-target :name="`${uid}-form-footer`" /> │
└─────────────────────────────────────────────────────────────┘11.2 表级 portal 命名(form-header / form-footer)
整表顶部、底部插入(与 **fields 块无关):
${uid}-form-header
${uid}-form-footeruid 由 props.uid 或内部 ulid() 决定,并经 #default 插槽与 defineExpose 一并透出 formHeaderPortalName / formFooterPortalName(字符串,等于上表带真实 uid 的完整名)。
11.3 Props(梳理稿 · 与 el-form-layout / el-form-query 对齐)
以下为 实现前 的属性清单:网格与标签命名与 ElFormQuery、ElFormLayout 保持一致(grid + fixedColCount?,不再使用已废弃的独立 colCount / breakpoints 根 props);§11.1 流程与 EllActionConfig 不变。
11.3.1 分层
| 分层 | 说明 |
|---|---|
| 数据 | v-model(modelValue)表单对象;initialValues? 仅在 reset 等场景恢复初值(实现时定稿)。 |
| 结构 | schema: EllFormSchema[](§3);字段项标签仅顶层 label、labelWidth,其余标签能力进 labelConfig(见 §3.1)。 |
| 实例 | uid?:portal / actions[].to;不传则 ulid()。 |
| 网格 | grid?、fixedColCount?、colGap?、compact?:列间距等与 ElFormQuery / grid 合并规则一致;rowGap 在 ElFormBuilder 上默认 0,不继承 grid / Provider / compact 的行间距,仅 props.rowGap 可显式设置。子块用 layoutConfig 覆盖 grid / fixedColCount 等。 |
formConfig | 透传 el-form 的非必需与大块配置:scrollToError、scrollIntoViewOptions、size、disabled、labelPosition、autoLabelWidth、视口 minWidth/maxWidth、以及其它 EP el-form 文档中的 prop(camelCase)。与 labelConfig 同键时以 labelConfig 为准。 |
labelConfig | 整表默认 EllFormLayoutItemLabelConfig(及扩展字段,见 §3.1 EllFormFieldLabelConfig 中仅字段级才有的 description / renderLabel);覆盖 formConfig 同名项。 |
顶层 labelWidth | 唯一保留在 ElFormBuilder 根上的标签宽度快捷;解析优先级:labelConfig.labelWidth > 顶层 labelWidth > formConfig 中的 labelWidth / label-width。 |
| 模式 / 业务 | mode?、editableFields?、validateOnMount?、actions?、controls?、*Handler?:含义同前。validateOnMount 也可放入 formConfig,若与顶层重复则 以顶层面向实现的显式约定为准(建议只放一处)。 |
11.3.2 formConfig 与 labelConfig(对齐 el-form-layout-item)
- 与
ElFormLayoutItem上formItemConfig+labelConfig的关系类似:formConfig对应「根级el-form版formItemConfig」——打包非核心、少手写的透传;labelConfig专门收敛标签行为。 formConfig∩labelConfig:如labelPosition、autoLabelWidth、labelWidth(EP 常为label-width)等,始终以labelConfig解析结果覆盖formConfig。- 字段级(§3.1):
label、labelWidth在EllFieldSchema顶层;labelConfig含description、renderLabel及EllFormLayoutItemLabelConfig全部;渲染ElFormLayoutItem时 字段labelConfig优先于与表单项合并对象中的标签相关键(与formItemConfig.formItemConfig/layoutFormItem.formItemConfig冲突时仍遵循ElFormLayoutItem既有规则:labelConfig优先于内层透传中的标签相关键)。
11.3.3 类型草案
/** 整表 `el-form` 透传对象(camelCase);不含 v-model、schema */
type EllFormBuilderFormConfig = Record<string, unknown>
interface ElFormBuilderProps {
modelValue: Record<string, unknown>
schema: EllFormSchema[]
initialValues?: Record<string, unknown>
uid?: string
grid?: EllGridPresetName | EllGridLayoutConfig
fixedColCount?: number
colGap?: number | string
rowGap?: number | string
compact?: boolean
/** 根上唯一标签宽度快捷;见 §11.3.1 优先级 */
labelWidth?: number | string
/**
* 透传 `el-form`:**scroll**、**size**、**disabled**、**minWidth/maxWidth**(若实现为视口)、**labelPosition**(未在 `labelConfig` 写时可用)等。
* 与 **`labelConfig`** 同名字段 **以 `labelConfig` 为准**。
*/
formConfig?: EllFormBuilderFormConfig
/** 整表默认标签行为;覆盖 **`formConfig`** 中同键 */
labelConfig?: EllFormLayoutItemLabelConfig
/** 子级 **`ElFormRenderer`**,见 §9.2 */
mode?: EllFormRenderMode
editableFields?: string[]
validateOnMount?: boolean
actions?: EllActionConfig[]
controls?: EllControlRegistry
submitHandler?: (model: Record<string, unknown>, formRef: FormInstance | undefined) => void | Promise<void>
saveHandler?: (model: Record<string, unknown>, formRef: FormInstance | undefined) => void | Promise<void>
resetHandler?: (model: Record<string, unknown>, formRef: FormInstance | undefined) => void
}已从旧稿平面属性迁入 formConfig 的示例:scrollToError、scrollIntoViewOptions、size、disabled、labelPosition、autoLabelWidth、minWidth / maxWidth。labelConfig 负责:justify 对应侧的 labelPosition: 'justify'、labelOverflow* 等(待与 ElFormQuery 整表策略对齐时实现)。
11.4 EllActionConfig
interface EllActionConfig {
key: 'submit' | 'save' | 'reset' | string
to: string // portal-target 的 name
text?: string
buttonProps?: Partial<ButtonProps>
render?: () => VNodeChild // 完全自定义渲染,忽略其他配置
}内置 key 的默认行为:
| key | 默认行为 |
|---|---|
submit | formRef.validate() 通过后 emit('submit', model) 或 submitHandler(若实现互斥规则) |
save | emit('save', model) 或 saveHandler(不做校验) |
reset | formRef.resetFields() + 按 initialValues 恢复;emit('reset') 或 resetHandler |
11.5 Events / Expose
// Events
emit('update:modelValue', model)
emit('submit', values)
emit('save', values)
emit('reset')
emit('validate-failed', errors)
emit('field-change', field, value, model)
// Expose
expose({
uid, // 当前实例的 uid(供外部构造 portal-target name)
formRef,
model,
getControlRef, // (spec: EllFormBuilderFieldRefSpec) => 底层控件实例;spec 与 el-form-item prop / 点分 / address[2] 等对齐
getFormItemRef, // (spec) => ElFormItem 实例(表单校验、错误状态等)
formItemRef, // Proxy:按注册键(点分)读取表单项,等价于 getFormItemRef(key)
getSchemaByField, // (field: EllFieldSchema['field']) => 字段项引用,见 §11.5.1
getSchema, // 同 getSchemaByField
})11.5.1 字段级控件 / 表单项实例(getControlRef / getFormItemRef)
- 类型:
EllFormBuilderFieldRefSpec(定义在@element-plus-lab/utils,主包export type白名单可查)支持string、{ prop }、{ segments };字符串支持address[2]、lines.1等与el-form-itemprop一致的写法,内部统一为 点分注册键(如address.2)。工具函数:ellFormBuilderFieldSpecToRegistryKey、ellFormItemPropToRegistryKey。 - 注入:
FORM_BUILDER_FIELD_REGISTRY_KEY(EllFormBuilderFieldRegistry)由ElFormBuilder与ElFormQueryprovide(同一InjectionKey);ElFormRenderer注册控件;ElFormLayoutItem或ElFormQuery内对每个el-form-item的ref回调 注册表单项。FORM_RENDERER_ENHANCE_CONTEXT_KEY见 §9.4。 - Schema 查找:
defineExpose提供getSchemaByField/getSchema,参数为EllFieldSchema.field(与model上数据键一致),不等同于getControlRef({ prop: 'lines.1' })所用prop字符串;prop与field不一致时请用field取 schema 项、用prop规格 取 ref。 ElFormRenderer:见 §9.3fieldRegistryKey;在 builder 内与fieldItemProps(item).prop解析结果一致,保证getControlRef与getFormItemReflookup 同一键。- 覆盖范围:仅经
ElFormRenderer渲染的字段注册 控件 ref;group/custom纯render、仅有PortalTarget体 等路径 无getControlRef(getFormItemRef是否可用取决于是否包在ElFormLayoutItem+prop下)。 - 动态
schema:项增减后须在nextTick(或等价的挂载后时机)再调用getControlRef/getFormItemRef,待子树挂载并完成注册。 formItemRefProxy:属性名为 点分字符串(与归一化后的spec一致),例如formItemRef['lines.1']与getFormItemRef('lines[1]')等价。v-model与prop:当前ElFormBuilder对fields/group子字段 仍使用v-model="model[item.field]"。若formItemConfig.prop为lines.2而field取其它名,则 校验路径与数据绑定可能不一致,需业务将model形状与prop对齐,或field与绑定路径统一(后续若在 builder 内支持「按 prop 绑定」再修订本句)。
11.6 典型使用示例
<script setup lang="ts">
import { ulid } from 'ulid'
const formUid = ulid()
const form = ref({})
const schema: EllFormSchema[] = [
{ field: 'name', label: '姓名', controlType: 'input' },
{ field: 'phone', label: '手机', controlType: 'input' },
{
type: 'group',
field: 'addressGroup',
label: '地址信息',
children: [
{ field: 'province', label: '省份', controlType: 'select' },
{
field: 'city',
label: '城市',
controlType: 'select',
dependencies: {
triggerFields: ['province'],
show: values => values.province != null && values.province !== '',
},
options: async ctx => fetchCities(String(ctx.model.province ?? '')),
},
],
},
// 无 children,body 完全由 render 注入
{
type: 'group',
field: 'detailGroup',
label: '明细',
render: ({ uid, schema }) => h(MyDetailTable, { uid }),
},
{ field: 'remark', label: '备注', controlType: 'textarea', span: 3 },
]
</script>
<template>
<!-- 页面头部接收保存按钮 -->
<el-simple-page-header title="入库申请">
<template #extra>
<portal-target :name="`${formUid}-toolbar-save`" />
</template>
</el-simple-page-header>
<!-- 通过 portal 向 addressGroup 标题旁插入操作 -->
<portal :to="`${formUid}-addressGroup-header`">
<el-button size="small" link>从地址簿选择</el-button>
</portal>
<el-form-builder
:uid="formUid"
v-model="form"
:schema="schema"
:col-count="{ md: 2, lg: 3 }"
:actions="[
{ key: 'save', to: `${formUid}-toolbar-save`, text: '保存草稿' },
{ key: 'reset', to: `${formUid}-toolbar-reset` },
]"
:on-save="handleSave"
/>
</template>十二、useForm 组合式函数
实现:packages/components/hooks/use-form;对外 useForm 与类型见 element-plus-lab;文档见 Hooks · useForm。
在 ElFormBuilder 之上持有 model / schema(Ref 可传)/ uid,模板渲染 Form 即可;options 与其余 ElFormBuilderProps 一致(勿传 modelValue)。
const {
Form,
uid,
model,
schema,
builderRef,
formRef,
validate,
validateField,
clearValidate,
resetFields,
scrollToField,
getFieldValue,
setFieldValue,
getFieldsValue,
setFieldsValue,
submit,
save,
setSchema,
getControlRef,
getFormItemRef,
formItemRef,
} = useForm({
schema: [...],
initialValues: { name: '张三' },
formConfig: { labelWidth: '100px' },
actions: [{ key: 'submit', to: `${myUid}-actions` }],
submitHandler: async (model, formRef) => {
await api.save(model)
},
})说明:当前 EllFormSchema 不含草案中的 default / transform 等键;initialValues 与 ElFormBuilder 一致;无统一的 errors ref(请使用 validate / validateField 或 el-form 能力)。updateSchema 可用自持 Ref(schema) 替换或 setSchema 全量更新。
十三、预览模式
13.1 三态渲染回落策略
自定义 render() → controlItem.previewComponent → formatter() 文本 → 原始值 toString13.2 内置预览组件
| 类型 | 预览组件 | 说明 |
|---|---|---|
input/textarea | EllTextPreview | 文本省略 + tooltip |
select | EllSelectPreview | 展示选中 label,多选逗号连接 |
date-picker | EllDatePreview | 按 format prop 格式化 |
switch | EllSwitchPreview | activeText/inactiveText |
rate | EllRatePreview | 只读 el-rate |
upload | EllUploadPreview | 文件列表 / 图片预览 |
十四、标签系统
14.1 autoLabelWidth
function calcAutoLabelWidth(labels: string[]): number {
const CJK = 14, ASCII = 8, PADDING = 14
const max = Math.max(...labels.map(label => {
let w = 0
for (const char of label) {
w += /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(char) ? CJK : ASCII
}
return w
}))
return Math.max(max + PADDING, 72)
}14.2 labelPosition: 'justify'(分散对齐 · 已实现)
早期草案曾考虑纯 CSS text-align: justify。当前实现(ElFormQuery、ElFormLayoutItem)均为:根 el-form / el-form-item 仍为 label-position: left,标签插槽内逐字包 span,内层容器 display: flex、justify-content: space-between 撑满 label-width。样式约束见 §8.3 与 form-query / form-layout-item 源码(__label-row--justify、__justify-inner)。
十五、事件增强
enhanceEvents: true 时,控件事件回调额外接收 ctx:
controlEvents: {
change: (value, ctx) => {
// ctx.model — 当前表单数据
// ctx.schema — 完整 schema 数组
// ctx.getField — (field: string) => EllFieldSchema
// ctx.setValue — (field: string, value: any) => void(直接修改 model)
// ctx.options — 当前字段已加载的 options
}
}十六、Portal 注意事项
Portal 机制是本方案中最需要边走边调整的部分,以下是目前预判的关键问题:
1. portal-target 必须先于 portal 存在<portal-target> 所在组件(如 <el-simple-page-header>)必须在 <el-form-builder> 之前挂载,否则 portal 内容找不到目标。实际上 Vue 的渲染顺序通常是父组件先于子组件,页面头部先于下方表单,一般没问题。但要注意异步组件或条件渲染场景。
2. uid 需要在父组件 setup 中生成 使用 portal-vue 时,<el-form-builder>(或 query)与各处 <portal-target> 须使用同一 uid,须在共同父组件中生成,不能分拆各自生成。useForm 提供的 uid 就是为了解决这个问题。
3. 组件卸载时的清理<el-form-builder> 卸载后,其内部的 <portal> 会自动清空 target,无需手动处理。但如果 target 依然存在(页面头部还在),会看到按钮消失——这是预期行为。
4. SSR 场景 portal-vue 在 SSR 下需要额外配置,若项目有 SSR 需求需提前确认。
十七、实现优先级
Phase 1 — 基础设施
utils: breakpoints + form-schema(含 parseSchema)+ form-builder-field-registry / field-path(`getControlRef` 注册表)
utils: use-grid-layout + simulate-dense-fill + form-label-overflow
Phase 2 — 布局组件
el-form-layout + el-form-layout-item(grid / fixedColCount;labelConfig 与 query 对齐)
Phase 3 — 渲染引擎
el-form-renderer(控件注册 + 三态渲染 + v-model 修饰符)
Phase 4 — 查询表单
el-form-query(schema 驱动;扩展位后续 portal-vue)
Phase 5 — 完整表单
el-form-builder(parseSchema + 分组渲染 + portal actions)
useForm
Phase 6 — 完善体验
autoLabelWidth + compact(校验失败滚动沿用 EP `el-form` `scroll-to-error` 透传)
transform 机制 + 预览组件完善
portal 相关的边界情况处理
Phase 7 — 业务组件(components/business/)
useFormDialog + useFormDrawer
el-cru-page十八、业务组件层
放置路径:
packages/components/(与基础组件平级,统一发布) 文档路径:docs/content/components/business/
基于表单基础组件封装的业务场景复合组件,随 packages/element-plus-lab 统一对外发布。
18.1 useFormDialog()
定位:在现有 useDialog() 基础上注入 el-form-builder,适用于弹窗新建/编辑场景。
原 useDialog 结构(参考)
useDialog({
renderHeader: () => VNodeChild
render: () => VNodeChild
renderFooter: () => VNodeChild
okHandler: () => void | Promise<void>
cancelHandler: () => void
})useFormDialog 扩展
在 useDialog 的弹窗能力上固定内容区为 ElFormBuilder(可 render 完全自绘),并注入 model / formRef。选项类型见 UseFormDialogOptions(packages/components/form-dialog)。
/** formConfig:勿传 modelValue;勿传 Builder 侧 submit/save/resetHandler(与弹层 footer 冲突) */
type EllFormDialogFormConfig = Omit<
ElFormBuilderProps,
'modelValue' | 'submitHandler' | 'saveHandler' | 'resetHandler'
>
interface UseFormDialogOptions {
formConfig: EllFormDialogFormConfig
title?: string
width?: number
dialogConfig?: Partial<DialogProps>
submitText?: string
cancelText?: string
actions?: Array<'cancel' | 'ok'>
renderHeader?: (payload: EllFormDialogPayload) => VNodeChild
render?: (payload: EllFormDialogPayload) => VNodeChild
renderFooter?: (payload: EllFormDialogPayload) => VNodeChild
/** validate 通过后调用;再 resolve({ reason: 'ok', data: { model } }) */
submitHandler?: (payload: EllFormDialogPayload) => void | Promise<void>
cancelHandler?: (payload: EllFormDialogPayload) => void
/** 与 useDialog / EllDialogProps 一致 */
beforeClose?: EllDialogProps['beforeClose']
}
interface EllFormDialogPayload {
model: Record<string, unknown>
formRef: FormInstance | undefined
}内部实现要点
ElFormBuilder默认在内容槽渲染,uid/v-model由useFormDialog内部持有;formConfig.actions默认[]- 底部「提交」:
validate()→submitHandler?→resolve;「取消」:cancelHandler?→reject({ reason: 'cancel' }) - 另暴露
close(result?)、dismiss();打开 API 为open/openFormDialog(与openDialog区分命名)
使用示例
const { open, close, dismiss } = useFormDialog({
formConfig: {
schema: [
{ field: 'name', label: '姓名', controlType: 'input', /* rules … */ },
{ field: 'email', label: '邮箱', controlType: 'input' },
],
fixedColCount: 2,
initialValues: { name: '', email: '' },
grid: { breakpoints: [{ name: 'all', min: 0, cols: 2 }] },
},
renderHeader: ({ model }) =>
h('span', `编辑用户:${String(model.name || '新用户')}`),
submitHandler: async ({ model }) => {
await api.saveUser(model)
// 需要仅关窗不传 ok 快照时可自定义 close({ ... })
close()
},
})
await open()
open({ initialValues: { name: '张三', email: 'z@example.com' }, title: '编辑' })对外文档:组件 · useFormDialog。
18.2 useFormDrawer()
定位:与 useFormDialog 完全对称,将弹窗换为抽屉,适用于复杂编辑场景(内容较多时抽屉比弹窗体验更好)。
实现见 packages/components/form-drawer;formConfig 约束与 EllFormDialogFormConfig 相同(勿传 modelValue 与 Builder 侧 submit/save/reset handler)。
interface UseFormDrawerOptions {
formConfig: EllFormDrawerFormConfig
title?: string
size?: number | string
variant?: EllDrawerProps['variant']
headerConfig?: EllDrawerProps['headerConfig']
drawerConfig?: Partial<DrawerProps>
/** 不可传 okHandler / cancelHandler,由钩子写入 */
actionConfig?: Omit<EllDrawerActionConfig, 'okHandler' | 'cancelHandler'>
submitText?: string
cancelText?: string
actions?: Array<'cancel' | 'ok'>
renderHeader?: (payload: EllFormDrawerPayload) => VNodeChild
render?: (payload: EllFormDrawerPayload) => VNodeChild
renderFooter?: (payload: EllFormDrawerPayload) => VNodeChild
submitHandler?: (payload: EllFormDrawerPayload) => void | Promise<void>
cancelHandler?: (payload: EllFormDrawerPayload) => void
beforeClose?: EllDrawerProps['beforeClose']
}
interface EllFormDrawerPayload {
model: Record<string, unknown>
formRef: FormInstance | undefined
}与 useFormDialog 的差异
| 方面 | useFormDialog | useFormDrawer |
|---|---|---|
| 容器 | el-dialog | el-drawer |
| 尺寸 | width + dialogConfig | size + drawerConfig |
| 底部操作 | actions / okText 等平铺 | 写入 actionConfig(placement 可为 top) |
| 打开 API | open / openFormDialog → openDialog | open / openFormDrawer → openDrawer |
使用示例
const { open, close } = useFormDrawer({
formConfig: {
schema: supplierSchema,
fixedColCount: 2,
grid: { breakpoints: [{ name: 'all', min: 0, cols: 2 }] },
},
size: 600,
submitHandler: async ({ model }) => {
await api.saveSupplier(model)
close()
},
})
await open()
open({ initialValues: { id: '1' }, title: '编辑供应商' })对外文档:组件 · useFormDrawer。
18.3 el-cru-page
定位:CRU(Create / Read / Update)页面的标准封装,将页面头部(el-simple-page-header)与表单(el-form-builder)组合为一个完整的页面级组件。内置保存 / 提交 / 重置通过 portal-vue 落到页头右侧 ${uid}-actions,与 el-form-query / ElSimplePageHeader(无 #title 时)一致。
实现:
packages/components/cru-page。内置toolbarActions生成的actions[].to恒为${uid}-actions;有#title时由本组件在页头#extra内挂载PortalTarget name="${uid}-actions"。依赖ElFormBuilder的actions/submitHandler/saveHandler/resetHandler(见 §11.3 / §11.4)。选项卡下非默认 tab 的内容通过根Portal投递至${uid}-tab-<value>。
CRU = Create + Read + Update,不含 Delete(删除通常在列表页处理)。
Props
interface ElCruPageProps {
/**
* 表单实例 uid。
* 不传则组件内部自动生成(ulid())。
* 需要从外部操控表单时(如父组件调用 formRef)可手动传入。
*/
uid?: string
/** 页面级 loading(例如透传给头部骨架;**不**作为 `el-form-builder` 的 `loading` prop) */
loading?: boolean
/** 页面头部配置(透传给 el-simple-page-header) */
headerConfig: EllSimplePageHeaderProps
/**
* 表单配置(透传给 el-form-builder;本组件会注入 **`uid` / `actions` / handlers**)。
* 自定义扩展按钮与内置按钮共用 **`${uid}-actions`**,以 **`order`** 排序。
*/
formConfig: Omit<ElFormBuilderProps, 'modelValue' | 'uid' | 'actions' | 'submitHandler' | 'saveHandler' | 'resetHandler'>
/**
* 保存回调(不做校验,直接提交)。
* 对应 el-form-builder actions 中 key='save' 的按钮。
*/
saveHandler?: (model: Record<string, any>) => void | Promise<void>
/**
* 提交回调(validate 通过后执行)。
* 对应 el-form-builder actions 中 key='submit' 的按钮。
*/
submitHandler?: (model: Record<string, any>, formRef: FormInstance) => void | Promise<void>
}Expose
expose({
uid, // 当前实例 uid
formRef, // el-form-builder expose 的 formRef(el-form 实例)
model, // 当前表单数据(响应式)
getControlRef,
getFormItemRef,
formItemRef,
})内部渲染结构
<el-cru-page>
├── <el-simple-page-header v-bind="headerConfig">
│ · 无 #title:右侧单一 PortalTarget name=${uid}-actions(与 el-form-query 一致)
│ · 有 #title:cru-page 提供 #extra:header-extra → PortalTarget name=${uid}-actions
│
└── <el-form-builder
:uid="uid"
v-bind="mergedFormConfig"
:actions="resolvedActions"
:submit-handler="submitHandler"
:save-handler="saveHandler"
/>toolbarActions 默认 ['save','submit'](不含 reset);resolvedActions 按该数组为每个 key 生成一项,to 恒为 ${uid}-actions,order 为 10 + i * 10(与 EllActionConfig.order 一致)。自定义 Portal 与 ElSimplePageHeader 的 #extra portal(默认 order 5)落在同一 target,靠 order 混排。
Slots
slots: {
// #title 时:header-extra 在 ${uid}-actions 的 PortalTarget 之前;扩展仍 Portal 到 ${uid}-actions
'header-extra': ()
'form-prepend': ()
'form-append': ()
}使用示例
<script setup lang="ts">
const cruPageRef = ref()
const schema: EllFormSchema[] = [
{ field: 'name', label: '供应商名称', controlType: 'input', required: true },
{ field: 'code', label: '编号', controlType: 'input' },
{ field: 'type', label: '类型', controlType: 'select',
options: supplierTypes },
{
type: 'group',
field: 'contactGroup',
label: '联系人信息',
children: [
{ field: 'contact', label: '联系人', controlType: 'input' },
{ field: 'phone', label: '电话', controlType: 'input' },
],
},
]
</script>
<template>
<el-cru-page
ref="cruPageRef"
:header-config="{ title: '供应商入库申请', showBack: true }"
:form-config="{ schema, colCount: { md: 2, lg: 3 }, initialValues: detail }"
:save-handler="handleSave"
:submit-handler="handleSubmit"
/>
</template>18.4 业务组件目录结构
业务复合组件与基础组件平级,统一在 packages/components/ 下, 由 packages/element-plus-lab 统一发布。 文档示例放在 docs/content/components/business/。
packages/components/
│
├── form-dialog/
│ ├── __tests__/
│ ├── src/
│ │ └── types.ts ← UseFormDialogOptions / EllFormDialogPayload
│ └── index.ts ← useFormDialog()
│
├── form-drawer/
│ ├── __tests__/
│ ├── src/
│ │ └── types.ts ← UseFormDrawerOptions / EllFormDrawerPayload
│ └── index.ts ← useFormDrawer()
│
└── cru-page/
├── __tests__/
├── src/
│ ├── cru-page.vue
│ └── types.ts ← ElCruPageProps
└── index.ts ← el-cru-page
docs/content/components/business/
├── form-dialog.md
├── form-drawer.md
└── cru-page.md18.5 三者的关系与适用场景
| 组件 | 适用场景 | 表单位置 | 按钮位置 |
|---|---|---|---|
useFormDialog | 简单新建/编辑(字段少) | 弹窗内容区 | 弹窗底部 footer |
useFormDrawer | 复杂新建/编辑(字段多、有分组) | 抽屉内容区 | 抽屉底部 footer |
el-cru-page | 独立编辑页面(CRU) | 页面主体 | 页面头部 extra |

jenemy