1. "通用"二字的分量:学生管理模块的复用场景与设计前提
先交代一下背景。我去年接手了一个教育类后台系统的升级改造,系统分教务管理、学工管理、招生管理三个子应用,业务形态各有侧重,但底层都绕不开一张学生信息表。最初班子里的同学处理方式是各写各的:教务线做了一个学生列表页,学工线又复制了一份改字段,招生线因为报名场景特殊直接另起炉灶。等需求迭代到第二版,问题就彻底暴露了——同一个学生的状态字段,三个系统三种含义,改一次状态枚举要动三个文件,加一个新字段要同步四五张表结构。那段日子,测试提的 bug 有一半是"教务端改了,学工端没跟上"。
所以我后来下决心做一个学生管理模块通用功能,核心目标只有一个:把多系统共用的那 80% 场景沉淀成一套可以被配置驱动的模块,而不是做成三个各写各的页面。这篇文章就围绕我落地这套通用模块的完整思路展开,包括组件怎么拆、列表怎么抽象、表单怎么复用、后端交互怎么约束,以及我在踩坑之后总结出的一套取舍原则。如果你也在做类似的通用 CRUD 模块,或者正要处理多个后台子系统之间的重复功能抽取,这篇应该能给你一些能直接落地的参考。
先说清楚一件事:通用不等于万能。我在设计之初就给自己划了边界。通用模块要解决的是"高频、稳定、模式固定"的能力,比如列表分页、关键字搜索、字段筛选、新增编辑、状态标签、删除确认、详情抽屉;而"业务特有、变化频繁、强定制"的部分,必须留口子给使用方自己扩展,比如某个学院特殊的上报流程、报名场景中独有的一键导入逻辑、权限按钮的差异化控制。如果一开始就想做一个"什么东西都塞进去"的大容器,那这个模块大概率会变成后期维护的灾难。
我最早踩过的坑,就是试图把三个业务线的需求全部抽象进一套配置。结果配置对象越来越长,光字段定义文件就写了上千行,后来为了兼容各种特殊情况,渲染逻辑里到处是if (xxx) ... else if (xxx),比不复用还难读。第二次重构时我把思路换成了"核心固话 + 边缘开放"——列表、分页、搜索、表单提交、删除这些链路做成固定骨架;自定义渲染、自定义校验、自定义请求、按钮权限做成插槽和扩展点。这样改完以后,教务、学工、招生三套系统都能用同一套模块跑起来,而每个系统只需要维护自己的业务扩展文件。
2. 模块骨架怎么搭:目录约定、组件分层与数据流
通用模块真正落地,靠的不是某一个魔法组件,而是一整套互相协作的目录结构和组件体系。我的组织方式分三层:页面入口层、业务封装层、基础组件层。
2.1 目录结构的长什么样
我习惯在一个功能模块下建如下结构,以student-module为例:
src/modules/student-module/ ├── index.vue # 模块入口页面,业务方引入即可 ├── config/ # 配置目录 │ ├── columns.js # 表格列配置 │ ├── searchFields.js # 搜索字段配置 │ ├── formFields.js # 新增/编辑表单字段配置 │ └── statusMap.js # 状态枚举映射与标签颜色 ├── components/ │ ├── StudentSearchPanel.vue # 搜索面板 │ ├── StudentTablePanel.vue # 表格容器 │ ├── StudentFormDrawer.vue # 新增/编辑抽屉 │ ├── StudentDetailDrawer.vue # 详情抽屉 │ └── StudentStatusTag.vue # 状态标签组件 ├── composables/ │ ├── useStudentList.js # 列表请求、分页、筛选组合函数 │ └── useStudentForm.js # 表单提交、校验组合函数 ├── api/ │ └── index.js # 接口请求封装 └── directives/ └── permission.js # 按钮权限指令这套结构的关键点在于:页面入口 index.vue 本身几乎不写逻辑,它只负责把配置对象和组合函数组装起来,剩下的全部由内部组件消化。业务方在使用的时候,只需要像这样:
<template> <StudentModule :columns="columns" :search-fields="searchFields" :form-fields="formFields" :api="studentApi" :permission-map="permissionMap" @refresh="handleRefresh" /> </template> <script setup> import StudentModule from '@/modules/student-module' import { columns, searchFields, formFields } from './studentConfig' import { studentApi } from './api' </script>入口组件的设计逻辑是"配置驱动"——绝大部分情况下业务方只需要改配置文件,不需要动组件内部实现。这也是通用模块最容易上手的部分。
2.2 组件各司其职,避免一锅炖
组件拆分的边界,我以单一职责为原则:
- StudentSearchPanel:负责收集搜索条件,产出标准化的
query对象。它接收searchFields配置,内部用动态渲染的方式渲染各个搜索项,搜索按钮触发emit('search', query)。 - StudentTablePanel:负责渲染表格主体,接受
query变化,触发列表接口请求,管理loading、tableData、pagination状态,并将分页事件向上传递。 - StudentFormDrawer:负责新增和编辑的共用表单弹窗,内部通过
formFields渲染完整表单,处理回显、校验、提交、关闭后的清理。 - StudentDetailDrawer:负责只读详情展示。它不是简单的"把表单禁用",而是从配置中生成一种独立的只读展示模式,因为在实际业务里,详情需要展示的字段经常比编辑表单要多(比如创建人、更新时间、审批日志)。
- StudentStatusTag:根据状态码渲染对应颜色的标签,配置来源是
statusMap。
这样拆分以后,每个组件可以独立测试,也能在某个子应用需要局部定制时单独替换,而不至于把整个模块换成一套新的。
2.3 数据流:单向流动,状态不散落
通用模块最怕的就是状态散落在各个子组件里,互相之间$parent、$emit满天飞。我最终的数据流约定是这样:
- 入口组件拥有页面级状态:
query(搜索条件)、page、pageSize、total、tableData。 - 子组件只通过 props 接收数据和配置,通过 emit 上报事件,不反向修改父级状态。
- 接口请求统一封装在
api/index.js,组件层不直接写fetch或axios,方便换后端、加拦截器、做 mock。
我用了组合函数useStudentList.ts来集中管理列表状态逻辑,这样即使在非通用模块的场景也能单独抽取复用:
export function useStudentList(api, options = {}) { const loading = ref(false) const tableData = ref([]) const query = ref({}) const pagination = reactive({ page: 1, pageSize: 20, total: 0 }) const fetchList = async () => { loading.value = true try { const params = { ...query.value, page: pagination.page, pageSize: pagination.pageSize, } const result = await api.getList(params) tableData.value = result.list || [] pagination.total = result.total || 0 } finally { loading.value = false } } const handleSearch = (nextQuery) => { query.value = nextQuery pagination.page = 1 fetchList() } const handlePageChange = (page, pageSize) => { pagination.page = page pagination.pageSize = pageSize fetchList() } return { loading, tableData, query, pagination, fetchList, handleSearch, handlePageChange } }注意这里的细节:搜索按钮触发时,页码必须重置为第一页,否则用户在切换条件时可能出现当前页超出总页数范围,拿到空列表。这个坑我做第一个版本的时候踩过,后来在handleSearch里强制page = 1才算根治。
3. 列表页的通用化工程化:搜索、分页、列配置、状态映射
列表是学生管理模块中使用频率最高的页面。通用化的核心,是把"展示什么列、按什么条件搜索、状态怎么显示、操作按钮怎么渲染"全部从硬编码中剥离出去。
3.1 搜索区的配置化设计
搜索区看起来简单,设计起来最容易失控。第一个版本我曾经把所有搜索项写死在面板组件里,结果三个业务线需求一变,组件就多出来一堆可配置隐藏项。正确的做法是,把搜索字段抽成配置数组:
// config/searchFields.js export const searchFields = [ { prop: 'keyword', label: '关键词', type: 'input', placeholder: '学号/姓名/手机号', span: 6 }, { prop: 'collegeId', label: '所属学院', type: 'select', options: collegeOptions, span: 6 }, { prop: 'studentStatus', label: '学籍状态', type: 'select', options: statusOptions, span: 6 }, { prop: 'enrollYear', label: '入学年份', type: 'date-picker', props: { type: 'year', valueFormat: 'YYYY' }, span: 6 }, ]然后在搜索面板组件里动态渲染:
<template> <el-form :model="searchModel" inline> <el-form-item v-for="field in searchFields" :key="field.prop" :label="field.label"> <component :is="resolveComponent(field.type)" v-model="searchModel[field.prop]" v-bind="field.props || {}" :options="field.options || []" /> </el-form-item> <el-form-item> <el-button type="primary" @click="emitSearch">查询</el-button> <el-button @click="handleReset">重置</el-button> </el-form-item> </el-form> </template>这里有一个隐藏的坑:el-select的options数据如果来自异步接口,需要在面板挂载时请求。我一般约定searchFields里的options可以是数组,也可以是一个返回Promise的函数,在搜索面板组件内用computed配合resolve处理。
重置按钮的行为也要约定清楚:重置应该清空searchModel,并触发一次不带任何条件的列表刷新,而不是单纯清空表单后让用户手动点查询。很多初级开发会漏掉这一步,导致用户体验不连贯。
3.2 表格列配置与自定义单元格
表格列的配置和搜索字段类似,我维护一份独立的columns.js:
// config/columns.js export const columns = [ { prop: 'studentNo', label: '学号', minWidth: 120 }, { prop: 'name', label: '姓名', minWidth: 100 }, { prop: 'gender', label: '性别', minWidth: 80, formatter: (row) => (row.gender === 1 ? '男' : '女') }, { prop: 'collegeName', label: '学院', minWidth: 140 }, { prop: 'majorName', label: '专业', minWidth: 140 }, { prop: 'studentStatus', label: '学籍状态', minWidth: 110, slotName: 'status' }, { prop: 'createdAt', label: '创建时间', minWidth: 170 }, ]列配置中的slotName字段非常关键。它意味着:当通用列的默认渲染方式不满足需求时,表格容器允许业务方通过具名插槽传入自定义渲染逻辑。例如:
<StudentTablePanel :columns="columns" :data="tableData" :loading="loading"> <template #cell-status="{ row }"> <StudentStatusTag :status="row.studentStatus" /> </template> <template #cell-actions="{ row }"> <el-button v-permission="'student:edit'" type="primary" link @click="handleEdit(row)">编辑</el-button> <el-button v-permission="'student:detail'" link @click="handleDetail(row)">详情</el-button> <el-button v-permission="'student:delete'" type="danger" link @click="handleDelete(row)">删除</el-button> </template> </StudentTablePanel>这样做的好处是:通用模块本身不感知"学籍状态"要怎么展示,它只负责提供插槽出口和传入当前行数据,具体展示交给业务方。列配置中带有formatter函数的简单映射,则直接在内部处理,减少不必要的插槽使用。
3.3 分页参数与数量选择
分页是列表页最容易出细节 bug 的地方。后端接口五花八门,有的返回{ list, total },有的返回{ records, totalCount },有的直接把分页信息放在page字段。我建议在api/index.js层把格式统一,比如:
// api/index.js const formatPageResult = (res) => { return { list: res.list ?? res.records ?? [], total: res.total ?? res.totalCount ?? 0, } } export const studentApi = { getList: (params) => http.get('/student/page', { params }).then(formatPageResult), }页容量选择我一般固定提供[10, 20, 50, 100]几个档位。很多管理后台动不动就 20/50/100/200,其实太细的档位对使用体验提升很有限,反而增加了后端处理压力。同时,pageSize变化后要保持当前页码不变,重新加载数据,但用户通常关心的是"每页改大后最好还是能看到刚才附近的数据",所以要不要重置到第一页可以看交互习惯,我更倾向于改pageSize时不重置页码,只有重新搜索时才重置。
3.4 状态标签映射:从散落到统一
学生模块里最常见的状态就是"在读、休学、退学、毕业"这类枚举。几乎每个业务线都会遇到,也几乎每个项目都有同学在表格里写三四个v-if来渲染不同颜色的标签。我把这个抽成了一个全局状态映射文件:
// config/statusMap.js export const STUDENT_STATUS = { STUDYING: { label: '在读', type: 'success' }, SUSPENDED: { label: '休学', type: 'warning' }, DROPPED: { label: '退学', type: 'danger' }, GRADUATED: { label: '毕业', type: 'info' }, }做成了StudentStatusTag.vue之后,标签组件自己负责根据状态码查找映射并渲染标签。这样能保证全系统状态显示完全一致,不会教务线用的是"在读-绿色",学工线用的却是"在读-蓝色"。
4. 表单与编辑的复用方案:动态字段、校验规则、回显时序
新增和编辑是学生管理模块里另一个开发量很大的部分。通用化表单的核心,是让表单字段、校验规则、初始值全部变成配置。
4.1 表单字段配置的数据结构
我设计formFields.js的每一条配置长这样:
export const formFields = [ { prop: 'name', label: '姓名', component: 'el-input', props: { placeholder: '请输入姓名', maxlength: 20 }, rules: [{ required: true, message: '请输入姓名', trigger: 'blur' }], span: 12, }, { prop: 'gender', label: '性别', component: 'el-radio-group', options: [{ label: '男', value: 1 }, { label: '女', value: 2 }], rules: [{ required: true, message: '请选择性别', trigger: 'change' }], span: 12, }, { prop: 'birthday', label: '出生日期', component: 'el-date-picker', props: { type: 'date', valueFormat: 'YYYY-MM-DD' }, span: 12, }, { prop: 'remark', label: '备注', component: 'el-input', props: { type: 'textarea', rows: 3 }, span: 24, }, ]表单组件的动态渲染和搜索面板稍微不同,因为要处理rules、required标记、span栅格布局。我封装了一个DynamicForm.vue,内部用el-form+el-row/el-col布局,对每个字段根据配置动态创建组件。
校验规则放在配置里还有一个额外的好处:编辑页和详情页可以共用这份字段配置,只需要在详情模式下把表单项全部禁用或者直接转成纯展示。我需要的时候会在详情抽屉里直接复用formFields,传入readonly属性,减少了维护两份字段表的成本。
4.2 回显时序的经典坑
编辑功能的回显,新手最容易踩的坑是这样:打开编辑弹窗时,表格行数据里已经有row.name、row.gender了,就把这些字段直接塞进表单模型里;但有些字段(比如所属学院、班主任)需要在编辑弹窗打开后再异步拉详情接口获取。于是一个表单里有些字段用旧数据,有些字段等新数据,列表数据和实体数据交叉混用,很容易出 bug。
我的处理方式是:编辑弹窗打开时永远以详情接口为准,不直接依赖表格行数据。流程是:
- 点击"编辑"按钮,记录当前行的
id。 - 弹窗打开,显示
loading遮罩。 - 请求详情接口获取完整数据,回填表单。
- 全部字段就绪后,关闭 loading 遮罩。
这里有个时序细节:在请求详情之前先调用resetFields(),否则上一次编辑的数据残留会导致新数据还没回来时界面闪烁显示旧内容。正确打开弹窗的方法顺序是:
const handleEdit = async (row) => { drawerVisible.value = true formRef.value?.resetFields() formLoading.value = true try { const detail = await api.getDetail(row.id) editModel.value = { ...detail } } finally { formLoading.value = false } }回显和重置的顺序很关键:先让弹窗出现并重置表单,再请求数据。反过来会出现表单模型被旧数据污染,新数据达到后一部分字段被覆盖,一部分没覆盖的情况。
4.3 提交与校验的边界处理
表单提交时的校验是整个模块可靠性的一部分。我统一封装了useStudentForm.ts:
export function useStudentForm(api, options = {}) { const formRef = ref(null) const editModel = ref({}) const submitting = ref(false) const handleSubmit = async () => { if (!formRef.value) return try { await formRef.value.validate() } catch (err) { return false } submitting.value = true try { const payload = { ...editModel.value } delete payload.createdAt delete payload.updatedAt if (options.idGetter) { await api.update(options.idGetter.value, payload) } else { await api.create(payload) } return true } finally { submitting.value = false } } return { formRef, editModel, submitting, handleSubmit } }提交时的delete payload.createdAt这类操作看似粗糙,其实是要过滤掉详情接口返回的只读字段。不同系统返回的只读字段不一样,我建议设计一个submitExcludeKeys配置项,让业务方自己声明提交时要忽略哪些字段。如果后端足够规范,也可以在接口层统一用 DTO 接收,前端不需要这么麻烦。但现实里后端接口往往就是@RequestBody Map一把梭,所以这个过滤能力很有必要。
4.4 表单默认值与重置行为
表单配置里我额外支持了一个default字段,可以是值,也可以是一个函数。新增模式打开时,表单模型需要初始化默认值;编辑模式打开时则用详情数据覆盖。比如:
export const formFields = [ { prop: 'enrollYear', label: '入学年份', component: 'el-date-picker', default: () => new Date().getFullYear() }, ]default用函数而不是静态值,是为了避免对象引用共享——如果你直接写default: { ... }并把它赋给多个表单实例,可能互相影响。用函数每次创建新对象,才是安全的做法。
5. 通用性的底线:配置、插槽与扩展点的取舍
这一章是我想重点说的。通用模块做得好不好,不看它"覆盖了多少场景",而看它"在覆盖不了场景时有多体面"。
5.1 每多一个配置项,都是多一份复杂度
一开始我设计的通用搜索面板有八个配置项,比如showExport,showReset,showAdvancedToggle,labelWidth,inline... 后来发现真正使用的只有三四个,而为了维护这些配置的组合逻辑,组件里全是复杂的v-if。每次需求变化,配置和逻辑都要一起改。
后来我给自己定的原则是:配置项只服务于当前真实存在的业务场景,不为了"可能未来会用到"提前铺路。通用模块的维护者应该主动删掉那些"觉得可能会有人用"的配置。
5.2 插槽是通用组件最后的逃生舱
配置化解决的是"可枚举、有规律"的变化,真正复杂的变化是配置描述不了的。这时候必须提供插槽机制,让业务方可以直接覆盖默认渲染。
我给表格容器做的插槽约定包括:
| 插槽名 | 作用 | 使用场景示例 |
|---|---|---|
#cell-{prop} | 覆盖某一列的单元格渲染 | 自定义头像、超链接、金额格式化 |
#cell-actions | 覆盖整个操作列 | 按钮分组国际化、增加"导入成绩"等特殊操作 |
#toolbar | 表格上方工具栏扩展 | 批量操作按钮、导出按钮、统计数字 |
#empty | 覆盖空数据占位提示 | 引导用户去创建第一条学生记录 |
插槽的命名和 API 让步于简单,不要搞出"前置插槽+后置插槽"一堆名字。业务方能在模板里直接看到cell-status就知道覆盖的是状态列,这种直观感比什么都重要。
5.3 请求通道的适配器设计
我曾遇到招生子系统的学生列表,分页参数名和学工系统不一样——一个用pageNum,一个用page。后端接口风格不统一,这在多团队协作环境中非常常见。
解决方案不是在组件里加一堆if,而是在api层做适配器。每个业务方传入自己的api对象时,可以基于通用模块约定的入参格式做一层转换:
const recruitApi = { getList: (params) => { return http.get('/recruit/student/list', { params: { pageNum: params.page, pageSize: params.pageSize, ...params } }) } }这样模块内部的useStudentList始终只面对一套标准参数,差异被隔离在接口适配层。这个模式很简单,但很多做通用模块的人容易忽略,非得把分页参数名做成动态配置项,结果越做越复杂。
5.4 约定优于配置:文档化与找人的成本
通用模块最大的隐性成本是"学习成本"。新人接手时,如果只靠翻代码猜配置项,效率极低。我在模块目录里固定放了一份README.md,明确写清楚配置结构、插槽清单、接口数据格式约定,以及一个最小示例。模块内部组件通过defineOptions({ name: 'StudentModule' })保证递归组件和 devtools 调试时有可辨识的名字。
另外还有一条经验:通用模块必须指定唯一的负责人。多个系统共用一套模块时,不能"谁都能改"。任何对通用模块的改动都要走评审,否则某次为了满足 A 系统临时加的配置,可能在不经意间破坏 B 系统的运行。
6. 实际项目中的踩坑记录与性能优化
做学生管理模块通用功能过程中,我记录了一批发生得非常频繁的实战问题,单独列出来分享。
6.1 缓存导致搜索条件残留
在管理后台中,页面通常用keep-alive缓存。这会导致用户在某页设置搜索条件后切走再切回来,条件还在。这不是 bug,但对某些用户来说会误以为系统 bug("为什么我明明没搜什么,列表却少了数据")。
我给模块入口组件的解决方式有两种,按场景选:
- 如果搜索条件是业务强诉求(比如招生期按批次快速过滤),就保留缓存并配合
activated钩子刷新列表。 - 如果搜索条件只是临时操作,就把页面设为不缓存,或者在离开时主动清空查询条件。
从通用模块的立场出发,我不会强制缓存与否,而是把clearQueryOnActivated做成一个可选项,默认关闭,因为不同子应用的业务预期不一样。
6.2 虚拟滚动与大数据量渲染
学生数据在大多数学校是几千到几万条,但在一些培训机构系统里,列表接口可能一次返回几百条,再配合卡片式展示就成了上千 DOM 节点,页面卡顿明显。在模块内部,我给StudentTablePanel加了一个基于el-table-v2(或vitual-table)的切换开关。数据量超过 500 条时,自动启用虚拟滚动;数据量小时用普通表格。这样既保证了小数据下的交互流畅(拖拽排序、固定列等),又避免大数据量时页面假死。
6.3 深监听配置对象导致性能问题
有一点容易踩坑:formFields、columns这些配置对象如果直接放进watch的深层监听里,任何一次内部对象的修改都会触发整个组件的重渲染。我实际遇到过打开一个详情抽屉,因为某个配置项被修改了label,整个表格跟着刷新了一次,闪烁了 100ms。
优化方式很简单:
- 配置对象在模块内部用
shallowRef或markRaw包裹,不参与响应式追踪。 - 需要响应配置变化时,只监听每个配置项顶层的
version字段或引用地址。
const rawConfig = markRaw(config) const columns = shallowRef(rawConfig.columns) const searchFields = shallowRef(rawConfig.searchFields)这样处理之后,配置对象本身一旦初始化便不再触发依赖更新,只有当外部主动传入新对象时才会刷新。
6.4 接口错误状态的三态覆盖
通用列表最容易出现的问题是"接口报错后只显示一只转圈圈"。用户看到 loading 转几秒突然消失,表格空荡荡,没有任何提示。我在模块内部约定,列表三种状态必须有完整 UI 覆盖:
- 加载中:展示
v-loading遮罩或骨架屏。 - 加载失败:展示错误提示 + 重试按钮。
- 加载成功但无数据:展示空状态插画 + 引导按钮。
错误状态下还需要把最近一次错误信息打印到控制台,方便排查。真实项目中,后端偶尔返回500,偶尔超时,如果没有错误态 UI,用户就会认为"这个系统坏了"而直接关掉,研发也很难定位问题。
6.5 权限按钮的前端控制与后端校验
系统中学生信息的修改、删除通常有严格的角色限制。我在模块内集成了一条自定义指令v-permission,按钮按需显示:
// directives/permission.js const hasPermission = (el, binding) => { const needed = binding.value if (!needed) return const userPermissions = localStorage.getItem('userPermissions')?.split(',') || [] const has = userPermissions.includes(needed) if (!has) { el.parentNode?.removeChild(el) } } export const permission = { mounted(el, binding) { hasPermission(el, binding) } }这个方法做好之后还要留个心眼:前端隐藏按钮只是体验优化,不能作为权限边界。后端接口必须同样校验权限,否则任何人都能手动调接口。我在多个项目里遇到过只做前端隐藏、后端裸奔的情况,当时的运维同事一脸崩溃,因为内部系统被离职账号删了一千多条数据。前端v-permission只能防手滑,不能防攻击。
7. 从学生模块延伸出去:这套设计还能用在哪些地方
做完学生管理模块通用功能之后,我很快发现这套模式可以直接迁移到其他管理模块,比如教师管理、班级管理、课程管理。它们本质上都是"列表 + 搜索 + 表单 + 详情"的组合,只是字段不同、API 不同、状态枚举不同。
我后续用同一套骨架做了另一个课程管理模块,开发时间大概只用了一半。原因是大部分组件、组合函数、扩展点设计都保留了,只需要新增配置文件和接口适配层。所以如果你有类似需求,不妨先跑通学生模块,再把模板复制到其他业务线,速度会快很多。
不过也要记住一点:不要为了用而用。如果某个业务模块的交互模式和学生模块差异非常大(比如是一棵组织树加右侧列表面板,而不是独立列表页),硬套通用骨架反而会让代码变扭。通用模块的合理边界,应该是"默认场景覆盖主流需求,特殊场景允许业务方绕开模块直接实现"。能绕得开、也能搭得上,这才是通用性健康的表现。
8. 最后再分享一点实在的:先跑两个场景再抽象
如果让我重来一次,我不会再一开始就规划一个"全功能通用模块"。这个观念是调了好几次才变的。最稳妥的做法是:先上手两个具体的业务场景,一边实现一边记录共性和差异,等两个场景跑完,再回头抽象通用模块。第一版可以先做得简单一点,只覆盖那两个场景;之后第三个、第四个场景自然找上来时,基于真实需求去迭代和扩展,反而更稳。
通用模块的"通用"不是设计出来的,是长出来的。你的第一版不需要想着覆盖所有团队的所有可能需求,只需要做到:在完成眼前业务的同时,给后续扩展留好接口和插槽位置。这样模块越来越多,你的通用骨架也会越来越贴合实际业务。
关于学生管理模块通用功能,技术层面能说的都在上面了。如果这套方案能给你一点启发,或者你刚好要开始做类似的抽取,那就去做吧,做完你会发现,真正的复杂度从来不在列表和表单本身,而在"哪些地方不该通用"的判断上。