学习了一个阶段的Elpis框架,现在进行一个阶段性总结
整体架构与数据流

启动顺序 middleware → routerSchema → controller → service → config → extend → router
Schema 设计理念
model/buiness/model.js奠定基类,后续的继承放在model/buiness/project/***.js
javascript
// model/buiness/model.js
module.exports = {
model: 'dashboard',
name: '电商系统',
menu: [
{
key: 'product',
name: '商品管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
product_id: {
type: 'string',
label: '商品ID',
tableOption: {
width: 300,
'show-overflow-tooltip': true
}
},
product_name: {
type: 'string',
label: '商品名称',
tableOption: {
width: 200
},
searchOption: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list'
}
},
price: {
type: 'number',
label: '价格',
tableOption: {
width: 200
},
searchOption: {
comType: 'select',
enumList: [
{
label: '全部',
value: -999
},
{
label: '¥39.9',
value: 399
},
{
label: '¥199',
value: 200
}
]
}
},
inventory: {
type: 'number',
label: '库存',
tableOption: {
width: 200
},
searchOption: {
comType: 'input'
}
},
create_time: {
type: 'string',
label: '创建时间',
tableOption: {},
searchOption: {
comType: 'dateRange'
}
}
}
},
tableConfig: {
headerButtons: [
{
label: '新增商品',
eventKey: 'showComponent',
type: 'primary',
plain: true
}
],
rowButtons: [
{
label: '修改',
eventKey: 'showComponent',
type: 'warning'
},
{
label: '删除',
eventKey: 'remove',
eventOption: {
params: {
product_id: 'schema::product_id'
}
},
type: 'danger'
}
]
},
path: '/todo'
}
},
{
key: 'order',
name: '订单管理',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo'
}
},
{
key: 'client',
name: '客户管理',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo'
}
}
]
}
实现路径model/index.js
javascript
// project 继承 model 方法
const projectExtendModel = (model, project) => {
return _.mergeWith({}, model, project, (modelValue, projectValue) => {
// 处理数组合并的特殊情况
if (_.isArray(modelValue) && _.isArray(projectValue)) {
let result = [];
// 因为 project 继承 model, 所以需要处理修改和新增内容的情况
// project有的键值, model也有 => 修改(重载)
// project有的键值, model没有 => 新增(拓展)
// model有的键值, project没有 => 保留(继承)
// 处理修改和保留
for (let i = 0; i < modelValue.length; i++) {
let modelItem = modelValue[i];
const projectItem = projectValue.find(
(item) => item.key === modelItem.key
);
// project 有, model 也有 =>递归调用 projectExtendModel 方法覆盖修改
result.push(
projectItem ? projectExtendModel(modelItem, projectItem) : modelItem
);
}
// 处理新增
for (let i = 0; i < projectValue.length; i++) {
const projectItem = projectValue[i];
if (!modelValue.find((item) => item.key === projectItem.key)) {
result.push(projectItem);
}
}
return result;
}
});
};
Router-Schema 接口契约 DSL
一份声明 API 入参形态 的 JSON-Schema 映射表。你不需要写校验函数,只要声明"这个接口期望收到什么",框架自动校验。 位置:app/router-schema/*.js
javascript
// app/router-schema/business.js
module.exports = {
'/api/proj/product/list': {
get: {
query: { // 校验 URL query
type: 'object',
properties: { page: { type: 'string' }, size: { type: 'string' } },
required: ['page', 'size']
}
}
},
'/api/proj/product': {
delete: {
body: { // 校验请求体
type: 'object',
properties: { product_id: { type: 'string' } },
required: ['product_id']
}
}
}
}
Model / Menu / Page 配置 DSL
一份配置描述一个完整 CRUD 页面
css
// model/buiness/model.js
module.exports = {
model: 'dashboard', // 模型标识(分组维度)
name: '电商系统', // 项目/模型展示名
menu: [
{
key: 'product',
name: '商品管理',
menuType: 'module', // module | group | sider
moduleType: 'schema', // schema(配置驱动) | custom(自定义页)
schemaConfig: { // ------ 页面编排的核心 DSL ------
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
product_id: { type:'string', label:'商品ID', tableOption:{ width:300 } },
product_name: { type:'string', label:'商品名称',
tableOption:{ width:200 },
searchOption:{ comType:'dynamicSelect', api:'/api/proj/product_enum/list' } },
price: { type:'number', label:'价格',
searchOption:{ comType:'select', enumList:[...] } },
create_time: { type:'string', label:'创建时间',
searchOption:{ comType:'dateRange' } }
}
},
tableConfig: {
headerButtons: [ { label:'新增商品', eventKey:'showComponent', type:'primary' } ],
rowButtons: [
{ label:'修改', eventKey:'showComponent', type:'warning' },
{ label:'删除', eventKey:'remove',
eventOption:{ params:{ product_id:'schema::product_id' } }, type:'danger' }
]
},
path: '/todo'
}
},
{ key:'order', name:'订单管理', menuType:'module', moduleType:'custom', customConfig:{ path:'/todo' } }
]
}
这份配置声明了:
| 字段 | 含义 |
|---|---|
menuType |
module(具体页面)/ group(菜单分组)/ sider(带侧边栏) |
moduleType |
schema(由 schemaConfig 自动生成 CRUD 页)/ custom(走自定义 Vue 页) |
schemaConfig.api |
数据来源接口,组件会自动拼 /list、DELETE 用它本体 |
schema.properties[key] |
一个字段的"多面描述":表格列、搜索项、类型、中文名 |
tableOption |
该字段在表格 中的展现(透传给 el-table-column) |
searchOption |
该字段在搜索栏 中的展现(comType 决定输入控件) |
tableConfig.headerButtons/rowButtons |
表格顶部/行内按钮,eventKey 绑定行为 |
分层继承:model 与 project
同一套模型可以被多个"项目"复用,项目对模型做局部覆盖。这是 elpis 的第二层 DSL 能力。
目录约定:
model/
buiness/
model.js ← 基础模型(通用配置)
project/
jd.js ← 京东项目覆盖
taobao.js ← 淘宝项目覆盖
pdd.js ← 拼多多项目覆盖
model/index.js 的 projectExtendModel 实现了"按 key 深度合并"的继承语义:
- model 有、project 也有(同 key) → 递归覆盖(修改);
- project 有、model 没有 → 追加(扩展);
- model 有、project 没有 → 保留(继承)。
scss
// model/index.js 的核心合并逻辑
modelList.forEach((item) => {
const { model, project } = item
for (const key in project) {
project[key] = projectExtendModel(model, project[key]) // project 继承 model
}
})
数组按 item.key 对齐后再递归合并,保证"同 key 覆盖、异 key 新增"而非简单拼接。
后端如何下发这份配置
app/service/project.js 直接 require('../../model/index') 拿到已继承好的 modelList,app/controller/project.js 暴露三个接口把配置变数据:
| 接口 | 作用 |
|---|---|
GET /api/project?proj_key= |
取某个项目(projKey)的完整配置 |
GET /api/project/list |
取项目列表(用于项目切换器) |
GET /api/project/model_list |
取所有 model + project 的结构化数据 |
前端 dashboard.vue 在 onMounted 拉取 /api/project → 写入 menuStore,菜单点击后根据 moduleType 路由到 /view/dashboard/schema 或自定义页
Schema UI 渲染 DSL
前端不需要为每个页面写表格/表单,而是用"解释器组件"把上层的 schemaConfig 渲染出来。
schema-view 拆分配置
app/pages/dashboard/complex-view/schema-view/hook/schema.js 是关键的"翻译层":它从 menuStore 取到 schemaConfig,用 buildDtoSchema 把同一份 properties 拆成两份视图 schema:
ini
// 只抽取 tableOption → 重命名为 option → 喂给表格
tableSchema = buildDtoSchema(configSchema, 'table')
// 只抽取 searchOption → 重命名为 option → 喂给搜索栏
searchSchema = buildDtoSchema(configSchema, 'search')
buildDtoSchema 的规则:遍历 properties,只保留带 ${comName}Option 的字段,把非 Option 属性(如 type/label)连同 option 一起输出。这样同一字段在表格和搜索栏可以有完全不同的表现,但只声明一次。
schema-table:配置即表格
app/pages/widgets/schema-table/schema-table.vue 直接遍历 schema.properties 生成列:
ini
<template v-for="(schemaItem, key) in schema.properties">
<el-table-column
v-if="schemaItem.option.visible !== false"
:prop="key" :label="schemaItem.label"
v-bind="schemaItem.option" <!-- tableOption 直接透传给 el-table-column -->
/>
</template>
它还会自动:拼 ${api}/list?page&size 拉数据、res.metadata.total 做分页、支持 option.toFixed 格式化数字。
schema-search-bar:配置即表单
app/pages/widgets/schema-search-bar/schema-search-bar.vue 遍历 properties,按 option.comType 选择具体输入控件:
ruby
<component
:is="SearchItemConfig[schemaItem.option?.comType].component"
:schema-key="key" :schema="schemaItem"
/>
app/pages/widgets/schema-search-bar/schema-item-config.js 维护控件映射表:
css
const SearchItemConfig = {
input: { component: input },
select: { component: select },
dynamicSelect: { component: dynamicSelect }, // 下拉项来自远端 api
dateRange: { component: dateRange }
}
设计要点 :
comType是一个有限枚举的词汇表------这正是 DSL 的"语法"特征:只能从约定的控件集里选,组件负责把它落成真实 UI。
按钮与事件的声明式绑定
tableConfig.rowButtons 里的 eventKey 是行为的"动词表":
showComponent→ 打开弹窗表单(自定义实现);remove→ 删除,eventOption.params用'schema::product_id'这样的占位语法,从当前行数据取值后发 DELETE 请求(table-panel.vue解析schema::前缀)。
设计思想小结
elpis 的 DSL 体系有四条主线:
- 声明式优于命令式:接口校验、页面表格、搜索表单都用"描述意图"代替"编写过程"。
- 配置即数据:model/project 本质上是 JSON 配置,可被加载、继承、下发、解释,和代码解耦。
- 分层继承(model → project) :用"覆盖式合并"实现"一套模型、多项目复用",是低代码里"模板 + 实例"的经典模式。
- 解释器模式贯穿始终:Loader 解释声明 → 后端中间件解释契约 → 前端 widget 解释 schema。每一层都只认"数据结构",不认具体业务。