这是 Elpis 框架系列的第四篇。第三篇讲了 DSL 配置如何驱动菜单、路由、搜索栏和表格。但一个完整的 CRUD 页面还缺三块:新增表单、编辑表单、详情面板。这一篇补上最后的拼图——如何用同一套 Schema 配置,驱动表单渲染、数据回填、字段校验和 API 提交。
一、要补什么
上一篇结束时,schema-view 已经能渲染搜索栏和表格了。但表格里的"新增"、"修改"、"查看详情"按钮点了之后什么都不会发生。
这一篇要做的事情:
- 实现
schema-form通用表单组件,根据配置动态渲染表单控件 - 实现三个动态组件:
create-form(新增)、edit-form(编辑)、detail-panel(详情) - 用 AJV(JSON Schema 校验库)做表单字段校验
- 打通按钮点击 → 弹出表单 → 填写/校验 → 提交 API → 刷新表格的完整链路
二、动态组件是怎么挂上去的
上一篇讲过,表格按钮通过 eventKey: "showComponent" 声明"点击后要展示一个组件",eventOption.comName 指定展示哪个组件。
// model 配置
tableConfig: {
headerButtons: [
{ label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } },
],
rowButtons: [
{ label: "查看详情", eventKey: "showComponent", eventOption: { comName: "detailPanel" } },
{ label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
],
}
comName 对应的 Vue 组件在 component-config.js 中注册:
// 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 },
};
schema-view 根据配置中的 componentConfig 动态渲染这些组件:
<!-- schema-view.vue -->
<component
v-for="(item, key) in components"
:key="key"
:is="ComponentConfig[key]?.component"
ref="comListRef"
@command="onComponentCommand"
/>
点击按钮时,schema-view 通过 ref 找到对应的组件实例,调用它的 show() 方法:
// schema-view.vue
function showComponent({ buttonConfig, rowData }) {
const { comName } = buttonConfig.eventOption;
const comRef = comListRef.value.find((item) => item.name === comName);
comRef.show(rowData); // rowData 是当前行数据,新增时为 undefined
}
每个动态组件都通过 defineExpose 暴露 name 和 show 两个属性。name 用于匹配,show 用于触发展示。
这套机制的关键是:配置只声明"要展示哪个组件",不关心组件内部怎么实现。新增一种动态组件只需要两步:写一个 Vue 组件,在 component-config.js 里注册。
三、componentConfig:动态组件的配置来源
每个动态组件需要知道自己的标题、按钮文案、主键字段等信息。这些信息在 model 配置的 componentConfig 中定义:
// model/business/model.js
componentConfig: {
createForm: {
title: "新增商品",
saveBtnText: "新增商品",
},
editForm: {
mainKey: "product_id", // 主键字段,用于查询和提交
title: "修改商品",
saveBtnText: "修改商品",
},
detailPanel: {
mainKey: "product_id",
title: "商品详情",
},
}
useSchema Hook 在解析配置时,会为每个 componentConfig 中的 key 构建对应的 schema:
// hook/schema.js
const { componentConfig } = mItem;
if (componentConfig && Object.keys(componentConfig).length > 0) {
const dtoComponents = {};
for (const comName in componentConfig) {
dtoComponents[comName] = {
schema: buildDtoSchema(configSchema, comName), // 提取 createFormOption / editFormOption 等
config: componentConfig[comName], // 标题、按钮文案等
};
}
components.value = dtoComponents;
}
buildDtoSchema(schema, "createForm") 会从字段定义中提取所有带 createFormOption 的字段,组装成表单需要的 schema。同一个 buildDtoSchema 方法,传不同的 comName,就能提取不同方向的投影。
四、schema-form:通用表单组件
schema-form 是表单的核心渲染器,和搜索栏的 schema-search-bar 思路一样:遍历 schema 的 properties,根据 comType 动态渲染对应的表单控件。
graph TD
A["schema-form 接收 schema + model"] --> B["遍历 schema.properties"]
B --> C{"comType"}
C -->|input| D["Input 组件"]
C -->|inputNumber| E["InputNumber 组件"]
C -->|select| F["Select 组件"]
D --> G["统一接口<br/>validate() + getValue()"]
E --> G
F --> G
style A fill:#fff3e0,stroke:#f57c00
style D fill:#e3f2fd,stroke:#1565c0
style E fill:#e3f2fd,stroke:#1565c0
style F fill:#e3f2fd,stroke:#1565c0
style G fill:#e8f5e9,stroke:#2e7d32
<!-- schema-form.vue -->
<template v-for="(itemSchema, key) in schema.properties">
<component
ref="formComList"
v-show="itemSchema.option.visible !== false"
:is="FormItemConfig[itemSchema.option?.comType]?.component"
:schemaKey="key"
:schema="itemSchema"
:model="model ? model[key] : undefined"
/>
</template>
控件类型映射表:
// form-item-config.js
const FormItemConfig = {
input: { component: Input },
inputNumber: { component: InputNumber },
select: { component: Select },
};
和搜索栏的区别在于:
- 表单控件多了
model属性——编辑表单需要回填已有数据 - 表单控件多了
validate()方法——提交前需要校验 - 表单控件支持
visible和disabled配置——有些字段只读(如编辑时的 ID 字段)
schema-form 对外暴露两个方法:
// schema-form.vue
const validate = () => {
return formComList.value.every((component) => component.validate());
};
const getValue = () => {
return formComList.value.reduce((dtoObj, component) => {
return { ...dtoObj, ...component.getValue() };
}, {});
};
validate() 遍历所有控件,全部通过才返回 true。getValue() 收集所有控件的值,合并成一个对象。
五、表单控件与 AJV 校验
每个表单控件内部都集成了 AJV 校验。AJV 是一个 JSON Schema 校验库,它能根据 JSON Schema 的规则(type、minLength、maxLength、minimum、maximum、pattern、enum 等)自动校验数据。
schema-form 在初始化时创建 AJV 实例,通过 provide 注入给所有子控件:
// schema-form.vue
const Ajv = require("ajv");
const ajv = new Ajv();
provide("ajv", ajv);
以 Input 控件为例,校验流程:
graph TD
A["用户输入 / 失焦触发校验"] --> B{"required 且为空?"}
B -->|是| C["提示:不能为空"]
B -->|否| D["ajv.compile(schema)"]
D --> E{"校验通过?"}
E -->|是| F["清除错误提示"]
E -->|否| G{"错误类型"}
G -->|type| H["提示:类型必须为 string"]
G -->|maxLength| I["提示:最大长度应为 N"]
G -->|minLength| J["提示:最小长度应为 N"]
G -->|pattern| K["提示:格式不正确"]
style C fill:#fce4ec,stroke:#c62828
style H fill:#fce4ec,stroke:#c62828
style I fill:#fce4ec,stroke:#c62828
style J fill:#fce4ec,stroke:#c62828
style K fill:#fce4ec,stroke:#c62828
style F fill:#e8f5e9,stroke:#2e7d32
// input.vue
const validate = () => {
validTips.value = null;
// 1. 必填校验
if (schema.option?.required && !dtoValue.value) {
validTips.value = "不能为空";
return false;
}
// 2. AJV Schema 校验
if (dtoValue.value) {
const validate = ajv.compile(schema);
const valid = validate(dtoValue.value);
if (!valid && validate.errors?.[0]) {
const { keyword, params } = validate.errors[0];
if (keyword === "type") validTips.value = `类型必须为 ${schema.type}`;
if (keyword === "maxLength")
validTips.value = `最大长度应为 ${params.limit}`;
if (keyword === "minLength")
validTips.value = `最小长度应为 ${params.limit}`;
if (keyword === "pattern") validTips.value = `格式不正确`;
return false;
}
}
return true;
};
AJV 的 compile 方法接收一个 JSON Schema 对象,返回一个校验函数。调用校验函数传入数据,返回 true/false,失败时 validate.errors 包含详细的错误信息。
这意味着校验规则直接写在字段的 Schema 定义中(type、minLength、maxLength、pattern 等),不需要额外写校验逻辑。JSON Schema 本身就是校验规则的声明。
InputNumber 控件的校验类似,但处理的是 minimum 和 maximum:
// input-number.vue
if (keyword === "minimum") validTips.value = `最小值应为 ${params.limit}`;
if (keyword === "maximum") validTips.value = `最大值应为 ${params.limit}`;
Select 控件校验枚举范围:
// select.vue
let dtoEnum = schema.option?.enumList?.map((item) => item.value) ?? [];
const validate = ajv.compile({ ...schema, enum: dtoEnum });
// 如果选中的值不在枚举列表中 → "取值超出枚举范围"
每个控件都在失焦(blur)或值变化(change)时触发校验,实时反馈错误信息。提交时 schema-form 再做一次全量校验。
六、placeholder 自动生成
表单控件会根据 Schema 中的校验规则自动生成 placeholder 提示:
// input.vue
const { minLength, maxLength, pattern } = schema;
const ruleList = [];
if (schema.option?.placeholder) ruleList.push(schema.option.placeholder);
if (minLength) ruleList.push(`最小长度: ${minLength}`);
if (maxLength) ruleList.push(`最大长度: ${maxLength}`);
if (pattern) ruleList.push(`格式: ${pattern}`);
placeholder.value = ruleList.join("|");
如果一个字段定义了 minLength: 2, maxLength: 50,输入框的 placeholder 会自动显示 最小长度: 2|最大长度: 50。用户不需要看文档就知道输入要求。
七、三个动态组件的实现
7.1 create-form:新增表单
sequenceDiagram
participant 用户
participant CreateForm as create-form
participant SchemaForm as schema-form
participant API as Koa API
用户->>CreateForm: 点击"新增商品"按钮
CreateForm->>CreateForm: show() → 打开 Drawer
CreateForm->>SchemaForm: 传入 createForm 的 schema
SchemaForm->>SchemaForm: 动态渲染表单控件
用户->>SchemaForm: 填写表单
用户->>CreateForm: 点击"保存"
CreateForm->>SchemaForm: validate() 校验
SchemaForm-->>CreateForm: 校验通过
CreateForm->>SchemaForm: getValue() 获取表单值
CreateForm->>API: POST /api/proj/product
API-->>CreateForm: { success: true }
CreateForm->>CreateForm: 关闭 Drawer
CreateForm->>CreateForm: emit("command", { event: "loadTableData" })
核心代码:
// create-form.vue
const { api, components } = inject("schemaViewData");
const show = () => {
const { config } = components.value[name.value];
title.value = config.title;
saveBtnText.value = config.saveBtnText;
isShow.value = true;
};
const save = async () => {
if (!schemaFormRef.value.validate()) return; // 校验不通过就不提交
const res = await $curl({
method: "post",
url: api.value,
data: { ...schemaFormRef.value.getValue() },
});
if (res?.success) {
ElNotification({ title: "创建成功", type: "success" });
close();
emit("command", { event: "loadTableData" }); // 通知表格刷新
}
};
7.2 edit-form:编辑表单
编辑表单比新增多两个步骤:根据主键查询已有数据,回填到表单中。
sequenceDiagram
participant 用户
participant EditForm as edit-form
participant SchemaForm as schema-form
participant API as Koa API
用户->>EditForm: 点击行"修改"按钮
EditForm->>EditForm: show(rowData) → 提取主键值
EditForm->>API: GET /api/proj/product?product_id=1
API-->>EditForm: 返回商品详情
EditForm->>SchemaForm: 传入 schema + model(已有数据)
SchemaForm->>SchemaForm: 渲染控件并回填数据
用户->>SchemaForm: 修改字段
用户->>EditForm: 点击"保存"
EditForm->>SchemaForm: validate() + getValue()
EditForm->>API: PUT /api/proj/product
API-->>EditForm: { success: true }
EditForm->>EditForm: 关闭 + 通知表格刷新
和新增的区别:
// edit-form.vue
const show = (rowData) => {
const { config } = components.value[name.value];
mainKey.value = config.mainKey; // "product_id"
mainValue.value = rowData[config.mainKey]; // 从行数据中取主键值
isShow.value = true;
fetchFormData(); // 根据主键查询详情
};
const fetchFormData = async () => {
const res = await $curl({
method: "get",
url: api.value,
query: { [mainKey.value]: mainValue.value }, // GET /api/proj/product?product_id=1
});
dtoModel.value = res.data; // 回填到 schema-form
};
const save = async () => {
if (!schemaFormRef.value.validate()) return;
const res = await $curl({
method: "put", // 用 PUT 而不是 POST
url: api.value,
data: {
[mainKey.value]: mainValue.value, // 提交时带上主键
...schemaFormRef.value.getValue(),
},
});
// ...
};
schema-form 接收 model 属性后,每个控件会用 model[key] 作为初始值:
// input.vue
const initData = () => {
dtoValue.value = model.value ?? schema.option?.default;
};
model.value 有值就用已有数据,没有就用配置中的默认值。
编辑表单中有些字段需要只读(比如商品 ID 不能改),通过 disabled: true 控制:
// model 配置
product_id: {
editFormOption: {
comType: "input",
disabled: true, // 编辑时不可修改
},
}
v-bind="schema.option" 会把 disabled 透传给 ElementPlus 的 el-input,输入框自动变为禁用状态。
7.3 detail-panel:详情面板
详情面板不需要表单控件,直接遍历 schema 展示 label + value:
<!-- detail-panel.vue -->
<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>
打开时根据主键查询详情数据,和 edit-form 的 fetchFormData 逻辑一样。区别是详情面板只展示不编辑,不需要 schema-form。
八、完整 CRUD 事件流
把所有操作串起来,一个 schema 模块的完整 CRUD 事件流:
graph TD
A["页面加载"] --> B["schema-view 解析配置"]
B --> C["渲染搜索栏 + 表格 + 动态组件"]
C --> D["请求 GET /list 填充表格"]
D --> E{"用户操作"}
E -->|搜索| F["收集搜索参数 → 重新请求 /list"]
E -->|点击新增| G["打开 create-form"]
E -->|点击修改| H["打开 edit-form"]
E -->|点击详情| I["打开 detail-panel"]
E -->|点击删除| J["请求 DELETE"]
G --> K["填写 → 校验 → POST → 刷新表格"]
H --> L["查询回填 → 修改 → 校验 → PUT → 刷新表格"]
I --> M["查询 → 展示详情"]
J --> N["确认 → DELETE → 刷新表格"]
K --> D
L --> D
N --> D
style A fill:#e8f5e9,stroke:#2e7d32
style G fill:#e3f2fd,stroke:#1565c0
style H fill:#fff3e0,stroke:#f57c00
style I fill:#f3e5f5,stroke:#6a1b9a
style J fill:#fce4ec,stroke:#c62828
动态组件通过 emit("command", { event: "loadTableData" }) 通知 schema-view 刷新表格:
// schema-view.vue
const onComponentCommand = (data) => {
if (data.event === "loadTableData") {
tablePanelRef.value.loadTableData();
}
};
这是一个松耦合的通信方式——动态组件不直接操作表格,只发出一个事件,schema-view 作为协调者决定怎么响应。
九、服务端 CRUD API
配置中的 api: "/api/proj/product" 是一个基础路径,框架按照 RESTful 约定拼接完整的 API:
| 操作 | HTTP 方法 | URL | 说明 |
|---|---|---|---|
| 列表 | GET | /api/proj/product/list | 搜索栏参数作为 query |
| 详情 | GET | /api/proj/product | 主键作为 query |
| 新增 | POST | /api/proj/product | 表单数据作为 body |
| 修改 | PUT | /api/proj/product | 主键 + 表单数据作为 body |
| 删除 | DELETE | /api/proj/product | 主键作为 body |
服务端对应的 Controller:
// app/controller/business.js
async create(ctx) {
const { product_name, price, inventory } = ctx.request.body;
this.success(ctx, { product_id: Date.now(), product_name, price, inventory });
}
async update(ctx) {
const { product_id, product_name, price, inventory } = ctx.request.body;
this.success(ctx, { product_id, product_name, price, inventory });
}
async get(ctx) {
const { product_id } = ctx.request.query;
const productItem = this.getProductList(ctx).find(
(item) => item.product_id === product_id,
);
this.success(ctx, productItem);
}
每个 API 都有对应的 Router Schema 做参数校验:
// app/router-schema/business.js
"/api/proj/product": {
post: {
body: {
type: "object",
properties: {
product_name: { type: "string" },
price: { type: "number" },
inventory: { type: "number" },
},
required: ["product_name"],
},
},
put: {
body: {
type: "object",
properties: {
product_id: { type: "string" },
product_name: { type: "string" },
price: { type: "number" },
inventory: { type: "number" },
},
required: ["product_name", "product_id"],
},
},
}
前端用 AJV + JSON Schema 校验,后端也用 AJV + JSON Schema 校验。同一套 Schema 标准,前后端双重保障。
十、从配置到完整 CRUD 页面
回到最开始的问题:一个完整的 CRUD 页面需要多少配置?
{
key: "product",
name: "商品管理",
menuType: "module",
moduleType: "schema",
schemaConfig: {
api: "/api/proj/product",
schema: {
type: "object",
properties: {
product_name: {
type: "string", label: "商品名称",
tableOption: { width: 200 },
searchOption: { comType: "dynamicSelect", api: "/api/proj/product_enum/list" },
createFormOption: { comType: "input" },
editFormOption: { comType: "input" },
detailPanelOption: {},
},
price: {
type: "number", label: "价格",
tableOption: { width: 200 },
searchOption: { comType: "select", enumList: [...] },
createFormOption: { comType: "inputNumber" },
editFormOption: { comType: "inputNumber" },
detailPanelOption: {},
},
// ... 其他字段
},
required: ["product_name"],
},
},
tableConfig: {
headerButtons: [{ label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } }],
rowButtons: [
{ label: "查看详情", eventKey: "showComponent", eventOption: { comName: "detailPanel" } },
{ label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
{ label: "删除", eventKey: "remove", eventOption: { params: { product_id: "schema::product_id" } } },
],
},
componentConfig: {
createForm: { title: "新增商品", saveBtnText: "新增商品" },
editForm: { mainKey: "product_id", title: "修改商品", saveBtnText: "修改商品" },
detailPanel: { mainKey: "product_id", title: "商品详情" },
},
}
这大约 50 行配置,产出的是:一个带搜索栏(支持输入框、下拉框、动态下拉框、日期范围)、带分页表格、带新增/编辑表单(含字段校验)、带详情面板、带删除确认的完整 CRUD 页面。传统写法大概需要 300 行以上的 Vue 代码 + 路由配置 + API 对接代码。
而且这份配置是可继承的——写在 Model 里,所有 Project 自动拥有,Project 只需要写差异部分。