编写管理系统存在的问题
通常的管理系统开发,会使用 vue 或者 react 等框架结合第三方优质的开源 UI 库,例如 ant design 或者 element ui。亦或者是在此二者的基础上进一步封装的 ant design pro 和 vue element admin 等框架。此类框架的确减轻了项目基础建设的负担,但是管理模块还是得编写大量的增删改查等代码,而在编写增删改查时会有以下效率低下且重复的问题存在。
增
“增”,需要将接口的表单字段翻译成对应的表单元素,同时需要编写对应的校验,提交表单时调用对应接口,新增成功后刷新表格等重复逻辑。
删
“删”,需要在点击删除按钮后打开警告窗口提示用户删除,用户同意后调用接口进行删除,删除后调用接口刷新表格。
改
“改”,通常情况下可以复用新增的表单,但是还是需要编写将数据回填表单,调用接口,刷新表格等重复逻辑。
查
“查”,会有更多要处理的重复逻辑,例如定义表格字段、处理筛选逻辑、处理分页逻辑、处理排序逻辑、调用接口等。
通过上述描述,可以知道一个管理系统中的增删改查存在大量重复逻辑,本文旨在提出一种对于前端来说“新”的设计思想,解决编写增删改查效率低下的问题。
对一个管理界面的抽象
想要提高增删改查的效率,那么需要对管理界面进行抽象,发现什么是通用的逻辑,并将之封装,才能提升效率。
一个管理界面在通常情况下,字段有以下两种表现。
- 表单里的字段,大部分会显示在表格中。
- 表格中的字段,一定会体现在筛选栏。
基于这一现象,可以抽离出一个界面中通用的字段信息,通过这一份信息去渲染表单、表格以及筛选,那么不就减轻了不少工作量。
对此,可以抽象出 ModelForm,即表单模型。
ModelForm
在一个表单中,大致可抽象为两种基础类型字段,输入类型,选择类型。输入类型可以理解为不确定的输入项,选择类型是确定的输入项,以此约束用户输入。
输入类型因为不确定性,可以表现为各种输入控件,例如 日期、时间、富文本、地图等各种控件,满足管理系统需求。
选择类型是选项固定的字段,数据来源可以为本地,或者是接口的远端数据。
通过实现以上描述,可以得到如下的一个模板代码:
class StudentModelForm extends ModelForm {
name = ModelForm.InputField({
verboseName: '姓名'
})
birthday = ModelForm.InputField({
verboseName: '出生日期',
widget: DatePickerWidget
})
gender = ModelForm.ChoicesField({
verboseName: '性别',
choices: [{
label: '男',
value: 1
}, {
label: '女',
value: 0
}]
})
classId = ModelForm.ChoicesField({
verboseName: '班级',
choices: [{
label: '三年一班',
value: 1
}, {
label: '三年二班',
value: 2
}, {
label: '三年三班',
value: 3
}]
})
solo = ModelForm.BooleanField({
verboseName: '是否是独生子女'
})
info = ModelForm.InputField({
verboseName: '信息'
})
}
通过上述模板代码的描述,可以渲染出如下的一个表单界面:
ModelAdmin
只有表单是不够的,还需要筛选功能以及表格展示,才能形成一个基础的管理界面。因为 ModelForm 已经包含了足够多的信息来表述表单,那么渲染表格也是可以利用其中的信息,为此封装 ModelAdmin,它是以 ModelForm 为元数据的一个渲染组件,代码示例如下:
class App extends ModelAdmin<any, any> {
filters = ['name', 'birthday', 'gender', 'classId', 'solo']
listDisplay = ['name', 'birthday', 'gender', 'classId', 'solo', 'info']
getModel(): new () => ModelForm {
return StudentModelForm
}
}
仅需 7 行代码,既完成了筛选的渲染,也完成了表格的渲染。
同时它还是管理界面的“驱动器”,通过暴露出的生命周期,实现管理界面各个模块的联动,生命周期如下所示:
class App extends ModelAdmin<any, any> {
filters = ['name', 'birthday', 'gender', 'classId', 'solo']
listDisplay = ['name', 'birthday', 'gender', 'classId', 'solo', 'info']
getModel(): new () => ModelForm {
return StudentModelForm
}
async getTableDataSource(pagination: TemplateTablePagination, filters?: any, orders?: any): Promise<App.Pagination<any>> {}
async onFormAddSubmit(composeValues: any, formValues: any, initialValues: any): Promise<void> {}
async onFormUpdateSubmit(composeValues: any, formValues: any, initialValues: any): Promise<void> {}
getaddFormInitialValues() {}
async getUpdateFormInitialValues(record: any): Promise<any> {}
async onConfirmDeleteRow(record: any): Promise<void> {}
}
getTableDataSource
getTableDataSource 生命周期用于获取表格数据,当表格需要刷新时即会调用。例如,新增、修改、删除了数据,亦或者时进行了切页,筛选、排序等操作,因此getTableDataSource 生命周期能够接收分页,筛选以及排序等数据作为参数进行使用。
onFormAddSubmit
onFormAddSubmit 生命周期用于新增表单提交并通过校验后的操作,该接口接收表单数据以及表单初始化的数据作为参数,通过此生命周期调用创建接口进行新增数据的创建,接口调用成功会同步更新表格数据。
onFormUpdateSubmit
onFormUpdateSubmit 生命周期用于更新表单提交并通过校验后的操作,该接口接收表单数据以及表单初始化数据作为参数,通过此生命周期调用更新接口进行数据的更新,接口调用成功会同步更新表格数据。
getaddFormInitialValues
getaddFormInitialValues 生命周期用于打开新增表单设置初始数据,该函数返回的数据会作为初始表单数据填入。
getUpdateFormInitialValues
getUpdateFormInitialValues 生命周期用于打开更新表单设置初始数据,因为更新数据通常需要做数据回填,因此该函数会接收待编辑的那一行数据作为参数,返回值会被填入表单,此函数是一个异步函数,因此可以在此调用接口,获取详细的数据进行回填,亦或者可以在此做数据修正。
onConfirmDeleteRow
onConfirmDeleteRow 生命周期用于当用户点击确认删除按钮后的操作,该函数接收删除的那一列数据作为参数。
ModelAdmin 会在内部维护当前管理界面各个组件的状态,同时利用暴露的生命周期进行管理界面的差异化。
通过 ModelAdmin 可以减少大量的逻辑编写,例如点击新增打开表单、点击更新回填数据、点击删除打开提示、点击切页进行页面切换、点击搜索调用接口等逻辑编写。
真正实现配置化形式编写管理界面。
Widget
管理界面因为业务繁杂,通常需要各种不同的控件,ModelForm 也需要对此进行适配,为此抽象了 Widget 概念,一个 FormField 可以使用任何 Widget,只要它在对应的业务场景是合理的,例如上述的性别,ChoiceField 默认是一个下拉选择。
在此体验可能不好,因此我们可以修改为 RadioWidget,如下:
gender = ModelForm.ChoicesField({
verboseName: '性别',
choices: [{
label: '男',
value: 1
}, {
label: '女',
value: 0
}],
widget: RadioWidget
})
最终渲染如下:
信息一栏使用输入框过于单调,我们可以使用富文本组件来丰富它。
info = ModelForm.InputField({
verboseName: '信息',
widget: RichTextWidget
})
需要上传资料,我们可以使用 UploadWidget。
doc = ModelForm.InputField({
verboseName: '资料',
widget: UploadWidget
})
Q/A
表格需要定制该怎么办
表格基于 ant design Table 进行开发,拥有完全的 ant design table 能力,只需要使用 Display 装饰器即可。
新增 action 列用于操作。
listDisplay 中新增 action 字段
listDisplay = ['name', 'birthday', 'gender', 'classId', 'solo', 'info', 'action']
同时使用 Display 装饰器进行描述
class App extends ModelAdmin<any, any> {
filters = ['name', 'birthday', 'gender', 'classId', 'solo']
listDisplay = ['name', 'birthday', 'gender', 'classId', 'solo', 'info', 'action']
getModel(): new () => ModelForm {
return StudentModelForm
}
@Display({
name: 'action',
title: '操作'
})
displayAction(val: any, record: any) {
return <div>
<span
className="ml-2 text-blue-400 cursor-pointer"
onClick={() => {
this.modifyRowData(record)
}}>
编辑
</span>
<span
className="text-red-400 ml-2 cursor-pointer"
onClick={() => {
this.deleteRowData(record)
}}>
删除
</span>
</div>
}
}
得到如下结果:
筛选需要定制?
使用 SetFilter 装饰器即可,如下:
class App extends ModelAdmin<any, any> {
filters = ['name', 'parent', 'birthday', 'gender', 'classId', 'solo']
listDisplay = ['name', 'birthday', 'gender', 'classId', 'solo', 'info', 'action']
getModel(): new () => ModelForm {
return StudentModelForm
}
@SetFilter({
name: 'parent'
})
filterParent() {
return ModelForm.InputField({
verboseName: '家长',
name: 'parent'
})
}
}
界面需要定制
ModelAdmin 的本质是一个 react class component,所以重写 render 方法就好了
class App extends ModelAdmin<any, any> {
filters = ['name', 'parent', 'birthday', 'gender', 'classId', 'solo']
listDisplay = ['name', 'birthday', 'gender', 'classId', 'solo', 'info', 'action']
getModel(): new () => ModelForm {
return StudentModelForm
}
renderAdminContent() {
return <div className="flex flex-col" style={{ background: '#fff', marginBottom: '24px' }}>
{this.renderFilter()}
<span className="text-2xl text-red">这是一个定制需求</span>
{this.renderTable()}
{this.getAddFormTemplate()}
{this.getChangeFormTemplate()}
</div>
}
}
选择的数据来自服务器
RemoteDataChoiceWidget 安排上
class RemoteClassDataWidget extends RemoteDataChoiceWidget<any, any> {
getRemoteData(): Promise<{ label: string; value: string | number }[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([{
label: '四年一班',
value: 1
}, {
label: '四年二班',
value: 2
}, {
label: '四年三班',
value: 3
}])
}, 1000)
})
}
}
classId = ModelForm.InputField({
verboseName: '班级',
widget: RemoteClassDataWidget
})
Django
以上设计思想源自 Django,一个非常厉害的 python 全栈框架。当然本文还有很多特殊的场景没有表达,但基本都能支持,毕竟 react 如此灵活。如果真有什么场景无法支持到,那不直接手写得了,毕竟这只是一个提升效率的轻量级框架,不会对工程整体造成什么影响。