1. 设计背景:让 Schema 页面具备业务交互能力
elpis-core 的 Schema 页面从“动态表格 + 动态搜索”继续扩展为可完成增、查、改等业务操作的页面。框架新增了 createForm、editForm、detailPanel 三类动态组件,并用领域模型中的配置决定组件是否出现、展示哪些字段以及如何调用接口。
这套设计要解决的核心问题是:同一类后台页面通常拥有相似的新增抽屉、编辑表单和详情面板,如果每个业务模块都重新编写 Vue 页面,会出现大量重复代码。elpis-core 将稳定的交互流程沉淀为通用组件,将变化的字段和行为放入 DSL,使业务方主要维护模型配置。
当前形成了两级动态扩展机制:
- 页面级动态组件:
createForm、editForm、detailPanel,由ComponentConfig注册。 - 表单项动态组件:
input、inputNumber、select,由FormItemConfig注册。
它们共享相同的设计思想:模型声明组件名称,注册表把名称映射为 Vue 组件,渲染器使用 <component :is="..."> 在运行时选择实现。
2. 总体架构:配置、投影、渲染与事件回流
动态组件并不是孤立的 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 动态组件完成实现选择与渲染。
- 组件通过统一命令回传结果,由页面协调表格刷新等后续动作。
3. DSL 设计:一份字段模型生成多种组件视图
动态组件配置位于 schemaConfig.schema 中。componentConfig 声明页面需要哪些业务组件;每个字段的 {comName}Option 声明该字段是否参与对应组件,以及使用什么控件。
下面是当前商品模型的精简版本:
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
product_id: {
type: 'string',
label: '商品ID',
editFormOption: {
comType: 'input',
disabled: true
},
detailPanelOption: {
comType: 'input',
disabled: true
}
},
product_name: {
type: 'string',
label: '商品名称',
minLength: 3,
maxLength: 10,
createFormOption: {
comType: 'input',
default: '测试'
},
editFormOption: {
comType: 'input'
},
detailPanelOption: {
comType: 'input',
disabled: true
}
}
},
componentConfig: {
createForm: {
title: '新增商品',
saveBtnText: '确认新增'
},
editForm: {
mainKey: 'product_id',
title: '编辑商品',
saveBtnText: '确认修改'
},
detailPanel: {
mainKey: 'product_id',
title: '商品详情',
saveBtnText: '收起'
}
}
}
}
这里存在两个互相配合的维度:
componentConfig.createForm决定实例化新增组件,并为它提供标题、按钮文案等组件级配置。product_name.createFormOption决定商品名称字段进入新增表单,并使用input控件。
因此,同一个 product_name 字段可以同时拥有 tableOption、searchOption、createFormOption、editFormOption 和 detailPanelOption。框架不是复制五份字段定义,而是从同一份模型中提取五个面向不同场景的视图。这是动态组件设计与前文 DSL、JSON Schema 思路的连接点。
3.1 Schema 投影
useSchema 中的 buildDtoSchema 根据组件名称查找对应的 Option 字段,并将它统一转换为组件消费的 option:
const buildDtoSchema = (_schema, comName) => {
if (!_schema?.properties) { return {} }
const dtoSchema = {
type: 'object',
properties: {}
}
for (const key in _schema.properties) {
const props = _schema.properties[key]
if (props[`${comName}Option`]) {
let dtoProps = {}
for (const pKey in props) {
if (pKey.indexOf('Option') < 0) {
dtoProps[pKey] = props[pKey]
}
}
dtoProps = Object.assign({}, dtoProps, {
option: props[`${comName}Option`]
})
dtoSchema.properties[key] = dtoProps
}
}
return dtoSchema
}
随后,componentConfig 中的每一项都会生成 { schema, config }:
const dtoComponents = {}
for (const comName in componentConfig) {
dtoComponents[comName] = {
schema: buildDtoSchema(configSchema, comName),
config: componentConfig[comName]
}
}
components.value = dtoComponents
以 createForm 为例,投影结果只保留具有 createFormOption 的字段。字段类型、长度和标签等通用信息被保留,其他场景的 Option 被清除,createFormOption 则被重命名为统一的 option。这样 SchemaForm 不需要知道数据来自新增表单还是编辑表单。
4. 页面级动态组件:注册表与统一调用协议
页面级组件由 component-config.js 集中注册:
import createForm from './create-form/create-form.vue'
import editForm from './edit-form/edit-form.vue'
import detailPanel from './detail-panel/detail-panel.vue'
const ComponentConfig = {
createForm: { component: createForm },
editForm: { component: editForm },
detailPanel: { component: detailPanel }
}
export default ComponentConfig
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 指定目标组件:
{
label: '编辑',
eventKey: 'showComponent',
eventOption: {
comName: 'editForm'
},
type: 'warning'
}
TablePanel 先处理自己能够完成的 remove 事件,无法处理的事件上抛给 SchemaView。页面再通过事件映射调用 showComponent:
const EventHandlerMap = {
showComponent
}
function showComponent({ btnConfig, rowData }) {
const { comName } = btnConfig.eventOption
if (!comName) { return }
const comRef = comListRef.value.find(item => item.name === comName)
if (!comRef || typeof comRef.show !== 'function') { return }
comRef.show(rowData)
}
每个动态组件通过 defineExpose 暴露相同的最小协议:
defineExpose({
name,
show
})
name 用于定位组件,show(rowData) 用于打开组件。新增按钮没有行数据,编辑和详情按钮会把当前行传入组件。这个协议让页面调度器不需要判断组件的具体类型。
5. 字段级动态组件: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"
/>
当前注册表支持三种表单控件:
const FormItemConfig = {
input: { component: input },
inputNumber: { component: inputNumber },
select: { component: select }
}
每个字段组件同样遵守统一协议:
validate():校验当前字段并展示错误信息。getValue():返回{ [schemaKey]: value }。
SchemaForm 聚合所有字段组件的结果:
const validate = () => {
return formComList.value.every(item => item.validate())
}
const getValue = () => {
return formComList.value.reduce((dto, component) => ({
...dto,
...component.getValue()
}), {})
}
当前字段组件注入 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 | 关闭面板 | 无数据变更 |
新增或编辑成功后,组件不直接操作表格,而是发出命令:
emit('command', {
event: 'loadTableData'
})
SchemaView 接收命令后调用 tablePanelRef.loadTableData()。这种回流方式保持了组件边界:表单负责提交,页面负责协调,表格负责加载数据。
6. 如何扩展新的动态组件
假设需要增加一个“批量导入商品”组件 importPanel,可以沿用现有机制完成扩展。
第一步,在模型中声明组件级配置:
componentConfig: {
importPanel: {
title: '批量导入商品',
accept: '.xlsx'
}
}
如果组件需要字段 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:
import importPanel from './import-panel/import-panel.vue'
const ComponentConfig = {
// 已有组件
importPanel: { component: importPanel }
}
第四步,在按钮中声明打开动作:
{
label: '批量导入',
eventKey: 'showComponent',
eventOption: {
comName: 'importPanel'
}
}
如果只是增加一种表单控件,例如日期选择器,则无需修改页面级注册表。实现字段组件的 validate 和 getValue,将其加入 FormItemConfig,然后在模型里使用 comType: 'datePicker' 即可。
7. 当前实现的边界与演进建议
当前分支已经建立了可工作的扩展骨架,但在继续扩大组件数量前,还需要收紧以下边界。
第一,注册表缺失时应提供明确降级。目前 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 投影这四类协议,新增业务能力就能以较小成本接入现有页面链路。