elpis-core 的 Schema 页面从“动态表格 + 动态搜索”继续扩展为可完成增、查、改等业务操作的页面。框架新增了 createForm、editForm、detailPanel 三类动态组件,并用领域模型中的配置决定组件是否出现、展示哪些字段以及如何调用接口。
这套设计要解决的核心问题是:同一类后台页面通常拥有相似的新增抽屉、编辑表单和详情面板,如果每个业务模块都重新编写 Vue 页面,会出现大量重复代码。elpis-core 将稳定的交互流程沉淀为通用组件,将变化的字段和行为放入 DSL,使业务方主要维护模型配置。
当前形成了两级动态扩展机制:
- 页面级动态组件:
createForm、editForm、detailPanel,由ComponentConfig注册。 - 表单项动态组件:
input、inputNumber、select,由FormItemConfig注册。
它们共享相同的设计思想:模型声明组件名称,注册表把名称映射为 Vue 组件,渲染器使用 <component :is="..."> 在运行时选择实现。
- 总体架构:配置、投影、渲染与事件回流
动态组件并不是孤立的 Vue 组件,而是领域模型、Schema 投影、组件注册、事件分发和 REST 接口共同组成的运行链路。
flowchart TD
A["领域模型<br/>model/buiness/model.js"] --> B["useSchema<br/>读取并拆分 Schema"]
B --> C["components<br/>{ schema, config }"]
C --> D["ComponentConfig<br/>页面组件注册表"]
D --> E["schema-view<br/>component :is 动态渲染"]
E --> F["createForm / editForm / detailPanel"]
F --> G["SchemaForm<br/>字段级动态渲染"]
G --> H["FormItemConfig<br/>input / inputNumber / select"]
F --> I["REST API<br/>POST / GET / PUT"]
I --> J["command: loadTableData"]
J --> K["刷新 SchemaTable"]
这条链路可以分成四层:
-
模型层描述页面能力和字段差异。
-
useSchema将一份总 Schema 投影为表格、搜索和各动态组件所需的子 Schema。 -
注册表和 Vue 动态组件完成实现选择与渲染。
-
组件通过统一命令回传结果,由页面协调表格刷新等后续动作。
-
DSL 设计:一份字段模型生成多种组件视图
动态组件配置位于 schemaConfig.schema 中。componentConfig 声明页面需要哪些业务组件;每个字段的 {comName}Option 声明该字段是否参与对应组件,以及使用什么控件。
下面是当前商品模型的精简版本:
<span>schemaConfig</span>: {
<span>api</span>: <span>'/api/proj/product'</span>,
<span>schema</span>: {
<span>type</span>: <span>'object'</span>,
<span>properties</span>: {
<span>product_id</span>: {
<span>type</span>: <span>'string'</span>,
<span>label</span>: <span>'商品ID'</span>,
<span>editFormOption</span>: {
<span>comType</span>: <span>'input'</span>,
<span>disabled</span>: <span>true</span>
},
<span>detailPanelOption</span>: {
<span>comType</span>: <span>'input'</span>,
<span>disabled</span>: <span>true</span>
}
},
<span>product_name</span>: {
<span>type</span>: <span>'string'</span>,
<span>label</span>: <span>'商品名称'</span>,
<span>minLength</span>: <span>3</span>,
<span>maxLength</span>: <span>10</span>,
<span>createFormOption</span>: {
<span>comType</span>: <span>'input'</span>,
<span>default</span>: <span>'测试'</span>
},
<span>editFormOption</span>: {
<span>comType</span>: <span>'input'</span>
},
<span>detailPanelOption</span>: {
<span>comType</span>: <span>'input'</span>,
<span>disabled</span>: <span>true</span>
}
}
},
<span>componentConfig</span>: {
<span>createForm</span>: {
<span>title</span>: <span>'新增商品'</span>,
<span>saveBtnText</span>: <span>'确认新增'</span>
},
<span>editForm</span>: {
<span>mainKey</span>: <span>'product_id'</span>,
<span>title</span>: <span>'编辑商品'</span>,
<span>saveBtnText</span>: <span>'确认修改'</span>
},
<span>detailPanel</span>: {
<span>mainKey</span>: <span>'product_id'</span>,
<span>title</span>: <span>'商品详情'</span>,
<span>saveBtnText</span>: <span>'收起'</span>
}
}
}
}
这里存在两个互相配合的维度:
componentConfig.createForm决定实例化新增组件,并为它提供标题、按钮文案等组件级配置。product_name.createFormOption决定商品名称字段进入新增表单,并使用input控件。
因此,同一个 product_name 字段可以同时拥有 tableOption、searchOption、createFormOption、editFormOption 和 detailPanelOption。框架不是复制五份字段定义,而是从同一份模型中提取五个面向不同场景的视图。这是动态组件设计与前文 DSL、JSON Schema 思路的连接点。
3.1 Schema 投影
useSchema 中的 buildDtoSchema 根据组件名称查找对应的 Option 字段,并将它统一转换为组件消费的 option:
<span>const</span> <span>buildDtoSchema</span> = (<span>_schema, comName</span>) => {
<span>if</span> (!_schema?.<span>properties</span>) { <span>return</span> {} }
<span>const</span> dtoSchema = {
<span>type</span>: <span>'object'</span>,
<span>properties</span>: {}
}
<span>for</span> (<span>const</span> key <span>in</span> _schema.<span>properties</span>) {
<span>const</span> props = _schema.<span>properties</span>[key]
<span>if</span> (props[<span>`<span>${comName}</span>Option`</span>]) {
<span>let</span> dtoProps = {}
<span>for</span> (<span>const</span> pKey <span>in</span> props) {
<span>if</span> (pKey.<span>indexOf</span>(<span>'Option'</span>) < <span>0</span>) {
dtoProps[pKey] = props[pKey]
}
}
dtoProps = <span>Object</span>.<span>assign</span>({}, dtoProps, {
<span>option</span>: props[<span>`<span>${comName}</span>Option`</span>]
})
dtoSchema.<span>properties</span>[key] = dtoProps
}
}
<span>return</span> dtoSchema
}
随后,componentConfig 中的每一项都会生成 { schema, config }:
<span>const</span> dtoComponents = {}
<span>for</span> (<span>const</span> comName <span>in</span> componentConfig) {
dtoComponents[comName] = {
<span>schema</span>: <span>buildDtoSchema</span>(configSchema, comName),
<span>config</span>: componentConfig[comName]
}
}
components.<span>value</span> = dtoComponents
以 createForm 为例,投影结果只保留具有 createFormOption 的字段。字段类型、长度和标签等通用信息被保留,其他场景的 Option 被清除,createFormOption 则被重命名为统一的 option。这样 SchemaForm 不需要知道数据来自新增表单还是编辑表单。
- 页面级动态组件:注册表与统一调用协议
页面级组件由 component-config.js 集中注册:
<span>import</span> createForm <span>from</span> <span>'./create-form/create-form.vue'</span>
<span>import</span> editForm <span>from</span> <span>'./edit-form/edit-form.vue'</span>
<span>import</span> detailPanel <span>from</span> <span>'./detail-panel/detail-panel.vue'</span>
<span>const</span> <span>ComponentConfig</span> = {
<span>createForm</span>: { <span>component</span>: createForm },
<span>editForm</span>: { <span>component</span>: editForm },
<span>detailPanel</span>: { <span>component</span>: detailPanel }
}
<span>export</span> <span>default</span> <span>ComponentConfig</span>
schema-view.vue 遍历 components,再根据同名键从注册表找到实现:
<component
v-for="(item, key) in components"
:key="key"
:is="ComponentConfig[key]?.component"
:schema="item.schema"
:option="item.config"
ref="comListRef"
@command="onComponentCommand"
/>
这里形成了一个简单的组件插件机制。DSL 中的 createForm 是稳定标识,注册表控制它对应哪个 Vue 组件。业务模型不需要导入组件文件,组件实现也不需要了解商品、订单等具体领域。
4.1 按钮事件如何打开组件
表格按钮通过 eventKey 描述动作,通过 eventOption.comName 指定目标组件:
{
<span>label</span>: <span>'编辑'</span>,
<span>eventKey</span>: <span>'showComponent'</span>,
<span>eventOption</span>: {
<span>comName</span>: <span>'editForm'</span>
},
<span>type</span>: <span>'warning'</span>
}
TablePanel 先处理自己能够完成的 remove 事件,无法处理的事件上抛给 SchemaView。页面再通过事件映射调用 showComponent:
<span>const</span> <span>EventHandlerMap</span> = {
showComponent
}
<span>function</span> <span>showComponent</span>(<span>{ btnConfig, rowData }</span>) {
<span>const</span> { comName } = btnConfig.<span>eventOption</span>
<span>if</span> (!comName) { <span>return</span> }
<span>const</span> comRef = comListRef.<span>value</span>.<span>find</span>(<span><span>item</span> =></span> item.<span>name</span> === comName)
<span>if</span> (!comRef || <span>typeof</span> comRef.<span>show</span> !== <span>'function'</span>) { <span>return</span> }
comRef.<span>show</span>(rowData)
}
每个动态组件通过 defineExpose 暴露相同的最小协议:
<span>defineExpose</span>({
name,
show
})
name 用于定位组件,show(rowData) 用于打开组件。新增按钮没有行数据,编辑和详情按钮会把当前行传入组件。这个协议让页面调度器不需要判断组件的具体类型。
- 字段级动态组件:SchemaForm 与 AJV 校验
SchemaForm 是新增和编辑组件共用的表单渲染器。它遍历子 Schema 的 properties,根据 option.comType 从 FormItemConfig 中选择字段组件:
<component
v-for="(itemSchema, key) in schema.properties"
ref="formComList"
:key="key"
:is="FormItemConfig[itemSchema.option?.comType].component"
:schema-key="key"
:schema="itemSchema"
:model="model ? model[key] : undefined"
/>
当前注册表支持三种表单控件:
<span>const</span> <span>FormItemConfig</span> = {
<span>input</span>: { <span>component</span>: input },
<span>inputNumber</span>: { <span>component</span>: inputNumber },
<span>select</span>: { <span>component</span>: select }
}
每个字段组件同样遵守统一协议:
validate():校验当前字段并展示错误信息。getValue():返回{ [schemaKey]: value }。
SchemaForm 聚合所有字段组件的结果:
<span>const</span> <span>validate</span> = (<span></span>) => {
<span>return</span> formComList.<span>value</span>.<span>every</span>(<span><span>item</span> =></span> item.<span>validate</span>())
}
<span>const</span> <span>getValue</span> = (<span></span>) => {
<span>return</span> formComList.<span>value</span>.<span>reduce</span>(<span>(<span>dto, component</span>) =></span> ({
...dto,
...component.<span>getValue</span>()
}), {})
}
当前字段组件注入 AJV,并使用 type、minLength、maxLength、minimum、maximum、pattern 等规则完成前端校验。这样,Schema 不仅决定控件类型,也逐步成为表单约束的单一来源。
5.1 新增、编辑和详情的接口语义
三个页面级组件复用 schemaConfig.api,通过 HTTP 方法区分操作:
| 动态组件 | 打开时行为 | 保存时行为 | 完成后 |
|---|---|---|---|
| `createForm` | 使用默认值初始化表单 | `POST /api/proj/product` | 关闭并刷新表格 |
| `editForm` | 按 `mainKey` 执行 `GET /api/proj/product` | `PUT /api/proj/product` | 关闭并刷新表格 |
| `detailPanel` | 按 `mainKey` 执行 `GET /api/proj/product` | 关闭面板 | 无数据变更 |
新增或编辑成功后,组件不直接操作表格,而是发出命令:
<span>emit</span>(<span>'command'</span>, {
<span>event</span>: <span>'loadTableData'</span>
})
SchemaView 接收命令后调用 tablePanelRef.loadTableData()。这种回流方式保持了组件边界:表单负责提交,页面负责协调,表格负责加载数据。
- 如何扩展新的动态组件
假设需要增加一个“批量导入商品”组件 importPanel,可以沿用现有机制完成扩展。
第一步,在模型中声明组件级配置:
<span>componentConfig</span>: {
<span>importPanel</span>: {
<span>title</span>: <span>'批量导入商品'</span>,
<span>accept</span>: <span>'.xlsx'</span>
}
}
如果组件需要字段 Schema,再为参与的字段增加 importPanelOption。如果它只上传文件,也可以只读取组件级配置,不创建字段投影。
第二步,实现组件并保持页面级协议:
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['command'])
const name = ref('importPanel')
const visible = ref(false)
function show() {
visible.value = true
}
function onImported() {
emit('command', { event: 'loadTableData' })
}
defineExpose({ name, show })
</script>
第三步,将实现加入 ComponentConfig:
<span>import</span> importPanel <span>from</span> <span>'./import-panel/import-panel.vue'</span>
<span>const</span> <span>ComponentConfig</span> = {
<span>// 已有组件</span>
<span>importPanel</span>: { <span>component</span>: importPanel }
}
第四步,在按钮中声明打开动作:
{
<span>label</span>: <span>'批量导入'</span>,
<span>eventKey</span>: <span>'showComponent'</span>,
<span>eventOption</span>: {
<span>comName</span>: <span>'importPanel'</span>
}
}
如果只是增加一种表单控件,例如日期选择器,则无需修改页面级注册表。实现字段组件的 validate 和 getValue,将其加入 FormItemConfig,然后在模型里使用 comType: 'datePicker' 即可。
- 当前实现的边界与演进建议
当前分支已经建立了可工作的扩展骨架,但在继续扩大组件数量前,还需要收紧以下边界。
第一,注册表缺失时应提供明确降级。目前 ComponentConfig[key]?.component 找不到实现时会得到空组件,表单中的 FormItemConfig[itemSchema.option?.comType].component 则可能直接报错。可以增加统一的组件解析函数,在开发环境报告未知的 comName 或 comType。
第二,动态组件协议可以显式化。现在页面默认组件会暴露 name 和 show,字段组件默认暴露 validate 和 getValue。可以用 TypeScript 类型、运行时断言或组件适配器固化协议,减少新增组件时的隐式约定。
第三,需要区分标准 JSON Schema 与 UI 扩展。type、minLength、minimum 等可交给 AJV;label、createFormOption、comType 属于 Elpis DSL。当前 required 放在 properties 内部,并由 buildDtoSchema 特殊读取,与标准 JSON Schema 将 required 放在对象层级的结构不同。后续可调整为标准结构,再由投影逻辑为字段生成 option.required。
第四,校验器实例和编译结果可以复用。目前各字段在校验时执行 ajv.compile。Schema 较大或校验频繁时,可以在 Schema 变化时编译一次,并缓存校验函数。
第五,组件配置和接口能力应同步校验。例如 editForm、detailPanel 依赖 mainKey,新增组件依赖 POST 接口。模型加载阶段可以检查必需字段,尽早暴露配置错误,而不是等用户点击按钮后才发现。
第六,注册方式可以逐步改为按需加载。当前组件使用静态 import,会进入页面构建依赖。组件数量增长后,可以将注册项改为 defineAsyncComponent(() => import(...)),使低频组件在使用时加载,同时保留同样的 DSL 名称和组件协议。
elpis-core 的动态组件扩展设计,本质上是将“页面会发生什么”写入模型,再通过两个注册表和两级动态渲染器把声明转换成 Vue 组件。它既保持了领域模型的集中表达,也给框架保留了明确的扩展入口。只要稳定组件标识、公开方法、事件命令和 Schema 投影这四类协议,新增业务能力就能以较小成本接入现有页面链路。
把页面交互写进模型、用注册表连接声明与实现,是低代码后台降重的务实做法,适合中后台 CRUD 场景;扩展前建议先固化组件协议与注册表降级策略。