# 学校管理模块 - 技术方案 --- | 文档信息 | 内容 | |---------|------| | **文档版本** | V1.0 | | **项目名称** | 盘古用户平台(Pangu User Platform) | | **模块名称** | 学校管理模块 | | **编写团队** | pangu | | **创建日期** | 2026-01-31 | | **审核状态** | 待评审 | --- ## 修订记录 | 版本 | 日期 | 修订人 | 修订内容 | |------|------|--------|----------| | V1.0 | 2026-01-31 | pangu | 初稿 | --- ## 目录 1. [模块概述](#1-模块概述) 2. [需求分析](#2-需求分析) 3. [前端技术方案](#3-前端技术方案) 4. [后端技术方案](#4-后端技术方案) 5. [数据库设计](#5-数据库设计) 6. [接口设计](#6-接口设计) 7. [任务分解与排期](#7-任务分解与排期) 8. [测试方案](#8-测试方案) 9. [风险评估](#9-风险评估) --- ## 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) **布局结构** ```vue ``` **核心状态** ```javascript // 查询参数 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 | 点击区域节点 | **实现要点** ```javascript // 加载区域树数据 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 的树形结构展示学校-年级-班级层级 - 支持展开/收起 - 不同层级显示不同操作按钮 **数据结构** ```javascript // 学校树节点结构 { 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 | ✓ | 状态 | **表单验证规则** ```javascript 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' } ] } ``` **核心方法** ```javascript // 打开新增弹窗 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) **功能说明** - 展示年级字典列表 - 支持多选 - 已挂载的年级默认禁用 - 确认后批量挂载 **组件交互** ```javascript // 打开弹窗 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接口封装 ```javascript // 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 路由配置 ```javascript // 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) ```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 tree = schoolService.selectSchoolTree(regionId); return success(tree); } /** * 获取学校列表 */ @GetMapping("/list") public TableDataInfo list(SchoolQueryDTO query) { startPage(); List 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) ```java /** * 学校服务接口 * @author pangu */ public interface ISchoolService { /** * 查询学校树形结构 * @param regionId 区域ID(可选) * @return 学校树 */ List selectSchoolTree(Long regionId); /** * 查询学校列表 * @param query 查询条件 * @return 学校列表 */ List 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 gradeIds); /** * 为年级挂载班级 * @param schoolGradeId 学校年级关联ID * @param classIds 班级ID列表 * @return 影响行数 */ int bindClasses(Long schoolGradeId, List classIds); /** * 删除学校年级 * @param schoolGradeId 学校年级关联ID * @return 影响行数 */ int deleteSchoolGrade(Long schoolGradeId); /** * 删除学校班级 * @param schoolClassId 学校班级关联ID * @return 影响行数 */ int deleteSchoolClass(Long schoolClassId); } ``` #### 4.3.3 服务实现(SchoolServiceImpl.java) ```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 selectSchoolTree(Long regionId) { // 1. 查询学校列表 List schools = schoolMapper.selectSchoolsByRegionId(regionId); // 2. 查询学校下的年级 List grades = schoolGradeMapper.selectBySchoolIds( schools.stream().map(School::getSchoolId).collect(Collectors.toList()) ); // 3. 查询年级下的班级 List 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 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 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 buildSchoolTree(List schools, List grades, List classes) { // 按学校ID分组年级 Map> gradeMap = grades.stream() .collect(Collectors.groupingBy(SchoolGrade::getSchoolId)); // 按学校年级ID分组班级 Map> 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 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 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 数据权限控制 ```java /** * 数据权限注解 * 分公司用户只能查看和管理所属区域的学校 */ @DataScope(deptAlias = "d", userAlias = "u") public List selectSchoolList(SchoolQueryDTO query) { // MyBatis拦截器自动拼接数据权限SQL return schoolMapper.selectSchoolList(query); } ``` **SQL示例** ```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 单元测试 **后端单元测试** ```java @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 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 | | 年级/班级字典 | 年级班级选择依赖字典数据 | 初始化种子数据 | --- ## 评审检查项 | 检查项 | 状态 | |-------|:----:| | 需求覆盖完整性 | ☐ | | 技术方案可行性 | ☐ | | 数据库设计合理性 | ☐ | | 接口设计规范性 | ☐ | | 任务分解粒度 | ☐ | | 工时估算合理性 | ☐ | | 测试方案完整性 | ☐ | | 风险识别充分性 | ☐ | --- ## 审核签字 | 角色 | 姓名 | 日期 | 签字 | |-----|------|------|------| | 技术负责人 | | | | | 前端负责人 | | | | | 后端负责人 | | | | | 产品负责人 | | | | --- *文档结束*