feat: 会员管理-添加学生绑定功能

1. 后端新增接口:
   - GET /business/student/available - 查询可绑定的学生列表
   - GET /business/student/byMember/{memberId} - 查询会员已绑定的学生
   - GET /business/member/{memberId}/students - 获取会员已绑定的学生列表
   - POST /business/member/{memberId}/bindStudents - 批量绑定学生到会员
   - POST /business/member/unbindStudent/{studentId} - 解绑学生
   - GET /business/school/grade/{schoolGradeId}/classes - 获取年级下的班级列表

2. 业务规则实现:
   - 教师身份只能绑定同校学生(schoolId相同)
   - 家长身份可绑定任意学生
   - 一个学生只能归属一个会员

3. 前端功能:
   - 新增StudentSelectDialog学生选择器组件
   - 支持按姓名、学号搜索
   - 支持多选绑定
   - MemberDialog集成学生选择器

4. 修复问题:
   - 修复会员编辑时学校信息(区域/年级/班级)回显问题
   - 修复班级列表接口404问题
This commit is contained in:
神码-方晓辉 2026-02-02 19:41:24 +08:00
parent d28b68ef46
commit 2baf792159
12 changed files with 504 additions and 14 deletions

View File

@ -10,10 +10,13 @@ import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.service.IPgMemberService;
import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.service.IPgStudentService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
@ -28,6 +31,7 @@ import java.util.Map;
public class PgMemberController extends BaseController {
private final IPgMemberService memberService;
private final IPgStudentService studentService;
/**
* 查询会员列表
@ -107,4 +111,32 @@ public class PgMemberController extends BaseController {
public R<Boolean> checkPhoneUnique(@RequestParam String phone, @RequestParam(required = false) Long memberId) {
return R.ok(memberService.checkPhoneUnique(phone, memberId));
}
/**
* 获取会员已绑定的学生列表
*/
@GetMapping("/{memberId}/students")
public R<List<PgStudent>> getMemberStudents(@PathVariable Long memberId) {
return R.ok(studentService.selectByMemberId(memberId));
}
/**
* 批量绑定学生到会员
*/
@SaCheckPermission("business:member:edit")
@Log(title = "会员管理-绑定学生", businessType = BusinessType.UPDATE)
@PostMapping("/{memberId}/bindStudents")
public R<Void> bindStudents(@PathVariable Long memberId, @RequestBody List<Long> studentIds) {
return toAjax(studentService.bindStudentsToMember(memberId, studentIds));
}
/**
* 解绑学生
*/
@SaCheckPermission("business:member:edit")
@Log(title = "会员管理-解绑学生", businessType = BusinessType.UPDATE)
@PostMapping("/unbindStudent/{studentId}")
public R<Void> unbindStudent(@PathVariable Long studentId) {
return toAjax(studentService.unbindStudent(studentId));
}
}

View File

@ -1,6 +1,7 @@
package org.dromara.pangu.member.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
@ -9,6 +10,7 @@ import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.util.Date;
import java.util.List;
/**
* 会员表
@ -51,6 +53,12 @@ public class PgMember extends BaseEntity {
private Long regionId;
/**
* 区域ID路径非数据库字段用于级联选择器回显
*/
@TableField(exist = false)
private List<Long> regionIds;
private Long schoolId;
private Long schoolGradeId;

View File

@ -8,6 +8,8 @@ import lombok.RequiredArgsConstructor;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.pangu.base.domain.PgRegion;
import org.dromara.pangu.base.mapper.PgRegionMapper;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.mapper.PgMemberMapper;
import org.dromara.pangu.member.service.IPgMemberService;
@ -16,7 +18,9 @@ import cn.hutool.crypto.digest.BCrypt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@ -31,6 +35,7 @@ public class PgMemberServiceImpl implements IPgMemberService {
private final PgMemberMapper baseMapper;
private final PgStudentMapper studentMapper;
private final PgRegionMapper regionMapper;
private static final String DEFAULT_PASSWORD = "123456";
@ -48,7 +53,29 @@ public class PgMemberServiceImpl implements IPgMemberService {
@Override
public PgMember selectById(Long memberId) {
return baseMapper.selectById(memberId);
PgMember member = baseMapper.selectById(memberId);
if (member != null && member.getRegionId() != null) {
// 查询区域的完整路径用于级联选择器回显
member.setRegionIds(getRegionPath(member.getRegionId()));
}
return member;
}
/**
* 获取区域的完整路径从根到当前节点的ID列表
*/
private List<Long> getRegionPath(Long regionId) {
List<Long> path = new ArrayList<>();
Long currentId = regionId;
while (currentId != null && currentId > 0) {
path.add(0, currentId);
PgRegion region = regionMapper.selectById(currentId);
if (region == null) {
break;
}
currentId = region.getParentId();
}
return path;
}
@Override

View File

@ -87,6 +87,14 @@ public class PgSchoolController extends BaseController {
return R.ok(schoolService.selectGradesBySchoolId(schoolId));
}
/**
* 获取年级下的班级列表
*/
@GetMapping("/grade/{schoolGradeId}/classes")
public R<List<PgSchoolClass>> getGradeClasses(@PathVariable Long schoolGradeId) {
return R.ok(schoolService.selectClassesBySchoolGradeId(schoolGradeId));
}
/**
* 为学校添加年级批量挂载
*/

View File

@ -1,6 +1,7 @@
package org.dromara.pangu.school.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@ -33,4 +34,10 @@ public class PgSchoolClass implements Serializable {
private Long createBy;
private Date createTime;
/**
* 班级名称非数据库字段用于前端显示
*/
@TableField(exist = false)
private String className;
}

View File

@ -50,4 +50,9 @@ public interface IPgSchoolService {
* 删除年级下的班级
*/
int removeGradeClass(Long schoolClassId);
/**
* 获取年级下的班级列表
*/
List<org.dromara.pangu.school.domain.PgSchoolClass> selectClassesBySchoolGradeId(Long schoolGradeId);
}

View File

@ -230,6 +230,22 @@ public class PgSchoolServiceImpl implements IPgSchoolService {
return schoolClassMapper.deleteById(schoolClassId);
}
@Override
public List<PgSchoolClass> selectClassesBySchoolGradeId(Long schoolGradeId) {
List<PgSchoolClass> schoolClasses = schoolClassMapper.selectList(
new LambdaQueryWrapper<PgSchoolClass>()
.eq(PgSchoolClass::getSchoolGradeId, schoolGradeId)
);
// 关联查询班级名称
for (PgSchoolClass sc : schoolClasses) {
PgClass cls = classMapper.selectById(sc.getClassId());
if (cls != null) {
sc.setClassName(cls.getClassName());
}
}
return schoolClasses;
}
private LambdaQueryWrapper<PgSchool> buildQueryWrapper(PgSchool school) {
LambdaQueryWrapper<PgSchool> lqw = new LambdaQueryWrapper<>();
lqw.like(StrUtil.isNotBlank(school.getSchoolName()), PgSchool::getSchoolName, school.getSchoolName());

View File

@ -58,4 +58,29 @@ public class PgStudentController extends BaseController {
public R<Void> remove(@PathVariable Long[] studentIds) {
return toAjax(studentService.deleteByIds(studentIds));
}
/**
* 查询可绑定的学生列表用于会员绑定学生
* @param studentName 学生姓名模糊查询
* @param studentNo 学号模糊查询
* @param memberId 当前会员ID
* @param schoolId 学校ID教师身份时传入限制只能选本校学生
*/
@GetMapping("/available")
public TableDataInfo<PgStudent> availableStudents(
@RequestParam(required = false) String studentName,
@RequestParam(required = false) String studentNo,
@RequestParam(required = false) Long memberId,
@RequestParam(required = false) Long schoolId,
PageQuery pageQuery) {
return studentService.selectAvailableStudents(studentName, studentNo, memberId, schoolId, pageQuery);
}
/**
* 查询会员已绑定的学生列表
*/
@GetMapping("/byMember/{memberId}")
public R<java.util.List<PgStudent>> listByMemberId(@PathVariable Long memberId) {
return R.ok(studentService.selectByMemberId(memberId));
}
}

View File

@ -18,4 +18,29 @@ public interface IPgStudentService {
int insert(PgStudent student);
int update(PgStudent student);
int deleteByIds(Long[] studentIds);
/**
* 查询可绑定的学生列表分页
* @param studentName 学生姓名模糊查询
* @param studentNo 学号模糊查询
* @param memberId 当前会员ID排除已绑定其他会员的学生
* @param schoolId 学校ID教师身份时必传限制只能选本校学生
* @param pageQuery 分页参数
*/
TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery);
/**
* 查询会员已绑定的学生列表
*/
List<PgStudent> selectByMemberId(Long memberId);
/**
* 批量绑定学生到会员
*/
int bindStudentsToMember(Long memberId, List<Long> studentIds);
/**
* 解绑学生
*/
int unbindStudent(Long studentId);
}

View File

@ -68,4 +68,65 @@ public class PgStudentServiceImpl implements IPgStudentService {
lqw.orderByDesc(PgStudent::getCreateTime);
return lqw;
}
@Override
public TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery) {
LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>();
// 姓名模糊查询
lqw.like(StrUtil.isNotBlank(studentName), PgStudent::getStudentName, studentName);
// 学号模糊查询
lqw.like(StrUtil.isNotBlank(studentNo), PgStudent::getStudentNo, studentNo);
// 可绑定条件未被绑定 已绑定当前会员
lqw.and(wrapper -> wrapper
.isNull(PgStudent::getMemberId)
.or()
.eq(memberId != null, PgStudent::getMemberId, memberId)
);
// 教师身份限制只能选本校学生
lqw.eq(schoolId != null, PgStudent::getSchoolId, schoolId);
lqw.orderByDesc(PgStudent::getCreateTime);
Page<PgStudent> page = baseMapper.selectPage(pageQuery.build(), lqw);
return TableDataInfo.build(page);
}
@Override
public List<PgStudent> selectByMemberId(Long memberId) {
if (memberId == null) {
return List.of();
}
return baseMapper.selectList(
new LambdaQueryWrapper<PgStudent>()
.eq(PgStudent::getMemberId, memberId)
.orderByDesc(PgStudent::getCreateTime)
);
}
@Override
public int bindStudentsToMember(Long memberId, List<Long> studentIds) {
if (memberId == null || studentIds == null || studentIds.isEmpty()) {
return 0;
}
int count = 0;
for (Long studentId : studentIds) {
PgStudent student = new PgStudent();
student.setStudentId(studentId);
student.setMemberId(memberId);
count += baseMapper.updateById(student);
}
return count;
}
@Override
public int unbindStudent(Long studentId) {
if (studentId == null) {
return 0;
}
// 使用原生SQL更新memberId为null
return baseMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PgStudent>()
.eq(PgStudent::getStudentId, studentId)
.set(PgStudent::getMemberId, null)
);
}
}

View File

@ -140,8 +140,8 @@
<el-table-column prop="gradeName" label="年级" width="80" />
<el-table-column prop="className" label="班级" width="60" />
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-popconfirm title="确定解绑该学生?" @confirm="handleRemoveStudent($index)">
<template #default="{ row }">
<el-popconfirm title="确定解绑该学生?" @confirm="handleRemoveStudent(row)">
<template #reference>
<el-button link type="danger" size="small">解绑</el-button>
</template>
@ -154,6 +154,9 @@
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
<!-- 学生选择器 -->
<StudentSelectDialog ref="studentSelectRef" @success="loadBoundStudents" />
</el-dialog>
</template>
@ -162,6 +165,7 @@ import { Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
import request from '@/utils/request'
import StudentSelectDialog from './StudentSelectDialog.vue'
const emit = defineEmits(['success'])
@ -169,6 +173,7 @@ const visible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const submitLoading = ref(false)
const studentSelectRef = ref(null)
//
const form = reactive({
@ -229,10 +234,9 @@ const open = async (row) => {
const res = await request.get(`/business/member/${row.memberId}`)
if (res.code === 200 && res.data) {
Object.assign(form, res.data)
//
// regionIds
//
if (form.regionId) {
// regionIds
form.regionIds = [form.regionId]
await loadSchoolList(form.regionId)
}
if (form.schoolId) {
@ -241,6 +245,8 @@ const open = async (row) => {
if (form.schoolGradeId) {
await loadClassList(form.schoolGradeId)
}
//
await loadBoundStudents()
}
} catch (e) {
console.error('加载会员数据失败', e)
@ -248,6 +254,22 @@ const open = async (row) => {
}
}
/**
* 加载已绑定的学生列表
*/
const loadBoundStudents = async () => {
if (!form.memberId) {
form.students = []
return
}
try {
const res = await request.get(`/business/member/${form.memberId}/students`)
form.students = res.data || []
} catch (e) {
form.students = []
}
}
/**
* 重置表单
*/
@ -287,8 +309,8 @@ const loadRegionTree = async () => {
*/
const loadSchoolList = async (regionId) => {
try {
const res = await request.get('/business/school/list', { params: { regionId } })
schoolList.value = res.rows || []
const res = await request.get('/business/school/listAll', { params: { regionId } })
schoolList.value = res.data || []
} catch (e) {
schoolList.value = []
}
@ -299,7 +321,7 @@ const loadSchoolList = async (regionId) => {
*/
const loadGradeList = async (schoolId) => {
try {
const res = await request.get('/business/school/grades', { params: { schoolId } })
const res = await request.get(`/business/school/${schoolId}/grades`)
gradeList.value = res.data || []
} catch (e) {
gradeList.value = []
@ -311,7 +333,7 @@ const loadGradeList = async (schoolId) => {
*/
const loadClassList = async (schoolGradeId) => {
try {
const res = await request.get('/business/school/classes', { params: { schoolGradeId } })
const res = await request.get(`/business/school/grade/${schoolGradeId}/classes`)
classList.value = res.data || []
} catch (e) {
classList.value = []
@ -379,14 +401,30 @@ const handleGradeChange = () => {
* 添加学生
*/
const handleAddStudent = () => {
ElMessage.info('学生选择功能待开发')
if (!isEdit.value || !form.memberId) {
ElMessage.warning('请先保存会员信息后再绑定学生')
return
}
studentSelectRef.value?.open({
memberId: form.memberId,
identityType: form.identityType,
schoolId: form.schoolId
})
}
/**
* 移除学生
* 解绑学生
*/
const handleRemoveStudent = (index) => {
form.students.splice(index, 1)
const handleRemoveStudent = async (row) => {
try {
const res = await request.post(`/business/member/unbindStudent/${row.studentId}`)
if (res.code === 200) {
ElMessage.success('解绑成功')
await loadBoundStudents()
}
} catch (e) {
console.error('解绑失败', e)
}
}
/**

View File

@ -0,0 +1,238 @@
<!--
学生选择器弹窗
@author 湖北新华业务中台研发团队
-->
<template>
<el-dialog
v-model="visible"
title="选择学生"
width="800px"
:close-on-click-modal="false"
destroy-on-close
>
<!-- 搜索条件 -->
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="姓名">
<el-input v-model="queryParams.studentName" placeholder="请输入学生姓名" clearable style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="学号">
<el-input v-model="queryParams.studentNo" placeholder="请输入学号" clearable style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
<el-button :icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 教师身份提示 -->
<el-alert
v-if="isTeacher"
title="教师只能绑定本校学生"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<!-- 学生列表 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="studentList"
border
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="studentName" label="姓名" min-width="80" />
<el-table-column prop="studentNo" label="学号" width="120" />
<el-table-column prop="schoolName" label="学校" min-width="150" show-overflow-tooltip />
<el-table-column prop="gradeName" label="年级" width="80" />
<el-table-column prop="className" label="班级" width="60" />
<el-table-column label="绑定状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.memberId === memberId" type="success" size="small">已绑定</el-tag>
<el-tag v-else-if="row.memberId" type="warning" size="small">已被绑定</el-tag>
<el-tag v-else type="info" size="small">未绑定</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-if="total > 0"
:current-page="queryParams.pageNum"
:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
style="margin-top: 12px; justify-content: flex-end"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<template #footer>
<div class="dialog-footer">
<span style="margin-right: 16px; color: #909399">已选择 {{ selectedStudents.length }} 名学生</span>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleConfirm">确定绑定</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
import request from '@/utils/request'
const emit = defineEmits(['success'])
const visible = ref(false)
const loading = ref(false)
const submitLoading = ref(false)
const studentList = ref([])
const total = ref(0)
const selectedStudents = ref([])
const tableRef = ref(null)
//
const memberId = ref(null)
const isTeacher = ref(false)
const schoolId = ref(null)
//
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
studentName: '',
studentNo: ''
})
/**
* 打开弹窗
* @param options { memberId: Long, identityType: String, schoolId: Long }
*/
const open = (options = {}) => {
memberId.value = options.memberId
isTeacher.value = options.identityType === '2'
schoolId.value = options.schoolId
resetQuery()
visible.value = true
getList()
}
/**
* 获取学生列表
*/
const getList = async () => {
loading.value = true
try {
const params = {
pageNum: queryParams.pageNum,
pageSize: queryParams.pageSize,
studentName: queryParams.studentName || undefined,
studentNo: queryParams.studentNo || undefined,
memberId: memberId.value,
schoolId: isTeacher.value ? schoolId.value : undefined
}
const res = await request.get('/business/student/available', { params })
studentList.value = res.rows || []
total.value = res.total || 0
} catch (e) {
console.error('获取学生列表失败', e)
studentList.value = []
total.value = 0
} finally {
loading.value = false
}
}
/**
* 搜索
*/
const handleQuery = () => {
queryParams.pageNum = 1
getList()
}
/**
* 重置搜索
*/
const resetQuery = () => {
queryParams.pageNum = 1
queryParams.pageSize = 10
queryParams.studentName = ''
queryParams.studentNo = ''
}
/**
* 分页大小变化
*/
const handleSizeChange = (val) => {
queryParams.pageSize = val
getList()
}
/**
* 页码变化
*/
const handleCurrentChange = (val) => {
queryParams.pageNum = val
getList()
}
/**
* 选择变化
*/
const handleSelectionChange = (selection) => {
selectedStudents.value = selection
}
/**
* 确定绑定
*/
const handleConfirm = async () => {
if (selectedStudents.value.length === 0) {
ElMessage.warning('请选择要绑定的学生')
return
}
//
const studentIds = selectedStudents.value
.filter(s => s.memberId !== memberId.value)
.map(s => s.studentId)
if (studentIds.length === 0) {
ElMessage.info('所选学生已全部绑定')
visible.value = false
return
}
submitLoading.value = true
try {
const res = await request.post(`/business/member/${memberId.value}/bindStudents`, studentIds)
if (res.code === 200) {
ElMessage.success(`成功绑定 ${studentIds.length} 名学生`)
visible.value = false
emit('success')
}
} finally {
submitLoading.value = false
}
}
defineExpose({ open })
</script>
<style scoped>
.search-form {
margin-bottom: 12px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
}
</style>