38 KiB
38 KiB
学校管理模块 - 技术方案
| 文档信息 | 内容 |
|---|---|
| 文档版本 | V1.0 |
| 项目名称 | 盘古用户平台(Pangu User Platform) |
| 模块名称 | 学校管理模块 |
| 编写团队 | pangu |
| 创建日期 | 2026-01-31 |
| 审核状态 | 待评审 |
修订记录
| 版本 | 日期 | 修订人 | 修订内容 |
|---|---|---|---|
| V1.0 | 2026-01-31 | pangu | 初稿 |
目录
1. 模块概述
1.1 模块定位
学校管理模块是盘古用户平台的核心基础模块,负责管理学校、年级、班级的树形组织结构,为学生管理、会员管理等业务模块提供基础数据支撑。
1.2 功能范围
学校管理模块
├── 学校管理
│ ├── 学校列表查询(区域树 + 学校树 + 列表)
│ ├── 新增学校
│ ├── 编辑学校
│ └── 删除学校
├── 年级挂载
│ ├── 为学校挂载年级
│ └── 删除学校年级
└── 班级挂载
├── 为年级挂载班级
└── 删除学校班级
1.3 角色权限
| 角色 | 查看所有学校 | 查看所属区域学校 | 新增/编辑学校 | 删除学校 | 管理年级/班级 |
|---|---|---|---|---|---|
| 超级管理员 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 分公司用户 | ✗ | ✓ | ✓ | ✓ | ✓ |
| 学校用户 | ✗ | ✗ | ✗ | ✗ | ✗ |
2. 需求分析
2.1 功能清单
| 功能编号 | 功能名称 | 功能描述 | 优先级 |
|---|---|---|---|
| SCH-001 | 学校列表查询 | 按区域树形展示学校、年级、班级结构 | P0 |
| SCH-002 | 学校信息查询 | 按学校名称、状态筛选查询 | P0 |
| SCH-003 | 新增学校 | 创建新学校,自动生成学校编码 | P0 |
| SCH-004 | 编辑学校 | 修改学校名称、所属区域、状态 | P0 |
| SCH-005 | 删除学校 | 软删除学校(需检查关联数据) | P0 |
| SCH-006 | 新增年级 | 为学校挂载年级(从年级库选择) | P0 |
| SCH-007 | 新增班级 | 为年级挂载班级(从班级库选择) | P0 |
| SCH-008 | 删除年级/班级 | 软删除年级/班级(需检查关联数据) | P1 |
2.2 业务规则
| 规则编号 | 规则描述 |
|---|---|
| SCH-R01 | 学校编码由系统自动生成,格式:SCH + 年份(4位) + 序号(4位),如 SCH20260001 |
| SCH-R02 | 所属地区、学校编码、学校名称为必填项 |
| SCH-R03 | 新增学校时,所属地区默认带入列表页选择的区域 |
| SCH-R04 | 删除学校前需检查是否有子级(年级/班级),有则提示 |
| SCH-R05 | 删除学校前需检查是否被学生信息引用,有则不允许删除 |
| SCH-R06 | 所有删除操作均为软删除,保留历史数据 |
| SCH-R07 | 学校下新增年级为选择挂载,非新建年级,支持多选 |
| SCH-R08 | 年级下新增班级为选择挂载,非新建班级,支持多选 |
2.3 页面原型
| 页面 | 描述 | 原型链接 |
|---|---|---|
| 学校列表页 | 左侧区域树 + 右侧学校树表格 | 墨刀原型-学校管理 |
| 学校编辑弹窗 | 新增/编辑学校信息 | 墨刀原型-编辑学校 |
| 年级选择弹窗 | 多选年级挂载 | 墨刀原型-新增年级 |
| 班级选择弹窗 | 多选班级挂载 | 墨刀原型-新增班级 |
3. 前端技术方案
3.1 技术选型
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue | 3.5.x | 前端框架 |
| Element Plus | 2.13.x | UI组件库 |
| Pinia | 3.x | 状态管理 |
| Axios | 1.x | HTTP请求 |
| Vite | 7.x | 构建工具 |
3.2 页面结构设计
views/school/
├── index.vue # 学校管理主页面
└── components/
├── RegionTree.vue # 左侧区域树组件
├── SchoolTree.vue # 右侧学校树表格组件
├── SchoolDialog.vue # 新增/编辑学校弹窗
├── GradeSelectDialog.vue # 年级选择弹窗
└── ClassSelectDialog.vue # 班级选择弹窗
3.3 组件设计
3.3.1 主页面(index.vue)
布局结构
<template>
<div class="school-container">
<!-- 左侧区域树 -->
<div class="region-tree-wrapper">
<RegionTree
@node-click="handleRegionSelect"
:default-selected="currentRegionId"
/>
</div>
<!-- 右侧内容区 -->
<div class="main-content">
<!-- 搜索栏 -->
<el-form :model="queryParams" inline>
<el-form-item label="学校名称">
<el-input v-model="queryParams.schoolName" placeholder="请输入学校名称" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="请选择状态">
<el-option label="全部" value="" />
<el-option label="启用" value="0" />
<el-option label="禁用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">新增学校</el-button>
</div>
<!-- 学校树表格 -->
<SchoolTree
:data="schoolTreeData"
:loading="loading"
@edit-school="handleEdit"
@delete-school="handleDelete"
@add-grade="handleAddGrade"
@delete-grade="handleDeleteGrade"
@add-class="handleAddClass"
@delete-class="handleDeleteClass"
/>
</div>
<!-- 弹窗组件 -->
<SchoolDialog ref="schoolDialogRef" @success="handleQuery" />
<GradeSelectDialog ref="gradeDialogRef" @success="handleQuery" />
<ClassSelectDialog ref="classDialogRef" @success="handleQuery" />
</div>
</template>
核心状态
// 查询参数
const queryParams = reactive({
regionId: null,
schoolName: '',
status: ''
})
// 学校树数据
const schoolTreeData = ref([])
// 当前选中的区域ID
const currentRegionId = ref(null)
3.3.2 区域树组件(RegionTree.vue)
功能说明
- 展示省-市-区三级区域树
- 点击节点筛选右侧学校列表
- 支持展开/收起操作
- 高亮当前选中节点
组件Props
| Prop | 类型 | 必填 | 说明 |
|---|---|---|---|
| defaultSelected | Number | 否 | 默认选中的区域ID |
组件Events
| Event | 参数 | 说明 |
|---|---|---|
| node-click | regionId, regionData | 点击区域节点 |
实现要点
// 加载区域树数据
const loadRegionTree = async () => {
const res = await getRegionTree()
regionTreeData.value = res.data
}
// 节点点击事件
const handleNodeClick = (data, node) => {
emit('node-click', data.regionId, data)
}
3.3.3 学校树表格组件(SchoolTree.vue)
功能说明
- 使用 el-table 的树形结构展示学校-年级-班级层级
- 支持展开/收起
- 不同层级显示不同操作按钮
数据结构
// 学校树节点结构
{
id: 1, // 唯一标识
type: 'school', // 节点类型:school/grade/class
name: '武汉市第一中学', // 显示名称
code: 'SCH20260001', // 编码
status: '0', // 状态
regionPath: '湖北省-武汉市-武昌区',
children: [
{
id: 101,
type: 'grade',
name: '七年级',
schoolGradeId: 1, // 学校年级关联ID
gradeId: 7,
children: [
{
id: 1001,
type: 'class',
name: '1班',
schoolClassId: 1, // 学校班级关联ID
classId: 1
}
]
}
]
}
表格列定义
| 列名 | 字段 | 宽度 | 说明 |
|---|---|---|---|
| 名称 | name | - | 学校/年级/班级名称,树形展开 |
| 编码 | code | 150px | 仅学校显示 |
| 区域 | regionPath | 200px | 仅学校显示 |
| 状态 | status | 100px | 启用/禁用 |
| 操作 | - | 250px | 根据类型显示不同按钮 |
操作按钮规则
| 节点类型 | 操作按钮 |
|---|---|
| school | 编辑、新增年级、删除 |
| grade | 新增班级、删除 |
| class | 删除 |
3.3.4 学校编辑弹窗(SchoolDialog.vue)
功能说明
- 支持新增和编辑两种模式
- 新增时学校编码自动生成(后端返回)
- 所属地区支持级联选择
- 表单验证
表单字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| schoolName | el-input | ✓ | 学校名称,最大100字符 |
| schoolCode | el-input | ✓ | 学校编码,新增时自动生成,只读 |
| schoolType | el-select | ✓ | 学校类型:小学/初中/高中 |
| regionId | el-cascader | ✓ | 所属区域,级联选择 |
| address | el-input | ✗ | 详细地址 |
| contactPerson | el-input | ✗ | 联系人 |
| contactPhone | el-input | ✗ | 联系电话 |
| status | el-switch | ✓ | 状态 |
表单验证规则
const rules = {
schoolName: [
{ required: true, message: '请输入学校名称', trigger: 'blur' },
{ max: 100, message: '学校名称长度不能超过100个字符', trigger: 'blur' }
],
schoolType: [
{ required: true, message: '请选择学校类型', trigger: 'change' }
],
regionId: [
{ required: true, message: '请选择所属区域', trigger: 'change' }
],
contactPhone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
核心方法
// 打开新增弹窗
const open = (regionId = null) => {
dialogVisible.value = true
isEdit.value = false
resetForm()
// 带入当前选中的区域
if (regionId) {
form.regionId = regionId
}
}
// 打开编辑弹窗
const openEdit = async (schoolId) => {
dialogVisible.value = true
isEdit.value = true
const res = await getSchool(schoolId)
Object.assign(form, res.data)
}
// 提交表单
const handleSubmit = async () => {
await formRef.value.validate()
if (isEdit.value) {
await updateSchool(form)
ElMessage.success('修改成功')
} else {
await addSchool(form)
ElMessage.success('新增成功')
}
dialogVisible.value = false
emit('success')
}
3.3.5 年级选择弹窗(GradeSelectDialog.vue)
功能说明
- 展示年级字典列表
- 支持多选
- 已挂载的年级默认禁用
- 确认后批量挂载
组件交互
// 打开弹窗
const open = async (schoolId, existingGradeIds = []) => {
currentSchoolId.value = schoolId
existingGrades.value = existingGradeIds
dialogVisible.value = true
await loadGradeList()
}
// 加载年级列表
const loadGradeList = async () => {
const res = await getGradeList({ status: '0' })
gradeList.value = res.rows.map(item => ({
...item,
disabled: existingGrades.value.includes(item.gradeId)
}))
}
// 确认挂载
const handleConfirm = async () => {
if (selectedGrades.value.length === 0) {
ElMessage.warning('请至少选择一个年级')
return
}
await bindGrades({
schoolId: currentSchoolId.value,
gradeIds: selectedGrades.value
})
ElMessage.success('年级挂载成功')
dialogVisible.value = false
emit('success')
}
3.3.6 班级选择弹窗(ClassSelectDialog.vue)
功能说明
- 展示班级字典列表
- 支持多选
- 已挂载的班级默认禁用
- 确认后批量挂载
组件交互
与年级选择弹窗类似,调用 bindClasses 接口完成班级挂载。
3.4 API接口封装
// api/school.js
import request from '@/utils/request'
// 获取学校树
export function getSchoolTree(regionId) {
return request({
url: '/api/school/tree',
method: 'get',
params: { regionId }
})
}
// 获取学校列表
export function getSchoolList(query) {
return request({
url: '/api/school/list',
method: 'get',
params: query
})
}
// 获取学校详情
export function getSchool(schoolId) {
return request({
url: `/api/school/${schoolId}`,
method: 'get'
})
}
// 新增学校
export function addSchool(data) {
return request({
url: '/api/school',
method: 'post',
data
})
}
// 修改学校
export function updateSchool(data) {
return request({
url: '/api/school',
method: 'put',
data
})
}
// 删除学校
export function deleteSchool(schoolId) {
return request({
url: `/api/school/${schoolId}`,
method: 'delete'
})
}
// 为学校挂载年级
export function bindGrades(data) {
return request({
url: '/api/school/bindGrades',
method: 'post',
data
})
}
// 为年级挂载班级
export function bindClasses(data) {
return request({
url: '/api/school/bindClasses',
method: 'post',
data
})
}
// 删除学校年级
export function deleteSchoolGrade(schoolGradeId) {
return request({
url: `/api/school/grade/${schoolGradeId}`,
method: 'delete'
})
}
// 删除学校班级
export function deleteSchoolClass(schoolClassId) {
return request({
url: `/api/school/class/${schoolClassId}`,
method: 'delete'
})
}
3.5 路由配置
// router/index.js
{
path: '/school',
component: Layout,
meta: { title: '学校管理', icon: 'school' },
children: [
{
path: '',
name: 'School',
component: () => import('@/views/school/index.vue'),
meta: { title: '学校管理' }
}
]
}
3.6 交互流程
3.6.1 查询学校流程
用户 ──► 点击区域树节点 ──► 更新 regionId ──► 调用 getSchoolTree(regionId)
│
▼
用户 ◀── 渲染学校树表格 ◀── 返回学校树数据 ◀── 后端处理
3.6.2 新增学校流程
用户 ──► 点击"新增学校" ──► 打开弹窗(带入当前区域) ──► 填写表单
│
▼
用户 ◀── 刷新列表 ◀── 关闭弹窗 ◀── 提示成功 ◀── 调用 addSchool()
3.6.3 挂载年级流程
用户 ──► 点击学校行"新增年级" ──► 打开年级选择弹窗 ──► 勾选年级
│
▼
用户 ◀── 刷新列表 ◀── 关闭弹窗 ◀── 提示成功 ◀── 调用 bindGrades()
3.6.4 删除学校流程
用户 ──► 点击"删除" ──► 二次确认 ──► 调用 deleteSchool()
│
├── 成功 ──► 提示成功 ──► 刷新列表
│
└── 失败(有关联数据) ──► 提示错误信息
4. 后端技术方案
4.1 技术选型
| 技术 | 版本 | 用途 |
|---|---|---|
| Spring Boot | 3.3.x | 应用框架 |
| Spring Security | 6.x | 安全框架 |
| MyBatis Plus | 3.5.x | ORM框架 |
| MySQL | 8.0 | 数据库 |
| Redis | 7.x | 缓存 |
| Hutool | 5.x | 工具库 |
4.2 包结构设计
com.pangu.user/
├── controller/
│ └── SchoolController.java # 学校管理控制器
├── service/
│ ├── ISchoolService.java # 学校服务接口
│ └── impl/
│ └── SchoolServiceImpl.java # 学校服务实现
├── mapper/
│ ├── SchoolMapper.java # 学校Mapper
│ ├── SchoolGradeMapper.java # 学校年级Mapper
│ └── SchoolClassMapper.java # 学校班级Mapper
├── domain/
│ ├── entity/
│ │ ├── School.java # 学校实体
│ │ ├── SchoolGrade.java # 学校年级关联实体
│ │ └── SchoolClass.java # 学校班级关联实体
│ ├── dto/
│ │ ├── SchoolQueryDTO.java # 学校查询DTO
│ │ ├── SchoolCreateDTO.java # 学校新增DTO
│ │ └── BindGradesDTO.java # 年级挂载DTO
│ └── vo/
│ ├── SchoolVO.java # 学校VO
│ └── SchoolTreeVO.java # 学校树VO
└── common/
└── enums/
└── SchoolTypeEnum.java # 学校类型枚举
4.3 核心代码设计
4.3.1 控制器层(SchoolController.java)
/**
* 学校管理控制器
* @author pangu
*/
@RestController
@RequestMapping("/api/school")
public class SchoolController extends BaseController {
@Autowired
private ISchoolService schoolService;
/**
* 获取学校树形结构
*/
@GetMapping("/tree")
public AjaxResult tree(@RequestParam(required = false) Long regionId) {
List<SchoolTreeVO> tree = schoolService.selectSchoolTree(regionId);
return success(tree);
}
/**
* 获取学校列表
*/
@GetMapping("/list")
public TableDataInfo list(SchoolQueryDTO query) {
startPage();
List<SchoolVO> list = schoolService.selectSchoolList(query);
return getDataTable(list);
}
/**
* 获取学校详情
*/
@GetMapping("/{schoolId}")
public AjaxResult getInfo(@PathVariable Long schoolId) {
return success(schoolService.selectSchoolById(schoolId));
}
/**
* 新增学校
*/
@Log(title = "学校管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SchoolCreateDTO dto) {
return toAjax(schoolService.insertSchool(dto));
}
/**
* 修改学校
*/
@Log(title = "学校管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody School school) {
return toAjax(schoolService.updateSchool(school));
}
/**
* 删除学校
*/
@Log(title = "学校管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{schoolId}")
public AjaxResult remove(@PathVariable Long schoolId) {
return toAjax(schoolService.deleteSchool(schoolId));
}
/**
* 为学校挂载年级
*/
@Log(title = "学校管理", businessType = BusinessType.UPDATE)
@PostMapping("/bindGrades")
public AjaxResult bindGrades(@Validated @RequestBody BindGradesDTO dto) {
return toAjax(schoolService.bindGrades(dto.getSchoolId(), dto.getGradeIds()));
}
/**
* 为年级挂载班级
*/
@Log(title = "学校管理", businessType = BusinessType.UPDATE)
@PostMapping("/bindClasses")
public AjaxResult bindClasses(@Validated @RequestBody BindClassesDTO dto) {
return toAjax(schoolService.bindClasses(dto.getSchoolGradeId(), dto.getClassIds()));
}
/**
* 删除学校年级
*/
@Log(title = "学校管理", businessType = BusinessType.DELETE)
@DeleteMapping("/grade/{schoolGradeId}")
public AjaxResult removeGrade(@PathVariable Long schoolGradeId) {
return toAjax(schoolService.deleteSchoolGrade(schoolGradeId));
}
/**
* 删除学校班级
*/
@Log(title = "学校管理", businessType = BusinessType.DELETE)
@DeleteMapping("/class/{schoolClassId}")
public AjaxResult removeClass(@PathVariable Long schoolClassId) {
return toAjax(schoolService.deleteSchoolClass(schoolClassId));
}
}
4.3.2 服务接口(ISchoolService.java)
/**
* 学校服务接口
* @author pangu
*/
public interface ISchoolService {
/**
* 查询学校树形结构
* @param regionId 区域ID(可选)
* @return 学校树
*/
List<SchoolTreeVO> selectSchoolTree(Long regionId);
/**
* 查询学校列表
* @param query 查询条件
* @return 学校列表
*/
List<SchoolVO> selectSchoolList(SchoolQueryDTO query);
/**
* 根据ID查询学校
* @param schoolId 学校ID
* @return 学校信息
*/
SchoolVO selectSchoolById(Long schoolId);
/**
* 新增学校
* @param dto 学校信息
* @return 影响行数
*/
int insertSchool(SchoolCreateDTO dto);
/**
* 修改学校
* @param school 学校信息
* @return 影响行数
*/
int updateSchool(School school);
/**
* 删除学校(软删除)
* @param schoolId 学校ID
* @return 影响行数
*/
int deleteSchool(Long schoolId);
/**
* 为学校挂载年级
* @param schoolId 学校ID
* @param gradeIds 年级ID列表
* @return 影响行数
*/
int bindGrades(Long schoolId, List<Long> gradeIds);
/**
* 为年级挂载班级
* @param schoolGradeId 学校年级关联ID
* @param classIds 班级ID列表
* @return 影响行数
*/
int bindClasses(Long schoolGradeId, List<Long> classIds);
/**
* 删除学校年级
* @param schoolGradeId 学校年级关联ID
* @return 影响行数
*/
int deleteSchoolGrade(Long schoolGradeId);
/**
* 删除学校班级
* @param schoolClassId 学校班级关联ID
* @return 影响行数
*/
int deleteSchoolClass(Long schoolClassId);
}
4.3.3 服务实现(SchoolServiceImpl.java)
/**
* 学校服务实现
* @author pangu
*/
@Service
public class SchoolServiceImpl implements ISchoolService {
@Autowired
private SchoolMapper schoolMapper;
@Autowired
private SchoolGradeMapper schoolGradeMapper;
@Autowired
private SchoolClassMapper schoolClassMapper;
@Autowired
private StudentMapper studentMapper;
@Autowired
private RegionMapper regionMapper;
@Override
public List<SchoolTreeVO> selectSchoolTree(Long regionId) {
// 1. 查询学校列表
List<School> schools = schoolMapper.selectSchoolsByRegionId(regionId);
// 2. 查询学校下的年级
List<SchoolGrade> grades = schoolGradeMapper.selectBySchoolIds(
schools.stream().map(School::getSchoolId).collect(Collectors.toList())
);
// 3. 查询年级下的班级
List<SchoolClass> classes = schoolClassMapper.selectBySchoolGradeIds(
grades.stream().map(SchoolGrade::getId).collect(Collectors.toList())
);
// 4. 组装树形结构
return buildSchoolTree(schools, grades, classes);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int insertSchool(SchoolCreateDTO dto) {
School school = new School();
BeanUtils.copyProperties(dto, school);
// 生成学校编码:SCH + 年份 + 4位序号
String schoolCode = generateSchoolCode();
school.setSchoolCode(schoolCode);
// 设置区域路径
String regionPath = regionMapper.selectRegionPath(dto.getRegionId());
school.setRegionPath(regionPath);
return schoolMapper.insert(school);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteSchool(Long schoolId) {
// 1. 检查是否有年级/班级
int gradeCount = schoolGradeMapper.countBySchoolId(schoolId);
if (gradeCount > 0) {
throw new ServiceException("该学校下存在年级数据,请先删除年级");
}
// 2. 检查是否被学生引用
int studentCount = studentMapper.countBySchoolId(schoolId);
if (studentCount > 0) {
throw new ServiceException("该学校已被学生信息引用,无法删除");
}
// 3. 软删除
return schoolMapper.deleteById(schoolId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int bindGrades(Long schoolId, List<Long> gradeIds) {
int count = 0;
for (Long gradeId : gradeIds) {
// 检查是否已存在
if (schoolGradeMapper.exists(schoolId, gradeId)) {
continue;
}
SchoolGrade sg = new SchoolGrade();
sg.setSchoolId(schoolId);
sg.setGradeId(gradeId);
count += schoolGradeMapper.insert(sg);
}
return count;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int bindClasses(Long schoolGradeId, List<Long> classIds) {
int count = 0;
for (Long classId : classIds) {
// 检查是否已存在
if (schoolClassMapper.exists(schoolGradeId, classId)) {
continue;
}
SchoolClass sc = new SchoolClass();
sc.setSchoolGradeId(schoolGradeId);
sc.setClassId(classId);
count += schoolClassMapper.insert(sc);
}
return count;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteSchoolGrade(Long schoolGradeId) {
// 1. 检查是否有班级
int classCount = schoolClassMapper.countBySchoolGradeId(schoolGradeId);
if (classCount > 0) {
throw new ServiceException("该年级下存在班级数据,请先删除班级");
}
// 2. 检查是否被学生引用
int studentCount = studentMapper.countBySchoolGradeId(schoolGradeId);
if (studentCount > 0) {
throw new ServiceException("该年级已被学生信息引用,无法删除");
}
// 3. 软删除
return schoolGradeMapper.deleteById(schoolGradeId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteSchoolClass(Long schoolClassId) {
// 1. 检查是否被学生引用
int studentCount = studentMapper.countBySchoolClassId(schoolClassId);
if (studentCount > 0) {
throw new ServiceException("该班级已被学生信息引用,无法删除");
}
// 2. 软删除
return schoolClassMapper.deleteById(schoolClassId);
}
/**
* 生成学校编码
* 格式:SCH + 年份(4位) + 序号(4位)
*/
private String generateSchoolCode() {
String year = String.valueOf(LocalDate.now().getYear());
String prefix = "SCH" + year;
// 查询当前年份最大编号
String maxCode = schoolMapper.selectMaxCode(prefix);
int seq = 1;
if (maxCode != null) {
seq = Integer.parseInt(maxCode.substring(7)) + 1;
}
return prefix + String.format("%04d", seq);
}
/**
* 构建学校树形结构
*/
private List<SchoolTreeVO> buildSchoolTree(List<School> schools,
List<SchoolGrade> grades,
List<SchoolClass> classes) {
// 按学校ID分组年级
Map<Long, List<SchoolGrade>> gradeMap = grades.stream()
.collect(Collectors.groupingBy(SchoolGrade::getSchoolId));
// 按学校年级ID分组班级
Map<Long, List<SchoolClass>> classMap = classes.stream()
.collect(Collectors.groupingBy(SchoolClass::getSchoolGradeId));
return schools.stream().map(school -> {
SchoolTreeVO vo = new SchoolTreeVO();
vo.setId(school.getSchoolId());
vo.setType("school");
vo.setName(school.getSchoolName());
vo.setCode(school.getSchoolCode());
vo.setStatus(school.getStatus());
vo.setRegionPath(school.getRegionPath());
// 组装年级
List<SchoolGrade> schoolGrades = gradeMap.getOrDefault(school.getSchoolId(), Collections.emptyList());
vo.setChildren(schoolGrades.stream().map(sg -> {
SchoolTreeVO gradeVO = new SchoolTreeVO();
gradeVO.setId(sg.getId());
gradeVO.setType("grade");
gradeVO.setName(sg.getGradeName());
gradeVO.setSchoolGradeId(sg.getId());
gradeVO.setGradeId(sg.getGradeId());
// 组装班级
List<SchoolClass> gradeClasses = classMap.getOrDefault(sg.getId(), Collections.emptyList());
gradeVO.setChildren(gradeClasses.stream().map(sc -> {
SchoolTreeVO classVO = new SchoolTreeVO();
classVO.setId(sc.getId());
classVO.setType("class");
classVO.setName(sc.getClassName());
classVO.setSchoolClassId(sc.getId());
classVO.setClassId(sc.getClassId());
return classVO;
}).collect(Collectors.toList()));
return gradeVO;
}).collect(Collectors.toList()));
return vo;
}).collect(Collectors.toList());
}
}
4.4 数据权限控制
/**
* 数据权限注解
* 分公司用户只能查看和管理所属区域的学校
*/
@DataScope(deptAlias = "d", userAlias = "u")
public List<SchoolVO> selectSchoolList(SchoolQueryDTO query) {
// MyBatis拦截器自动拼接数据权限SQL
return schoolMapper.selectSchoolList(query);
}
SQL示例
-- 原始SQL
SELECT * FROM pg_school WHERE del_flag = '0'
-- 分公司用户执行时自动拼接
SELECT * FROM pg_school s
LEFT JOIN sys_dept d ON s.region_id = d.dept_id
WHERE s.del_flag = '0'
AND d.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = #{roleId})
5. 数据库设计
5.1 相关表结构
| 表名 | 说明 |
|---|---|
| pg_region | 区域表 |
| pg_school | 学校表 |
| pg_grade | 年级字典表 |
| pg_class | 班级字典表 |
| pg_school_grade | 学校年级关联表 |
| pg_school_class | 学校班级关联表 |
5.2 核心表结构(摘要)
pg_school(学校表)
| 字段 | 类型 | 说明 |
|---|---|---|
| school_id | bigint | 学校ID(主键) |
| school_code | varchar(32) | 学校编码(唯一) |
| school_name | varchar(100) | 学校名称 |
| school_type | char(2) | 学校类型 |
| region_id | bigint | 所属区域ID |
| region_path | varchar(200) | 区域路径 |
| status | char(1) | 状态 |
| del_flag | char(1) | 删除标志 |
pg_school_grade(学校年级关联表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键ID |
| school_id | bigint | 学校ID |
| grade_id | bigint | 年级ID |
| del_flag | char(1) | 删除标志 |
pg_school_class(学校班级关联表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键ID |
| school_grade_id | bigint | 学校年级关联ID |
| class_id | bigint | 班级ID |
| del_flag | char(1) | 删除标志 |
6. 接口设计
6.1 接口清单
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 获取学校树 | GET | /api/school/tree | 按区域获取学校树形结构 |
| 获取学校列表 | GET | /api/school/list | 分页查询学校列表 |
| 获取学校详情 | GET | /api/school/{id} | 获取单个学校信息 |
| 新增学校 | POST | /api/school | 创建学校 |
| 修改学校 | PUT | /api/school | 更新学校 |
| 删除学校 | DELETE | /api/school/{id} | 软删除学校 |
| 挂载年级 | POST | /api/school/bindGrades | 为学校挂载年级 |
| 挂载班级 | POST | /api/school/bindClasses | 为年级挂载班级 |
| 删除年级 | DELETE | /api/school/grade/{id} | 删除学校年级 |
| 删除班级 | DELETE | /api/school/class/{id} | 删除学校班级 |
6.2 接口详情
详见《接口设计文档_v1.0.md》第3章 学校管理接口。
7. 任务分解与排期
7.1 前端开发任务
| 任务编号 | 任务名称 | 优先级 | 预估工时 | 依赖 |
|---|---|---|---|---|
| FE-SCH-01 | 创建 school/index.vue 主页面框架 | P0 | 2h | - |
| FE-SCH-02 | 开发 RegionTree 区域树组件 | P0 | 2h | - |
| FE-SCH-03 | 开发 SchoolTree 学校树表格组件 | P0 | 4h | FE-SCH-01 |
| FE-SCH-04 | 开发 SchoolDialog 新增/编辑弹窗 | P0 | 3h | FE-SCH-03 |
| FE-SCH-05 | 开发 GradeSelectDialog 年级选择弹窗 | P0 | 2h | FE-SCH-03 |
| FE-SCH-06 | 开发 ClassSelectDialog 班级选择弹窗 | P0 | 2h | FE-SCH-05 |
| FE-SCH-07 | 封装 api/school.js 接口 | P0 | 1h | - |
| FE-SCH-08 | 主页面逻辑实现与联调 | P0 | 3h | FE-SCH-01~07 |
| FE-SCH-09 | 样式优化与交互完善 | P1 | 2h | FE-SCH-08 |
| FE-SCH-10 | 单元测试与Bug修复 | P1 | 2h | FE-SCH-09 |
前端总工时:23h(约3个工作日)
7.2 后端开发任务
| 任务编号 | 任务名称 | 优先级 | 预估工时 | 依赖 |
|---|---|---|---|---|
| BE-SCH-01 | 创建 School 实体类及相关DTO/VO | P0 | 1h | - |
| BE-SCH-02 | 创建 SchoolMapper 及 XML | P0 | 2h | BE-SCH-01 |
| BE-SCH-03 | 创建 SchoolGradeMapper 及 XML | P0 | 1h | BE-SCH-01 |
| BE-SCH-04 | 创建 SchoolClassMapper 及 XML | P0 | 1h | BE-SCH-01 |
| BE-SCH-05 | 实现 ISchoolService 接口 | P0 | 1h | - |
| BE-SCH-06 | 实现学校CRUD方法 | P0 | 3h | BE-SCH-02, BE-SCH-05 |
| BE-SCH-07 | 实现年级/班级挂载方法 | P0 | 2h | BE-SCH-03, BE-SCH-04 |
| BE-SCH-08 | 实现学校树查询方法 | P0 | 2h | BE-SCH-02~04 |
| BE-SCH-09 | 实现删除校验逻辑 | P0 | 2h | BE-SCH-06 |
| BE-SCH-10 | 实现学校编码生成逻辑 | P0 | 1h | BE-SCH-06 |
| BE-SCH-11 | 创建 SchoolController | P0 | 2h | BE-SCH-06~08 |
| BE-SCH-12 | 数据权限控制实现 | P1 | 2h | BE-SCH-11 |
| BE-SCH-13 | 单元测试编写 | P1 | 3h | BE-SCH-11 |
| BE-SCH-14 | 接口联调与Bug修复 | P1 | 2h | BE-SCH-13 |
后端总工时:25h(约3.5个工作日)
7.3 开发排期
Week 1
├── Day 1: BE-SCH-01~04(实体类、Mapper)
├── Day 2: BE-SCH-05~08(Service实现)
├── Day 3: BE-SCH-09~11(Controller、校验逻辑)
├── Day 4: FE-SCH-01~04(主页面、区域树、学校树)
└── Day 5: FE-SCH-05~08(弹窗组件、接口封装、联调)
Week 2
├── Day 1: BE-SCH-12~14(数据权限、测试)
├── Day 2: FE-SCH-09~10(样式优化、测试)
└── Day 3: 集成测试、Bug修复
8. 测试方案
8.1 单元测试
后端单元测试
@SpringBootTest
public class SchoolServiceTest {
@Autowired
private ISchoolService schoolService;
@Test
public void testInsertSchool() {
SchoolCreateDTO dto = new SchoolCreateDTO();
dto.setSchoolName("测试学校");
dto.setSchoolType("02");
dto.setRegionId(111L);
int result = schoolService.insertSchool(dto);
assertEquals(1, result);
}
@Test
public void testDeleteSchoolWithGrades() {
// 准备:创建学校并挂载年级
Long schoolId = createSchoolWithGrades();
// 执行&断言:删除应抛出异常
assertThrows(ServiceException.class, () -> {
schoolService.deleteSchool(schoolId);
});
}
@Test
public void testBindGrades() {
Long schoolId = 1L;
List<Long> gradeIds = Arrays.asList(1L, 2L, 3L);
int result = schoolService.bindGrades(schoolId, gradeIds);
assertEquals(3, result);
}
}
8.2 接口测试
| 测试场景 | 接口 | 预期结果 |
|---|---|---|
| 查询学校树 | GET /api/school/tree?regionId=111 | 返回武昌区下所有学校树 |
| 新增学校 | POST /api/school | 返回成功,学校编码自动生成 |
| 编辑学校 | PUT /api/school | 返回成功,数据已更新 |
| 删除有子级的学校 | DELETE /api/school/1 | 返回失败,提示有年级数据 |
| 删除无子级的学校 | DELETE /api/school/2 | 返回成功 |
| 挂载年级 | POST /api/school/bindGrades | 返回成功 |
| 重复挂载年级 | POST /api/school/bindGrades | 返回成功(忽略重复) |
| 挂载班级 | POST /api/school/bindClasses | 返回成功 |
| 删除被引用的班级 | DELETE /api/school/class/1 | 返回失败,提示被学生引用 |
8.3 UI测试清单
| 测试项 | 测试步骤 | 预期结果 |
|---|---|---|
| 区域树展示 | 进入学校管理页面 | 左侧显示区域树,默认展开 |
| 区域筛选 | 点击区域树节点 | 右侧学校列表刷新 |
| 学校树展开 | 点击学校行展开图标 | 显示年级和班级 |
| 新增学校 | 点击新增按钮,填写表单 | 弹窗显示,提交后列表刷新 |
| 编辑学校 | 点击编辑按钮 | 弹窗显示并回填数据 |
| 删除学校确认 | 点击删除按钮 | 显示确认对话框 |
| 新增年级 | 点击学校行"新增年级" | 弹出年级选择框,支持多选 |
| 新增班级 | 点击年级行"新增班级" | 弹出班级选择框,支持多选 |
| 表单验证 | 必填项留空提交 | 显示验证提示 |
9. 风险评估
9.1 技术风险
| 风险 | 影响 | 概率 | 应对措施 |
|---|---|---|---|
| 树形数据量大导致性能问题 | 中 | 低 | 分页加载、懒加载子节点 |
| 数据权限逻辑复杂 | 中 | 中 | 复用RuoYi数据权限框架 |
| 前后端联调延迟 | 低 | 中 | 前端Mock数据先行开发 |
9.2 业务风险
| 风险 | 影响 | 概率 | 应对措施 |
|---|---|---|---|
| 需求变更频繁 | 中 | 中 | 预留扩展字段,模块化设计 |
| 删除校验遗漏 | 高 | 低 | 全面梳理关联关系,完善测试用例 |
9.3 依赖风险
| 依赖项 | 影响 | 应对措施 |
|---|---|---|
| 区域管理模块 | 学校必须选择区域 | 区域模块优先开发或提供Mock |
| 年级/班级字典 | 年级班级选择依赖字典数据 | 初始化种子数据 |
评审检查项
| 检查项 | 状态 |
|---|---|
| 需求覆盖完整性 | ☐ |
| 技术方案可行性 | ☐ |
| 数据库设计合理性 | ☐ |
| 接口设计规范性 | ☐ |
| 任务分解粒度 | ☐ |
| 工时估算合理性 | ☐ |
| 测试方案完整性 | ☐ |
| 风险识别充分性 | ☐ |
审核签字
| 角色 | 姓名 | 日期 | 签字 |
|---|---|---|---|
| 技术负责人 | |||
| 前端负责人 | |||
| 后端负责人 | |||
| 产品负责人 |
文档结束