为什么需要动态组件扩展设计?
Schema 页面可以根据配置渲染搜索区和表格,但"新增商品""修改商品""查看详情"等操作具有共同特点:触发入口在表格中,内容却是不同的业务面板。
如果在页面中直接为每个操作编写抽屉、表单和事件处理函数,会带来以下问题:
SchemaView必须认识每一种业务操作;商品、订单、客户等模块会不断向其中堆积代码。- 同一字段需要在表格、创建、编辑、详情等区域重复定义。
- 新增一个业务面板需要修改页面结构和事件分支,无法做到"增加配置即可接入"。
因此,这里将业务面板设计为可注册、可配置、可由表格事件唤起的动态组件。页面只负责发现和调度组件;组件自身负责展示、请求和提交;字段由 Schema 统一描述。
设计思路
扩展机制由四部分组成:字段配置、组件注册、Schema 派生和事件分发。
scss
业务模型 schemaConfig
│
├─ 字段的 createFormOptions / editFormOptions / detailPanelOptions
├─ componentConfig:面板级配置(标题、主键、按钮文本)
└─ tableConfig:操作按钮及目标组件名
│
▼
useSchema 按组件名派生专属 Schema
│
▼
SchemaView 动态渲染已注册组件
│
▼
表格操作事件 → showComponent → 组件 show(rowData)
通过配置描述扩展
商品模型为同一字段定义不同区域的展示方式。例如商品名称同时出现在表格、搜索、创建表单、编辑表单和详情面板中,但每个区域只读取自己的 *Options。
css
// model/business/model.js
product_name: {
type: 'string',
label: '商品名称',
minLength: 3,
maxLength: 10,
tableOptions: { width: 200 },
searchOptions: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list'
},
createFormOptions: { comType: 'input' },
editFormOptions: { comType: 'input' },
detailPanelOptions: {}
}
componentConfig 存放面板级信息,tableConfig 决定由哪个按钮打开哪个面板:
css
componentConfig: {
createForm: { title: '新增商品', saveBtnText: '新增商品' },
editForm: {
mainKey: 'product_id',
title: '修改商品',
saveBtnText: '修改商品'
},
detailPanel: { mainKey: 'product_id', title: '商品详情' }
},
tableConfig: {
headerButtons: [{
label: '新增商品',
eventKey: 'showComponent',
eventOption: { comName: 'createForm' },
type: 'primary'
}],
rowButtons: [{
label: '修改',
eventKey: 'showComponent',
eventOption: { comName: 'editForm' },
type: 'warning'
}]
}
按组件名派生专属 Schema
一个业务 Schema 包含多个区域的配置。 useSchema 使用组件名拼接出选项键,例如传入 createForm 时读取 createFormOptions,从而避免把表格或搜索配置传入表单。
ini
// app/pages/dashborad/complex-view/schema-view/hook/schma.js
const buildDtoSchema = (schema, comName) => {
const dtoSchema = { type: 'object', properties: {} };
const optionKey = `${comName}Options`;
for (const key in schema.properties) {
const props = schema.properties[key];
if (!props[optionKey]) continue;
const dtoProps = {};
for (const pKey in props) {
if (pKey.indexOf('Option') < 0) dtoProps[pKey] = props[pKey];
}
dtoProps.option = props[optionKey];
if (schema.required?.find((requiredKey) => requiredKey === key)) {
dtoProps.option.required = true;
}
dtoSchema.properties[key] = dtoProps;
}
return dtoSchema;
};
每一个组件都会得到一份专属 Schema 配置:
ini
const dtoComponents = {};
for (const comName in componentConfig) {
dtoComponents[comName] = {
schema: buildDtoSchema(configSchema, comName),
config: componentConfig[comName]
};
}
components.value = dtoComponents;
注册和渲染动态组件
组件注册表负责把配置中的字符串名称转换为实际 Vue 组件。新增一个业务面板时,首先需要在这里注册。
css
// components/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';
export default {
createForm: { component: createForm },
editForm: { component: editForm },
detailPanel: { component: detailPanel }
};
SchemaView 遍历由 useSchema 生成的 components,并通过动态 <component> 挂载它们。组件发出 command 后,页面统一处理表格刷新。
ini
<component
v-for="(item, key) in components"
:key="key"
:is="ComponentConfig[key]?.component"
ref="comListRef"
@command="onComponentCommand"
/>
ini
const onComponentCommand = ({ event }) => {
if (event === 'loadTableData') {
tablePanelRef.value.loadTableData();
}
};
通过表格事件打开目标组件
表格将按钮配置和当前行数据作为 operate 事件参数传出。 SchemaView 通过事件映射表解析事件,而不是在模板里为每个按钮写条件分支。
javascript
const EventHandlerMap = { showComponent };
const onTableOperate = ({ btnConfig, rowData }) => {
const handler = EventHandlerMap[btnConfig.eventKey];
if (handler) handler({ btnConfig, rowData });
};
function showComponent({ btnConfig, rowData }) {
const { comName } = btnConfig.eventOption;
const comRef = comListRef.value.find((item) => item.name === comName);
if (!comRef || typeof comRef.show !== 'function') {
console.error(`找不到 com ${comName}`);
return;
}
comRef.show(rowData);
}
为让父组件能够查找和调用子组件,每个动态面板都需要公开同一份最小接口:
ini
const name = ref('editForm');
defineExpose({
name,
show
});
Schema Form 如何支撑表单类组件
schema-form 只关心字段 Schema,不关心它被创建表单还是编辑表单使用。它由控件注册表选择具体控件,并将校验和取值能力向上暴露。
ini
<!-- schema-form.vue -->
<component
v-for="(itemSchema, key) in schema.properties"
ref="formComList"
v-show="itemSchema.option.visible !== false"
:is="FormItemConfig[itemSchema.option?.comType]?.component"
:schema-key="key"
:schema="itemSchema"
:model="model ? model[key] : undefined"
/>
javascript
const validate = () => formComList.value.every((component) => component.validate());
const getValue = () => formComList.value.reduce(
(value, component) => ({ ...value, ...component.getValue() }),
{}
);
defineExpose({ validate, getValue });
字段控件使用 AJV 对 Schema 校验。例如数字输入会校验必填、类型和上下界,并通过 getValue() 返回自己的字段值。
ini
const validate = () => {
if (schema.option?.required && !dtoValue.value) {
validTips.value = '不能为空';
return false;
}
const validateBySchema = ajv.compile(schema);
if (dtoValue.value && !validateBySchema(dtoValue.value)) {
validTips.value = '不符合要求';
return false;
}
return true;
};
三个动态组件的具体实现(可扩展更多)
createForm:新增并刷新列表
创建面板把 createForm 专属 Schema 传给 schema-form。提交前调用表单校验,通过后发送 POST 请求;成功后关闭抽屉并通知父页面刷新。
ini
// components/create-form/create-form.vue
const save = async () => {
if (loading.value || !schemaFormRef.value.validate()) return;
loading.value = true;
const res = await $curl({
method: 'post',
url: api.value,
data: schemaFormRef.value.getValue()
});
loading.value = false;
if (!res?.success) return;
ElNotification({ title: '创建成功', message: '创建成功', type: 'success' });
isShow.value = false;
emit('command', { event: 'loadTableData' });
};
editForm:加载、回填、保存
编辑面板从行数据读取 mainKey 指定的主键,先请求详情,再将详情数据作为 schema-form 的 model。保存时将主键和表单值合并后提交。
ini
const show = (rowData) => {
const { config } = components.value[name.value];
mainKey.value = config.mainKey;
mainValue.value = rowData[config.mainKey];
dtoModel.value = {};
isShow.value = true;
fetchFormData();
};
const fetchFormData = async () => {
const res = await $curl({
method: 'get',
url: api.value,
query: { [mainKey.value]: mainValue.value }
});
if (res?.success && res.data) dtoModel.value = res.data;
};
const save = async () => {
if (!schemaFormRef.value.validate()) return;
const res = await $curl({
method: 'put',
url: api.value,
data: { [mainKey.value]: mainValue.value, ...schemaFormRef.value.getValue() }
});
if (res?.success) emit('command', { event: 'loadTableData' });
};
detailPanel:按 Schema 只读展示
详情面板同样用行数据中的主键查询单条记录,但不渲染可编辑控件,而是遍历详情 Schema 的字段并显示对应数据。
ruby
<el-row
v-for="(item, key) in components[name]?.schema?.properties"
:key="key"
class="row-item"
>
<el-row class="item-label">{{ item.label }}:</el-row>
<el-row class="item-value">{{ dtoModel[key] }}</el-row>
</el-row>
总结
在schema配置下,SchemaView 不直接处理新增、编辑和详情的业务细节,只负责组件注册、渲染和事件分发。createForm、editForm、detailPanel 分别完成创建、回填修改和只读展示,schema-form 负责字段渲染、校验和取值。由组件发送 loadTableData 事件,再由页面刷新表格,形成了动态面板操作后的页面更新闭环。