更优雅的编写后台管理系统

4,065 阅读7分钟

编写管理系统存在的问题

通常的管理系统开发,会使用 vue 或者 react 等框架结合第三方优质的开源 UI 库,例如 ant design 或者 element ui。亦或者是在此二者的基础上进一步封装的 ant design pro 和 vue element admin 等框架。此类框架的确减轻了项目基础建设的负担,但是管理模块还是得编写大量的增删改查等代码,而在编写增删改查时会有以下效率低下且重复的问题存在。

“增”,需要将接口的表单字段翻译成对应的表单元素,同时需要编写对应的校验,提交表单时调用对应接口,新增成功后刷新表格等重复逻辑。

“删”,需要在点击删除按钮后打开警告窗口提示用户删除,用户同意后调用接口进行删除,删除后调用接口刷新表格。

“改”,通常情况下可以复用新增的表单,但是还是需要编写将数据回填表单,调用接口,刷新表格等重复逻辑。

“查”,会有更多要处理的重复逻辑,例如定义表格字段、处理筛选逻辑、处理分页逻辑、处理排序逻辑、调用接口等。

通过上述描述,可以知道一个管理系统中的增删改查存在大量重复逻辑,本文旨在提出一种对于前端来说“新”的设计思想,解决编写增删改查效率低下的问题。

对一个管理界面的抽象

想要提高增删改查的效率,那么需要对管理界面进行抽象,发现什么是通用的逻辑,并将之封装,才能提升效率。

一个管理界面在通常情况下,字段有以下两种表现。

  1. 表单里的字段,大部分会显示在表格中
  2. 表格中的字段,一定会体现在筛选栏

基于这一现象,可以抽离出一个界面中通用的字段信息,通过这一份信息去渲染表单、表格以及筛选,那么不就减轻了不少工作量。

image.png 对此,可以抽象出 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: '信息'
    })
}

通过上述模板代码的描述,可以渲染出如下的一个表单界面:

image.png

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 行代码,既完成了筛选的渲染,也完成了表格的渲染。

image.png

同时它还是管理界面的“驱动器”,通过暴露出的生命周期,实现管理界面各个模块的联动,生命周期如下所示:

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 默认是一个下拉选择。

image.png

在此体验可能不好,因此我们可以修改为 RadioWidget,如下:

gender = ModelForm.ChoicesField({
    verboseName: '性别',
    choices: [{
        label: '男',
        value: 1
    }, {
        label: '女',
        value: 0
    }],
    widget: RadioWidget
})

最终渲染如下:

image.png

信息一栏使用输入框过于单调,我们可以使用富文本组件来丰富它。

info = ModelForm.InputField({
    verboseName: '信息',
    widget: RichTextWidget
})

image.png

需要上传资料,我们可以使用 UploadWidget。

doc = ModelForm.InputField({
    verboseName: '资料',
    widget: UploadWidget
})

image.png

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>
    }
}

得到如下结果:

image.png

筛选需要定制?

使用 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'
        })
    }
}

image.png

界面需要定制

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>
    }
}

image.png

选择的数据来自服务器

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
})

image.png

Django

以上设计思想源自 Django,一个非常厉害的 python 全栈框架。当然本文还有很多特殊的场景没有表达,但基本都能支持,毕竟 react 如此灵活。如果真有什么场景无法支持到,那不直接手写得了,毕竟这只是一个提升效率的轻量级框架,不会对工程整体造成什么影响。