一套 Schema 驱动四视图:记 useCrudSchemas 的设计与实践

在企业管理后台开发中,CRUD 页面往往占据 80% 以上的开发量。搜索、表格、表单、详情------四个视图,四套配置,稍有改动就四处修改。本文介绍一个生产验证过的方案:用单一 Schema 同时驱动四个视图,将 CRUD 页面的开发效率提升一个数量级。

一、问题:CRUD 页面的四份配置

一个典型的管理后台页面长这样:

css 复制代码
┌──────────────────────────────────────────────┐
│  🔍 搜索区:用户名[___] 状态[___]  [搜索] [重置] │
├──────────────────────────────────────────────┤
│  📋 表格区:| 用户名 | 状态 | 创建时间 | 操作   │
│             | 张三   | 启用 | 01-01   | 编辑 删除│
├──────────────────────────────────────────────┤
│  ✏️ 表单弹窗:用户名[___] 状态[___] [确定] [取消] │
├──────────────────────────────────────────────┤
│  📄 详情抽屉:用户名:张三  状态:启用  时间:...  │
└──────────────────────────────────────────────┘

常规做法是分别维护四份配置:

ts 复制代码
// ❌ 四份独立配置,字段信息重复四遍
const searchConfig = [{ field: 'username', label: '用户名', component: 'Input' }, ...]
const tableColumns  = [{ field: 'username', label: '用户名', width: 120 }, ...]
const formSchema    = [{ field: 'username', label: '用户名', component: 'Input', required: true }, ...]
const detailSchema  = [{ field: 'username', label: '用户名' }, ...]

当字段名从 username 改成 userName,或者新增一个字段时,四个地方都要改。字段越多,维护成本指数级上升。

二、方案:单一 Schema,按需派生

useCrudSchemas 的核心思想很简单:

less 复制代码
一份 CrudSchema[]  →  →  →  {
                           searchSchema   // 搜索表单配置
                           tableColumns   // 表格列配置
                           formSchema     // 编辑表单配置
                           detailSchema   // 详情描述配置
                         }

一份数据,四个视图。 字段的基本信息(fieldlabel)只写一次,各视图的特化配置通过 searchtableformdetail 子对象覆盖。

Schema 定义

ts 复制代码
export type CrudSchema = {
  field: string          // 字段名 --- 四个视图共用
  label: string          // 标签 --- 四个视图共用
  dictType?: string      // 字典类型 --- 自动注入选项 & 格式化器
  dictClass?: 'string' | 'number' | 'boolean'  // 字典值类型

  isSearch?: boolean              // 是否出现在搜索区
  search?: CrudSearchParams       // 搜索区特化配置
  isTable?: boolean               // 是否出现在表格
  table?: CrudTableParams         // 表格特化配置
  isForm?: boolean                // 是否出现在表单
  form?: CrudFormParams           // 表单特化配置
  isDetail?: boolean              // 是否出现在详情
  detail?: CrudDescriptionsParams // 详情特化配置
  children?: CrudSchema[]         // 支持树形结构(多级表头)
}

每个视图的 show 属性控制可见性,其余配置覆盖默认值。没有 show: false 则默认显示。

使用示例

ts 复制代码
const { allSchemas } = useCrudSchemas([
  {
    field: 'username',
    label: '用户名',
    search: { component: 'Input' },
    table: { width: 120 },
    form: { component: 'Input', required: true },
    detail: { span: 12 },
  },
  {
    field: 'status',
    label: '状态',
    dictType: 'common_status',      // 🔥 字典类型 --- 自动处理一切
    dictClass: 'number',
    search: { component: 'Select' },
    form: { component: 'Select', required: true },
  },
  {
    field: 'createTime',
    label: '创建时间',
    isSearch: false,                 // 不在搜索区出现
    form: { component: 'DatePicker', show: false },  // 表单中隐藏
    detail: { dateFormat: 'YYYY-MM-DD HH:mm:ss' },
  },
])

// allSchemas.searchSchema  → 搜索表单配置(2个字段)
// allSchemas.tableColumns   → 表格列配置(3列,status 自动带 DictTag 格式化)
// allSchemas.formSchema     → 编辑表单配置(2个字段)
// allSchemas.detailSchema   → 详情描述配置(3个字段)

三、核心设计要点

3.1 按视图过滤与合并

四个过滤函数的逻辑一致,以 filterSearchSchema 为例:

ts 复制代码
const filterSearchSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => {
  const searchSchema: FormSchema[] = []

  eachTree(crudSchema, (schemaItem: CrudSchema) => {
    // 1. 可见性判断:isSearch 或 search.show
    if (schemaItem?.isSearch || schemaItem.search?.show) {
      // 2. 默认组件:无指定时用 Input,有 dictType 时自动切 Select
      let component = schemaItem?.search?.component || 'Input'

      // 3. 字典自动注入选项
      if (schemaItem.dictType) {
        options.push({ label: '全部', value: '' })  // 搜索区加"全部"选项
        getDictOptions(schemaItem.dictType).forEach(d => options.push(d))
        if (!schemaItem.search?.component) component = 'Select'
      }

      // 4. 合并:默认值 → search 配置 → dict 派生配置
      const searchSchemaItem = merge(
        { component, ...schemaItem.search, field: schemaItem.field, label: ... },
        { componentProps: comonentProps }
      )

      // 5. 异步 API 选项加载
      if (searchSchemaItem.api) {
        searchRequestTask.push(async () => {
          const res = await searchSchemaItem.api()
          // 找到已推入 schema 的对应项,更新其 options
          allSchemas.searchSchema[index].componentProps.options = filterOptions(res)
        })
      }

      delete searchSchemaItem.show
      searchSchema.push(searchSchemaItem)
    }
  })

  // 6. 批量执行异步任务
  for (const task of searchRequestTask) { task() }
  return searchSchema
}

设计亮点:

步骤 说明
eachTree 递归遍历 支持 children 嵌套,处理多级表头场景
可见性判断 isSearch 为 false 即跳过,也可以 search.show: false 精确控制
字典自动注入 声明 dictType 后自动获取选项、自动切换组件类型
merge 合并策略 先放默认值,再放用户配置,保证用户配置优先级最高
延迟收集异步任务 先同步收集所有 schema item,再统一执行异步 API 调用,避免阻塞主流程

3.2 表格的字典自动格式化

表格视图的处理有一个巧妙的自动化:

ts 复制代码
const filterTableSchema = (crudSchema: CrudSchema[]): TableColumn[] => {
  const tableColumns = treeMap(crudSchema, {
    conversion: (schema: CrudSchema) => {
      if (schema?.isTable !== false && schema?.table?.show !== false) {
        // 🔥 自动注入 DictTag 格式化器
        if (!schema.formatter && schema.dictType) {
          schema.formatter = (_: Recordable, __: TableColumn, cellValue: any) => {
            return h(DictTag, { type: schema.dictType!, value: cellValue })
          }
        }
        return { ...schema.table, ...schema }
      }
    }
  })

  // 二次过滤:去掉 treeMap 产生的 undefined 节点
  return filter(tableColumns, (data) => !!data.field)
}

声明 dictType: 'common_status' 后,表格列自动使用 DictTag 组件渲染,将 0/1 显示为带颜色的"启用"/"停用"标签------零额外配置

treeMap + filter 的两阶段处理也很巧妙:

  1. treeMap 递归转换树结构,不满足条件的节点返回 undefined
  2. filter 二次过滤去掉 undefined,并清理空 children

3.3 表单的字典类型区分

表单视图对字典的处理更精细,按 dictClass 区分三种类型:

ts 复制代码
if (schemaItem.dictType) {
  if (schemaItem.dictClass === 'number') {
    getIntDictOptions(schemaItem.dictType)    // value 转 int
  } else if (schemaItem.dictClass === 'boolean') {
    getBoolDictOptions(schemaItem.dictType)   // value 转 boolean
  } else {
    getDictOptions(schemaItem.dictType)       // value 保持 string
  }
}

这样保证了表单提交时,status 字段的值类型与后端接口完全一致------string、number、boolean 严格区分避免类型转换 bug。

3.4 详情视图的日期格式化

ts 复制代码
const filterDescriptionsSchema = (crudSchema: CrudSchema[]): DescriptionsSchema[] => {
  eachTree(crudSchema, (schemaItem: CrudSchema) => {
    if (schemaItem?.isDetail !== false && schemaItem.detail?.show !== false) {
      // 日期格式化优先级:detail.dateFormat > 自动推导
      if (schemaItem.detail?.dateFormat || schemaItem.formatter == 'formatDate') {
        descriptionsSchemaItem.dateFormat =
          schemaItem?.detail?.dateFormat || 'YYYY-MM-DD HH:mm:ss'
      }
      // dictType 透传,由详情组件自行处理字典渲染
      if (schemaItem.dictType) {
        descriptionsSchemaItem.dictType = schemaItem.dictType
      }
    }
  })
}

细节处理到位:用户可以通过 detail.dateFormat 自定义格式,也可以通过 formatter: 'formatDate' 标记自动获得默认格式。

四、架构图

css 复制代码
                        CrudSchema[]
                             │
              ┌──────────────┼──────────────┐
              │              │              │
     eachTree 遍历        treeMap 递归      eachTree 遍历
              │              │              │
    ┌─────────▼─────────┐   │   ┌──────────▼──────────┐
    │ filterSearchSchema │   │   │ filterFormSchema     │
    │                    │   │   │                      │
    │ • 可见性判断       │   │   │ • 可见性判断         │
    │ • dictType → 选项  │   │   │ • dictClass 类型区分  │
    │ • api 异步加载     │   │   │ • api 异步加载       │
    │ • merge 合并配置   │   │   │ • 默认值设置          │
    └────────┬───────────┘   │   └──────────┬───────────┘
             │               │              │
             ▼               ▼              ▼
      searchSchema    tableColumns    formSchema    detailSchema
      (FormSchema[])  (TableColumn[]) (FormSchema[]) (DescriptionsSchema[])

五、进阶技巧

5.1 异步选项加载

当选项数据来自后端接口而非字典时:

ts 复制代码
{
  field: 'deptId',
  label: '所属部门',
  isSearch: true,
  search: {
    component: 'Select',
    api: () => getDeptList(),  // 异步加载部门列表
  },
}

api 返回的数组会被自动注入到对应字段的 componentProps.options,并经过 i18n 翻译。

5.2 多级表头

CrudSchema 支持 children,利用 treeMap 递归转换,天然支持多级表头:

ts 复制代码
{
  label: '基本信息',
  children: [
    { field: 'username', label: '用户名' },
    { field: 'nickname', label: '昵称' },
  ]
}

5.3 列排序辅助

额外导出的 sortTableColumns 工具函数,用于将指定列移到表格最前面:

ts 复制代码
sortTableColumns(allSchemas.tableColumns, 'selection')

配合 ag-grid 或 Element Plus Table 使用,灵活控制列顺序。

六、适用场景与局限

✅ 适合

  • 标准 CRUD 页面:字段在搜索/表格/表单/详情四个视图都有出现
  • 字典驱动型字段多:状态、类型、分类等枚举字段,一次声明自动处理
  • 快速原型开发:从一份 Schema 直接生成完整功能页面

⚠️ 不适合

  • 四个视图字段差异极大:若大部分字段只在某一个视图出现,单一 Schema 的优势不明显
  • 高度定制化页面:如复杂的自定义搜索面板、非标表格渲染,Schema 声明式配置力不从心

七、总结

useCrudSchemas 的核心价值可以归结为三点:

  1. 消除重复fieldlabeldictType 写一次,四个视图自动消费
  2. 智能推导 :有 dictType 自动选组件、自动注选项、自动格式化;有 dateFormat 自动处理日期显示
  3. 渐进增强 :默认行为覆盖 80% 场景,剩余 20% 通过 search/table/form/detail 子配置精确覆盖

在一个有 50+ CRUD 页面的企业管理后台中,这个 Hook 将单个页面的配置代码从 ~200 行减少到 ~60 行,字段级别的改动从四处修改变成一处修改。它不是一个技术炫技的产物,而是在重复劳动中生长出来的自动化方案------这正是业务开发中最值得沉淀的东西。


相关推荐
狂炫冰美式6 分钟前
人均配了AI, 为什么公司还是没变快? 🤔 本质还是分布式系统问题
前端·后端·架构
铁皮饭盒1 小时前
Next.js 风格路由内置?Bun FileSystemRouter 凭啥这么香
javascript
乘风gg1 小时前
多 Agent 不是万能的!搞懂这 5 个原则,少走 1 年弯路!
前端·agent·ai编程
猩猩程序员2 小时前
Vercel 推出 Agent 框架 Eve:让 AI Agent 像写 Web 应用一样简单
前端
小林ixn2 小时前
别再背八股了!从 5 个真实场景彻底搞懂 JavaScript 的 this
javascript
爱读源码的大都督2 小时前
Claude Code源码分析(三):为什么系统提示词中需要有tools呢?
前端·人工智能·后端
爱勇宝2 小时前
Claude Code 被曝暗藏“隐形检测”代码:封代理不是最可怕的,可怕的是你根本不知道它在干什么
前端·后端·程序员
小牛不牛的程序员2 小时前
我用 Claude Code 半天撸完了一个完整网站,AI 编程到底提升了多少效率?
前端
东风破_2 小时前
JavaScript 面试常考的字符串算法:从反转字符串到回文判断
前端·javascript
巴勒个啦2 小时前
D3.js 入门实战:用力导向图可视化项目依赖关系
javascript