Elpis Schema 动态组件与表单:配置驱动的完整 CRUD 闭环

这是 Elpis 框架系列的第四篇。第三篇讲了 DSL 配置如何驱动菜单、路由、搜索栏和表格。但一个完整的 CRUD 页面还缺三块:新增表单、编辑表单、详情面板。这一篇补上最后的拼图------如何用同一套 Schema 配置,驱动表单渲染、数据回填、字段校验和 API 提交。


一、要补什么

上一篇结束时,schema-view 已经能渲染搜索栏和表格了。但表格里的"新增"、"修改"、"查看详情"按钮点了之后什么都不会发生。

这一篇要做的事情:

  1. 实现 schema-form 通用表单组件,根据配置动态渲染表单控件
  2. 实现三个动态组件:create-form(新增)、edit-form(编辑)、detail-panel(详情)
  3. 用 AJV(JSON Schema 校验库)做表单字段校验
  4. 打通按钮点击 → 弹出表单 → 填写/校验 → 提交 API → 刷新表格的完整链路

二、动态组件是怎么挂上去的

上一篇讲过,表格按钮通过 eventKey: "showComponent" 声明"点击后要展示一个组件",eventOption.comName 指定展示哪个组件。

javascript 复制代码
// 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 中注册:

javascript 复制代码
// 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 动态渲染这些组件:

html 复制代码
<!-- schema-view.vue -->
<component
  v-for="(item, key) in components"
  :key="key"
  :is="ComponentConfig[key]?.component"
  ref="comListRef"
  @command="onComponentCommand"
/>

点击按钮时,schema-view 通过 ref 找到对应的组件实例,调用它的 show() 方法:

javascript 复制代码
// 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 暴露 nameshow 两个属性。name 用于匹配,show 用于触发展示。

这套机制的关键是:配置只声明"要展示哪个组件",不关心组件内部怎么实现 。新增一种动态组件只需要两步:写一个 Vue 组件,在 component-config.js 里注册。


三、componentConfig:动态组件的配置来源

每个动态组件需要知道自己的标题、按钮文案、主键字段等信息。这些信息在 model 配置的 componentConfig 中定义:

javascript 复制代码
// model/business/model.js
componentConfig: {
  createForm: {
    title: "新增商品",
    saveBtnText: "新增商品",
  },
  editForm: {
    mainKey: "product_id",     // 主键字段,用于查询和提交
    title: "修改商品",
    saveBtnText: "修改商品",
  },
  detailPanel: {
    mainKey: "product_id",
    title: "商品详情",
  },
}

useSchema Hook 在解析配置时,会为每个 componentConfig 中的 key 构建对应的 schema:

javascript 复制代码
// 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["统一接口
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
html 复制代码
<!-- 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>

控件类型映射表:

javascript 复制代码
// form-item-config.js
const FormItemConfig = {
  input: { component: Input },
  inputNumber: { component: InputNumber },
  select: { component: Select },
};

和搜索栏的区别在于:

  1. 表单控件多了 model 属性------编辑表单需要回填已有数据
  2. 表单控件多了 validate() 方法------提交前需要校验
  3. 表单控件支持 visibledisabled 配置------有些字段只读(如编辑时的 ID 字段)

schema-form 对外暴露两个方法:

javascript 复制代码
// 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() 遍历所有控件,全部通过才返回 truegetValue() 收集所有控件的值,合并成一个对象。


五、表单控件与 AJV 校验

每个表单控件内部都集成了 AJV 校验。AJV 是一个 JSON Schema 校验库,它能根据 JSON Schema 的规则(type、minLength、maxLength、minimum、maximum、pattern、enum 等)自动校验数据。

schema-form 在初始化时创建 AJV 实例,通过 provide 注入给所有子控件:

javascript 复制代码
// 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
javascript 复制代码
// 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 定义中(typeminLengthmaxLengthpattern 等),不需要额外写校验逻辑。JSON Schema 本身就是校验规则的声明。

InputNumber 控件的校验类似,但处理的是 minimummaximum

javascript 复制代码
// input-number.vue
if (keyword === "minimum") validTips.value = `最小值应为 ${params.limit}`;
if (keyword === "maximum") validTips.value = `最大值应为 ${params.limit}`;

Select 控件校验枚举范围:

javascript 复制代码
// 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 提示:

javascript 复制代码
// 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" })

核心代码:

javascript 复制代码
// 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: 关闭 + 通知表格刷新

和新增的区别:

javascript 复制代码
// 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] 作为初始值:

javascript 复制代码
// input.vue
const initData = () => {
  dtoValue.value = model.value ?? schema.option?.default;
};

model.value 有值就用已有数据,没有就用配置中的默认值。

编辑表单中有些字段需要只读(比如商品 ID 不能改),通过 disabled: true 控制:

javascript 复制代码
// model 配置
product_id: {
  editFormOption: {
    comType: "input",
    disabled: true,    // 编辑时不可修改
  },
}

v-bind="schema.option" 会把 disabled 透传给 ElementPlus 的 el-input,输入框自动变为禁用状态。

7.3 detail-panel:详情面板

详情面板不需要表单控件,直接遍历 schema 展示 label + value:

html 复制代码
<!-- 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 刷新表格:

javascript 复制代码
// 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:

javascript 复制代码
// 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 做参数校验:

javascript 复制代码
// 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 页面需要多少配置?

javascript 复制代码
{
  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 只需要写差异部分。

相关推荐
kerli2 小时前
Compose 组件:Box 核心参数及其 Bias 算法
android·前端
luckyCover2 小时前
TypeScript学习系列(二):高级类型篇
前端·typescript
NickJiangDev2 小时前
Elpis NPM 发布:把框架从业务中剥离出来
前端
im_AMBER2 小时前
手撕发布订阅与观察者模式:从原理到实践
前端·javascript·面试
九英里路2 小时前
cpp容器——string模拟实现
java·前端·数据结构·c++·算法·容器·字符串
Justin3go2 小时前
丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术
前端·后端·架构
JokerLee...2 小时前
大屏自适应方案
前端·vue.js·大屏端
dyb-dev2 小时前
我是如何学习 NestJS 的
前端·nestjs·全栈
kyriewen3 小时前
重排、重绘、合成:浏览器渲染的“三兄弟”,你惹不起也躲不过
前端·javascript·浏览器