Compare commits

...

22 Commits

Author SHA1 Message Date
神码-方晓辉 e8c4f3f568 style: 学生弹窗归属用户改为只读红色标签
- 归属用户字段改为只读展示
- 展示格式:会员昵称(手机号)
- 红色字体显示
- 移除选择/清除按钮
2026-02-02 21:12:20 +08:00
神码-方晓辉 43ee2dbf87 refactor: 删除学生管理中的学科字段
前端:
- 列表页删除学科列
- 编辑弹窗删除学科下拉框

后端:
- PgStudent 实体删除 subjectId 字段
- StudentVo 删除 subjectId 和 subjectName 字段
- Service 层删除学科关联查询

需求文档:
- 删除学生数据项中的学科字段
- 删除表格列定义中的学科列

数据库需执行:ALTER TABLE pg_student DROP COLUMN subject_id;
2026-02-02 21:11:37 +08:00
神码-方晓辉 b5c2692aab feat: 学生导入重新选择文件时自动覆盖之前的记录
在 beforeUpload 中调用 clearFiles() 清空文件列表,
实现"选择即覆盖"效果,无需手动删除再重新选择

同步更新需求文档
2026-02-02 21:04:40 +08:00
神码-方晓辉 e1ea099a38 fix: 修复学生管理左侧树筛选条件未正确传递的问题
问题:点击年级/班级节点时,只传了当前节点ID,未带上父级条件
修复:
1. 参数名改为与后端一致(schoolGradeId、schoolClassId)
2. 点击年级节点时,同时传递 schoolId + schoolGradeId
3. 点击班级节点时,同时传递 schoolId + schoolGradeId + schoolClassId

同步更新需求文档
2026-02-02 21:00:57 +08:00
神码-方晓辉 904c7c9500 feat: 学生导入增加教师身份校验
当会员手机号对应的会员身份为"教师"时,校验规则:
1. 教师必须已设置学校信息,否则提示"未设置学校信息,无法绑定学生"
2. 教师所属区域必须与学生所属区域一致
3. 教师所属学校必须与学生所属学校一致
4. 教师所属年级必须与学生所属年级一致
5. 教师所属班级必须与学生所属班级一致
不一致则导入失败并返回具体原因

同步更新需求文档
2026-02-02 20:53:44 +08:00
神码-方晓辉 18b9a09348 fix: 会员列表昵称列自适应宽度填充表格 2026-02-02 20:43:24 +08:00
神码-方晓辉 a384b27033 fix: 优化会员列表页列宽度
- 会员编号:160 → 185(显示完整编号)
- 手机号:150 → 145
- 昵称:min-width 100 → 固定 120(减少浪费空间)
2026-02-02 20:42:49 +08:00
神码-方晓辉 3d70bd0356 fix: 会员管理编辑页绑定学生显示学校、年级、班级
修改 selectByMemberId 接口返回 StudentVo 而非 PgStudent,
包含学校名称、年级名称、班级名称等关联数据
2026-02-02 20:40:09 +08:00
神码-方晓辉 6eb0643ae7 docs: 更新需求文档中出生日期格式说明
1. 会员管理:出生日期格式 YYYY-MM-DD
2. 学生管理:出生日期格式 YYYY-MM-DD
3. 学生导入:出生日期格式 YYYY-MM-DD,用户手机号改为会员手机号
2026-02-02 20:35:46 +08:00
神码-方晓辉 4e54dc8422 fix: 会员管理出生日期只显示年月日(YYYY-MM-DD)
去除时分秒显示,保持格式一致
2026-02-02 20:34:50 +08:00
神码-方晓辉 26efdc42fc feat: 会员管理手机号列添加眼睛按钮切换显示
点击眼睛图标可切换手机号的脱敏/完整显示
2026-02-02 20:33:57 +08:00
神码-方晓辉 cca0902d28 fix: 学生管理出生年月改为出生日期(YYYY-MM-DD)
1. 新增/编辑弹窗:日期选择器从月份改为日期
2. 列表页:出生日期列显示完整日期格式
2026-02-02 20:33:10 +08:00
神码-方晓辉 c18970ee98 fix: 修复学生导入时创建会员缺少member_code的问题
创建会员时自动生成会员编码(M+时间戳+4位随机数)
同时设置注册时间
2026-02-02 20:25:45 +08:00
神码-方晓辉 da75094367 fix: 修复学生导入上传认证失败的问题
使用自定义上传方法(http-request)替代原生action,
通过项目的request工具发送请求,自动走Vite代理并携带token
2026-02-02 20:19:48 +08:00
神码-方晓辉 ea1524ea67 feat: 实现学生批量导入功能
1. 新增 POST /business/student/import 接口
2. 实现导入业务逻辑:
   - 校验必填字段(姓名、学号、会员手机号、学校、年级、班级)
   - 根据名称匹配学校、年级、班级
   - 根据手机号查找或创建会员(不存在则自动创建家长账号)
   - 检查学号唯一性
   - 返回导入结果(成功数、失败数、失败明细)
2026-02-02 20:11:56 +08:00
神码-方晓辉 eb7ef037f0 fix: 学生导入模板出生年月改为出生日期(YYYY-MM-DD) 2026-02-02 20:06:43 +08:00
神码-方晓辉 4eea1eef34 fix: 修复学生导入模板下载功能
使用 axios (request) 替代 fetch,走 Vite 代理正确转发请求
2026-02-02 20:04:14 +08:00
神码-方晓辉 ca433d6ab9 feat: 完善学生导入模板下载功能
1. 后端新增接口 /business/student/template
   - 使用 ExcelUtil 导出模板
   - 包含示例数据方便用户参考

2. 前端修复模板下载:
   - 使用 fetch 下载文件
   - 文字"用户手机号"改为"会员手机号"

3. 新增 StudentImportDto 导入数据对象
   - 字段:姓名、学号、会员手机号、区域、学校、年级、班级、性别、出生年月
2026-02-02 20:02:41 +08:00
神码-方晓辉 6784e32e1e fix: 修复学生管理模块问题
1. 后端修复:
   - 添加 StudentVo 视图类,包含学校名、年级名、班级名、学科名、会员昵称等关联数据
   - Service 返回 StudentVo,关联查询各表数据

2. 前端修复:
   - StudentDialog 改为自获取 schoolTree 和 subjectList
   - 修复字段名适配(studentId/studentName)
   - 编辑时完整回显学校/年级/班级三级路径
   - 添加 MemberSelectDialog 会员选择弹窗

3. ImportDialog 完善:
   - 修复 token 获取方式适配 RuoYi 框架
   - 完善上传进度显示

4. index.vue 修复:
   - 表格列字段名适配后端返回数据
   - 添加日期格式化函数
2026-02-02 19:57:14 +08:00
神码-方晓辉 9883fddb67 fix: 学生管理左侧学校树字段映射修复
将 el-tree 的 label 属性从 'label' 改为 'name',
与后端 SchoolTreeNode 返回的字段名保持一致。
2026-02-02 19:49:12 +08:00
神码-方晓辉 a9f57646fa fix: 添加学生管理学校树接口
前端调用 /business/student/schoolTree 接口获取学校树结构,
但后端缺失此接口导致路径匹配到 /{studentId} 引发类型转换错误。
2026-02-02 19:44:41 +08:00
神码-方晓辉 2baf792159 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问题
2026-02-02 19:41:24 +08:00
22 changed files with 1612 additions and 203 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.vo.StudentVo;
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<StudentVo>> 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

@ -1,17 +1,30 @@
package org.dromara.pangu.student.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.excel.utils.ExcelUtil;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.dromara.pangu.school.domain.PgSchool;
import org.dromara.pangu.school.domain.vo.SchoolTreeNode;
import org.dromara.pangu.school.service.IPgSchoolService;
import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.domain.dto.StudentImportDto;
import org.dromara.pangu.student.domain.vo.StudentVo;
import org.dromara.pangu.student.service.IPgStudentService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 学生管理
@ -25,16 +38,25 @@ import org.springframework.web.bind.annotation.*;
public class PgStudentController extends BaseController {
private final IPgStudentService studentService;
private final IPgSchoolService schoolService;
@SaCheckPermission("business:student:list")
@GetMapping("/list")
public TableDataInfo<PgStudent> list(PgStudent student, PageQuery pageQuery) {
public TableDataInfo<StudentVo> list(PgStudent student, PageQuery pageQuery) {
return studentService.selectPageList(student, pageQuery);
}
/**
* 获取学校树结构用于学生管理左侧筛选
*/
@GetMapping("/schoolTree")
public R<List<SchoolTreeNode>> schoolTree() {
return R.ok(schoolService.selectSchoolTree(new PgSchool()));
}
@SaCheckPermission("business:student:query")
@GetMapping("/{studentId}")
public R<PgStudent> getInfo(@PathVariable Long studentId) {
public R<StudentVo> getInfo(@PathVariable Long studentId) {
return R.ok(studentService.selectById(studentId));
}
@ -58,4 +80,67 @@ 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<List<StudentVo>> listByMemberId(@PathVariable Long memberId) {
return R.ok(studentService.selectByMemberId(memberId));
}
/**
* 下载学生导入模板
*/
@GetMapping("/template")
public void downloadTemplate(HttpServletResponse response) {
// 创建示例数据方便用户参考填写
List<StudentImportDto> sampleData = new ArrayList<>();
StudentImportDto sample = new StudentImportDto();
sample.setStudentName("张三");
sample.setStudentNo("2026001");
sample.setMemberPhone("13800138000");
sample.setRegionPath("湖北省-武汉市-硚口区");
sample.setSchoolName("武汉市第二中学");
sample.setGradeName("高一");
sample.setClassName("1班");
sample.setGender("");
sample.setBirthday("2010-01-15");
sampleData.add(sample);
ExcelUtil.exportExcel(sampleData, "学生导入模板", StudentImportDto.class, response);
}
/**
* 批量导入学生
*/
@SaCheckPermission("business:student:import")
@Log(title = "学生管理", businessType = BusinessType.IMPORT)
@PostMapping("/import")
public R<Map<String, Object>> importData(MultipartFile file) throws Exception {
// 解析 Excel 数据
List<StudentImportDto> dataList = ExcelUtil.importExcel(file.getInputStream(), StudentImportDto.class);
// 调用服务层处理导入
Map<String, Object> result = studentService.importStudents(dataList);
return R.ok(result);
}
}

View File

@ -44,8 +44,6 @@ public class PgStudent extends BaseEntity {
private Long schoolClassId;
private Long subjectId;
private Long memberId;
private String status;

View File

@ -0,0 +1,52 @@
package org.dromara.pangu.student.domain.dto;
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.write.style.ColumnWidth;
import cn.idev.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;
/**
* 学生导入数据传输对象
*
* @author pangu
*/
@Data
@HeadRowHeight(25)
public class StudentImportDto {
@ExcelProperty(value = "姓名", index = 0)
@ColumnWidth(15)
private String studentName;
@ExcelProperty(value = "学号", index = 1)
@ColumnWidth(20)
private String studentNo;
@ExcelProperty(value = "会员手机号", index = 2)
@ColumnWidth(15)
private String memberPhone;
@ExcelProperty(value = "区域", index = 3)
@ColumnWidth(25)
private String regionPath;
@ExcelProperty(value = "学校", index = 4)
@ColumnWidth(25)
private String schoolName;
@ExcelProperty(value = "年级", index = 5)
@ColumnWidth(12)
private String gradeName;
@ExcelProperty(value = "班级", index = 6)
@ColumnWidth(10)
private String className;
@ExcelProperty(value = "性别", index = 7)
@ColumnWidth(8)
private String gender;
@ExcelProperty(value = "出生日期", index = 8)
@ColumnWidth(12)
private String birthday;
}

View File

@ -0,0 +1,70 @@
package org.dromara.pangu.student.domain.vo;
import lombok.Data;
import java.util.Date;
/**
* 学生视图对象包含关联数据
*
* @author pangu
*/
@Data
public class StudentVo {
private Long studentId;
private String studentName;
private String studentNo;
/**
* 性别0未知 1男 2女
*/
private String gender;
private Date birthday;
private Long regionId;
private String regionPath;
private Long schoolId;
/**
* 学校名称
*/
private String schoolName;
private Long schoolGradeId;
/**
* 年级名称
*/
private String gradeName;
private Long schoolClassId;
/**
* 班级名称
*/
private String className;
private Long memberId;
/**
* 会员昵称
*/
private String memberNickname;
/**
* 会员手机号
*/
private String memberPhone;
private String status;
private Date createTime;
private String remark;
}

View File

@ -3,8 +3,11 @@ package org.dromara.pangu.student.service;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.domain.dto.StudentImportDto;
import org.dromara.pangu.student.domain.vo.StudentVo;
import java.util.List;
import java.util.Map;
/**
* 学生 Service 接口
@ -12,10 +15,42 @@ import java.util.List;
* @author pangu
*/
public interface IPgStudentService {
TableDataInfo<PgStudent> selectPageList(PgStudent student, PageQuery pageQuery);
TableDataInfo<StudentVo> selectPageList(PgStudent student, PageQuery pageQuery);
List<PgStudent> selectList(PgStudent student);
PgStudent selectById(Long studentId);
StudentVo selectById(Long studentId);
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<StudentVo> selectByMemberId(Long memberId);
/**
* 批量绑定学生到会员
*/
int bindStudentsToMember(Long memberId, List<Long> studentIds);
/**
* 解绑学生
*/
int unbindStudent(Long studentId);
/**
* 批量导入学生
* @param dataList Excel 导入的数据列表
* @return 导入结果 {successCount, failCount, failList}
*/
Map<String, Object> importStudents(List<StudentImportDto> dataList);
}

View File

@ -1,35 +1,69 @@
package org.dromara.pangu.student.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.pangu.base.domain.PgClass;
import org.dromara.pangu.base.domain.PgGrade;
import org.dromara.pangu.base.mapper.PgClassMapper;
import org.dromara.pangu.base.mapper.PgGradeMapper;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.mapper.PgMemberMapper;
import org.dromara.pangu.school.domain.PgSchool;
import org.dromara.pangu.school.domain.PgSchoolClass;
import org.dromara.pangu.school.domain.PgSchoolGrade;
import org.dromara.pangu.school.mapper.PgSchoolClassMapper;
import org.dromara.pangu.school.mapper.PgSchoolGradeMapper;
import org.dromara.pangu.school.mapper.PgSchoolMapper;
import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.domain.dto.StudentImportDto;
import org.dromara.pangu.student.domain.vo.StudentVo;
import org.dromara.pangu.student.mapper.PgStudentMapper;
import org.dromara.pangu.student.service.IPgStudentService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 学生 Service 实现
*
* @author pangu
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class PgStudentServiceImpl implements IPgStudentService {
private final PgStudentMapper baseMapper;
private final PgSchoolMapper schoolMapper;
private final PgSchoolGradeMapper schoolGradeMapper;
private final PgSchoolClassMapper schoolClassMapper;
private final PgGradeMapper gradeMapper;
private final PgClassMapper classMapper;
private final PgMemberMapper memberMapper;
@Override
public TableDataInfo<PgStudent> selectPageList(PgStudent student, PageQuery pageQuery) {
public TableDataInfo<StudentVo> selectPageList(PgStudent student, PageQuery pageQuery) {
LambdaQueryWrapper<PgStudent> lqw = buildQueryWrapper(student);
Page<PgStudent> page = baseMapper.selectPage(pageQuery.build(), lqw);
return TableDataInfo.build(page);
// 转换为 VO 并填充关联数据
List<StudentVo> voList = convertToVoList(page.getRecords());
Page<StudentVo> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
voPage.setRecords(voList);
return TableDataInfo.build(voPage);
}
@Override
@ -38,8 +72,12 @@ public class PgStudentServiceImpl implements IPgStudentService {
}
@Override
public PgStudent selectById(Long studentId) {
return baseMapper.selectById(studentId);
public StudentVo selectById(Long studentId) {
PgStudent student = baseMapper.selectById(studentId);
if (student == null) {
return null;
}
return convertToVo(student);
}
@Override
@ -61,6 +99,7 @@ public class PgStudentServiceImpl implements IPgStudentService {
LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>();
lqw.like(StrUtil.isNotBlank(student.getStudentName()), PgStudent::getStudentName, student.getStudentName());
lqw.like(StrUtil.isNotBlank(student.getStudentNo()), PgStudent::getStudentNo, student.getStudentNo());
lqw.eq(StrUtil.isNotBlank(student.getGender()), PgStudent::getGender, student.getGender());
lqw.eq(student.getSchoolId() != null, PgStudent::getSchoolId, student.getSchoolId());
lqw.eq(student.getSchoolGradeId() != null, PgStudent::getSchoolGradeId, student.getSchoolGradeId());
lqw.eq(student.getSchoolClassId() != null, PgStudent::getSchoolClassId, student.getSchoolClassId());
@ -68,4 +107,436 @@ public class PgStudentServiceImpl implements IPgStudentService {
lqw.orderByDesc(PgStudent::getCreateTime);
return lqw;
}
/**
* 批量转换为 VO 并填充关联数据
*/
private List<StudentVo> convertToVoList(List<PgStudent> students) {
if (students == null || students.isEmpty()) {
return Collections.emptyList();
}
// 收集所有需要查询的 ID
Set<Long> schoolIds = new HashSet<>();
Set<Long> schoolGradeIds = new HashSet<>();
Set<Long> schoolClassIds = new HashSet<>();
Set<Long> memberIds = new HashSet<>();
for (PgStudent s : students) {
if (s.getSchoolId() != null) schoolIds.add(s.getSchoolId());
if (s.getSchoolGradeId() != null) schoolGradeIds.add(s.getSchoolGradeId());
if (s.getSchoolClassId() != null) schoolClassIds.add(s.getSchoolClassId());
if (s.getMemberId() != null) memberIds.add(s.getMemberId());
}
// 批量查询关联数据
Map<Long, PgSchool> schoolMap = schoolIds.isEmpty() ? Collections.emptyMap() :
schoolMapper.selectByIds(schoolIds).stream()
.collect(Collectors.toMap(PgSchool::getSchoolId, Function.identity()));
Map<Long, PgSchoolGrade> schoolGradeMap = schoolGradeIds.isEmpty() ? Collections.emptyMap() :
schoolGradeMapper.selectByIds(schoolGradeIds).stream()
.collect(Collectors.toMap(PgSchoolGrade::getId, Function.identity()));
Map<Long, PgSchoolClass> schoolClassMap = schoolClassIds.isEmpty() ? Collections.emptyMap() :
schoolClassMapper.selectByIds(schoolClassIds).stream()
.collect(Collectors.toMap(PgSchoolClass::getId, Function.identity()));
Map<Long, PgMember> memberMap = memberIds.isEmpty() ? Collections.emptyMap() :
memberMapper.selectByIds(memberIds).stream()
.collect(Collectors.toMap(PgMember::getMemberId, Function.identity()));
// 收集基础年级和班级 ID
Set<Long> baseGradeIds = schoolGradeMap.values().stream()
.map(PgSchoolGrade::getGradeId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Set<Long> baseClassIds = schoolClassMap.values().stream()
.map(PgSchoolClass::getClassId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
// 查询基础年级和班级名称
Map<Long, String> gradeNameMap = baseGradeIds.isEmpty() ? Collections.emptyMap() :
gradeMapper.selectByIds(baseGradeIds).stream()
.collect(Collectors.toMap(PgGrade::getGradeId, PgGrade::getGradeName));
Map<Long, String> classNameMap = baseClassIds.isEmpty() ? Collections.emptyMap() :
classMapper.selectByIds(baseClassIds).stream()
.collect(Collectors.toMap(PgClass::getClassId, PgClass::getClassName));
// 转换为 VO
List<StudentVo> voList = new ArrayList<>();
for (PgStudent s : students) {
StudentVo vo = new StudentVo();
BeanUtil.copyProperties(s, vo);
// 填充学校名称
PgSchool school = schoolMap.get(s.getSchoolId());
if (school != null) {
vo.setSchoolName(school.getSchoolName());
}
// 填充年级名称
PgSchoolGrade schoolGrade = schoolGradeMap.get(s.getSchoolGradeId());
if (schoolGrade != null && schoolGrade.getGradeId() != null) {
vo.setGradeName(gradeNameMap.get(schoolGrade.getGradeId()));
}
// 填充班级名称
PgSchoolClass schoolClass = schoolClassMap.get(s.getSchoolClassId());
if (schoolClass != null && schoolClass.getClassId() != null) {
vo.setClassName(classNameMap.get(schoolClass.getClassId()));
}
// 填充会员信息
PgMember member = memberMap.get(s.getMemberId());
if (member != null) {
vo.setMemberNickname(member.getNickname());
vo.setMemberPhone(member.getPhone());
}
voList.add(vo);
}
return voList;
}
/**
* 单个转换为 VO
*/
private StudentVo convertToVo(PgStudent student) {
List<StudentVo> voList = convertToVoList(Collections.singletonList(student));
return voList.isEmpty() ? null : voList.get(0);
}
@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<StudentVo> selectByMemberId(Long memberId) {
if (memberId == null) {
return List.of();
}
List<PgStudent> students = baseMapper.selectList(
new LambdaQueryWrapper<PgStudent>()
.eq(PgStudent::getMemberId, memberId)
.orderByDesc(PgStudent::getCreateTime)
);
// 转换为 VO包含学校年级班级名称
return convertToVoList(students);
}
@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;
}
return baseMapper.update(null,
new LambdaUpdateWrapper<PgStudent>()
.eq(PgStudent::getStudentId, studentId)
.set(PgStudent::getMemberId, null)
);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> importStudents(List<StudentImportDto> dataList) {
int successCount = 0;
List<Map<String, Object>> failList = new ArrayList<>();
// 预先查询所有学校方便匹配
List<PgSchool> allSchools = schoolMapper.selectList(new LambdaQueryWrapper<>());
Map<String, PgSchool> schoolNameMap = allSchools.stream()
.collect(Collectors.toMap(PgSchool::getSchoolName, Function.identity(), (a, b) -> a));
// 预先查询所有年级
List<PgGrade> allGrades = gradeMapper.selectList(new LambdaQueryWrapper<>());
Map<String, PgGrade> gradeNameMap = allGrades.stream()
.collect(Collectors.toMap(PgGrade::getGradeName, Function.identity(), (a, b) -> a));
// 预先查询所有班级
List<PgClass> allClasses = classMapper.selectList(new LambdaQueryWrapper<>());
Map<String, PgClass> classNameMap = allClasses.stream()
.collect(Collectors.toMap(PgClass::getClassName, Function.identity(), (a, b) -> a));
for (int i = 0; i < dataList.size(); i++) {
StudentImportDto dto = dataList.get(i);
int rowNum = i + 2; // Excel 行号从2开始1是表头
try {
// 1. 校验必填字段
String error = validateImportRow(dto);
if (error != null) {
failList.add(createFailItem(rowNum, error));
continue;
}
// 2. 查找学校
PgSchool school = schoolNameMap.get(dto.getSchoolName().trim());
if (school == null) {
failList.add(createFailItem(rowNum, "学校\"" + dto.getSchoolName() + "\"不存在"));
continue;
}
// 3. 查找年级学校年级关联表
PgGrade baseGrade = gradeNameMap.get(dto.getGradeName().trim());
if (baseGrade == null) {
failList.add(createFailItem(rowNum, "年级\"" + dto.getGradeName() + "\"不存在"));
continue;
}
PgSchoolGrade schoolGrade = schoolGradeMapper.selectOne(
new LambdaQueryWrapper<PgSchoolGrade>()
.eq(PgSchoolGrade::getSchoolId, school.getSchoolId())
.eq(PgSchoolGrade::getGradeId, baseGrade.getGradeId())
);
if (schoolGrade == null) {
failList.add(createFailItem(rowNum, "学校\"" + dto.getSchoolName() + "\"下没有年级\"" + dto.getGradeName() + "\""));
continue;
}
// 4. 查找班级学校班级关联表
PgClass baseClass = classNameMap.get(dto.getClassName().trim());
if (baseClass == null) {
failList.add(createFailItem(rowNum, "班级\"" + dto.getClassName() + "\"不存在"));
continue;
}
PgSchoolClass schoolClass = schoolClassMapper.selectOne(
new LambdaQueryWrapper<PgSchoolClass>()
.eq(PgSchoolClass::getSchoolGradeId, schoolGrade.getId())
.eq(PgSchoolClass::getClassId, baseClass.getClassId())
);
if (schoolClass == null) {
failList.add(createFailItem(rowNum, "年级\"" + dto.getGradeName() + "\"下没有班级\"" + dto.getClassName() + "\""));
continue;
}
// 5. 查找或创建会员
PgMember member = findOrCreateMember(dto.getMemberPhone().trim());
Long memberId = member.getMemberId();
// 6. 教师身份校验教师的区域/学校/年级/班级必须与学生一致
if ("2".equals(member.getIdentityType())) {
String teacherError = validateTeacherStudent(member, school, schoolGrade, schoolClass);
if (teacherError != null) {
failList.add(createFailItem(rowNum, teacherError));
continue;
}
}
// 7. 检查学号是否重复
if (StrUtil.isNotBlank(dto.getStudentNo())) {
PgStudent existStudent = baseMapper.selectOne(
new LambdaQueryWrapper<PgStudent>()
.eq(PgStudent::getStudentNo, dto.getStudentNo().trim())
);
if (existStudent != null) {
failList.add(createFailItem(rowNum, "学号\"" + dto.getStudentNo() + "\"已存在"));
continue;
}
}
// 8. 创建学生
PgStudent student = new PgStudent();
student.setStudentName(dto.getStudentName().trim());
student.setStudentNo(StrUtil.isNotBlank(dto.getStudentNo()) ? dto.getStudentNo().trim() : null);
student.setGender(convertGender(dto.getGender()));
student.setBirthday(parseBirthday(dto.getBirthday()));
student.setRegionPath(dto.getRegionPath());
student.setSchoolId(school.getSchoolId());
student.setSchoolGradeId(schoolGrade.getId());
student.setSchoolClassId(schoolClass.getId());
student.setMemberId(memberId);
student.setStatus("0"); // 默认正常
baseMapper.insert(student);
successCount++;
} catch (Exception e) {
log.error("导入第{}行数据失败", rowNum, e);
failList.add(createFailItem(rowNum, "系统错误:" + e.getMessage()));
}
}
Map<String, Object> result = new HashMap<>();
result.put("successCount", successCount);
result.put("failCount", failList.size());
result.put("failList", failList);
return result;
}
/**
* 校验导入行数据
*/
private String validateImportRow(StudentImportDto dto) {
if (StrUtil.isBlank(dto.getStudentName())) {
return "姓名不能为空";
}
if (StrUtil.isBlank(dto.getStudentNo())) {
return "学号不能为空";
}
if (StrUtil.isBlank(dto.getMemberPhone())) {
return "会员手机号不能为空";
}
if (!dto.getMemberPhone().matches("^1[3-9]\\d{9}$")) {
return "会员手机号格式不正确";
}
if (StrUtil.isBlank(dto.getSchoolName())) {
return "学校不能为空";
}
if (StrUtil.isBlank(dto.getGradeName())) {
return "年级不能为空";
}
if (StrUtil.isBlank(dto.getClassName())) {
return "班级不能为空";
}
return null;
}
/**
* 创建失败项
*/
private Map<String, Object> createFailItem(int row, String reason) {
Map<String, Object> item = new HashMap<>();
item.put("row", row);
item.put("reason", reason);
return item;
}
/**
* 查找或创建会员
*/
private PgMember findOrCreateMember(String phone) {
// 先查找已有会员
PgMember member = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>()
.eq(PgMember::getPhone, phone)
);
if (member != null) {
return member;
}
// 不存在则创建新会员身份为家长初始密码123456
PgMember newMember = new PgMember();
newMember.setMemberCode(generateMemberCode()); // 生成会员编码
newMember.setPhone(phone);
newMember.setNickname("家长" + phone.substring(7)); // 默认昵称
newMember.setIdentityType("1"); // 家长
newMember.setPassword(BCrypt.hashpw("123456")); // 初始密码
newMember.setStatus("0"); // 正常
newMember.setRegisterSource("4"); // 批量导入
newMember.setRegisterTime(new Date()); // 注册时间
memberMapper.insert(newMember);
return newMember;
}
/**
* 校验教师与学生的归属关系
* 教师的区域/学校/年级/班级必须与学生一致
*/
private String validateTeacherStudent(PgMember teacher, PgSchool studentSchool,
PgSchoolGrade studentGrade, PgSchoolClass studentClass) {
String teacherInfo = "教师\"" + (teacher.getNickname() != null ? teacher.getNickname() : "未知")
+ "\"(" + teacher.getPhone() + ")";
// 检查教师是否设置了学校信息
if (teacher.getSchoolId() == null || teacher.getSchoolGradeId() == null || teacher.getSchoolClassId() == null) {
return teacherInfo + "未设置学校信息,无法绑定学生";
}
// 校验区域通过学校的区域ID间接校验
if (teacher.getRegionId() != null && studentSchool.getRegionId() != null) {
if (!teacher.getRegionId().equals(studentSchool.getRegionId())) {
return teacherInfo + "所属区域与学生不一致";
}
}
// 校验学校
if (!teacher.getSchoolId().equals(studentSchool.getSchoolId())) {
return teacherInfo + "所属学校与学生不一致";
}
// 校验年级
if (!teacher.getSchoolGradeId().equals(studentGrade.getId())) {
return teacherInfo + "所属年级与学生不一致";
}
// 校验班级
if (!teacher.getSchoolClassId().equals(studentClass.getId())) {
return teacherInfo + "所属班级与学生不一致";
}
return null; // 校验通过
}
/**
* 生成会员编码M + 年月日时分秒毫秒 + 4位随机数
*/
private String generateMemberCode() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS");
String dateStr = sdf.format(new Date());
int random = (int) (Math.random() * 9000) + 1000;
return "M" + dateStr + random;
}
/**
* 转换性别
*/
private String convertGender(String gender) {
if (StrUtil.isBlank(gender)) {
return "0"; // 未知
}
if ("".equals(gender.trim())) {
return "1";
}
if ("".equals(gender.trim())) {
return "2";
}
return "0"; // 未知
}
/**
* 解析出生日期
*/
private Date parseBirthday(String birthday) {
if (StrUtil.isBlank(birthday)) {
return null;
}
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
return sdf.parse(birthday.trim());
} catch (Exception e) {
return null;
}
}
}

View File

@ -299,7 +299,7 @@
| 手机号 | 文本 | ✓ | 11位手机号唯一 |
| 昵称 | 文本 | - | 最大长度50字符 |
| 性别 | 枚举 | - | 男/女/未知 |
| 出生日期 | 日期 | - | - |
| 出生日期 | 日期 | - | 格式YYYY-MM-DD |
| 身份类型 | 枚举 | ✓ | 家长/教师 |
| 所属区域 | 树形选择 | 条件必填 | 教师身份必填 |
| 所属学校 | 下拉选择 | 条件必填 | 教师身份必填 |
@ -322,8 +322,8 @@
| 功能编号 | 功能名称 | 功能描述 | 优先级 |
| ------- | ------ | ------------------- |:---:|
| STU-001 | 学生列表查询 | 按姓名、学号、性别、手机号、学科等筛选 | P0 |
| STU-002 | 学校树筛选 | 通过左侧学校树快速定位学生 | P0 |
| STU-001 | 学生列表查询 | 按姓名、学号、性别、手机号等筛选 | P0 |
| STU-002 | 学校树筛选 | 通过左侧学校树快速定位学生,点击节点时带上完整层级条件(学校+年级+班级) | P0 |
| STU-003 | 新增学生 | 手动创建学生信息 | P0 |
| STU-004 | 编辑学生 | 修改学生基本信息 | P0 |
| STU-005 | 删除学生 | 软删除学生信息 | P1 |
@ -356,7 +356,7 @@
| 6 | 年级 | ✓ | 年级名称,需与系统数据匹配 |
| 7 | 班级 | ✓ | 班级名称,需与系统数据匹配 |
| 8 | 性别 | - | 男/女 |
| 9 | 出生年月 | - | 格式YYYY-MM |
| 9 | 出生日期 | - | 格式YYYY-MM-DD |
#### 4.3.5 数据项说明
@ -367,12 +367,11 @@
| 姓名 | 文本 | ✓ | 最大长度50字符 |
| 学号 | 文本 | - | 唯一最大长度20字符 |
| 性别 | 枚举 | - | 男/女/未知 |
| 出生年月 | 日期 | - | - |
| 出生日期 | 日期 | - | 格式YYYY-MM-DD |
| 所属区域 | 树形选择 | ✓ | 省-市-区三级 |
| 所属学校 | 下拉选择 | ✓ | 依赖区域 |
| 所属年级 | 下拉选择 | ✓ | 依赖学校 |
| 所属班级 | 下拉选择 | ✓ | 依赖年级 |
| 学科 | 下拉选择 | - | 学科信息 |
| 归属用户 | 关联 | ✓ | 关联会员ID |
| 创建时间 | 日期时间 | - | 系统自动记录 |
@ -826,9 +825,12 @@
**批量导入要求:**
| 序号 | 要求内容 |
|:---:|--------|
| 1 | 下载模板,模板包含姓名-必填,学号-必填,用户手机号-必填,区域-必填,学校-必填,年级-必填,班级-必填,性别-选填,出生年月-选填 |
| 1 | 下载模板,模板包含姓名-必填,学号-必填,会员手机号-必填,区域-必填,学校-必填,年级-必填,班级-必填,性别-选填,出生日期-选填 |
| 2 | 导入数据时,检查必填信息,并检查区域/学校/年级/班级对应的信息一致 |
| 3 | 导入时同时需要检查用户手机号如果系统中存在用户手机号那么导入的学生就挂在当前用户下如果系统中不存在对应的用户自动创建当前手机号的用户身份为家长初始密码123456并把学生信息挂在当前用户下 |
| 4 | **教师身份校验**:如果会员手机号对应的会员身份为"教师",需校验教师与学生的归属关系一致(区域、学校、年级、班级),不一致则导入失败并提示原因 |
| 5 | 教师未设置学校信息时,无法绑定学生,提示"教师未设置学校信息" |
| 6 | 重新选择文件时自动覆盖之前的文件记录,无需手动删除 |
**表格列定义:**
| 字段 | 说明 |
@ -836,12 +838,11 @@
| 姓名 | 学生姓名 |
| 学号 | 学生学号 |
| 性别 | 男/女/未知 |
| 出生年月 | 日期格式 |
| 出生日期 | 格式YYYY-MM-DD |
| 地区 | 如:湖北省-武汉市-武昌区 |
| 学校 | 学校名称 |
| 年级 | 年级名称 |
| 班级 | 班级名称 |
| 学科 | 学科名称 |
| 用户身份 | 家长/教师 |
| 用户昵称 | 归属会员昵称 |
| 用户手机号 | 归属会员手机号 |

View File

@ -23,7 +23,7 @@ export default {
* 是否显示 tagsView
*/
tagsView: true,
/**
* 显示页签图标
*/

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>

View File

@ -48,10 +48,17 @@
</el-row>
<el-table v-loading="loading" :data="tableData" border stripe :header-cell-style="{ background: '#f5f7fa', color: '#606266' }" style="width: 100%">
<el-table-column prop="memberCode" label="会员编号" width="160" />
<el-table-column prop="phone" label="手机号" width="130">
<el-table-column prop="memberCode" label="会员编号" width="185" />
<el-table-column prop="phone" label="手机号" width="145">
<template #default="{ row }">
{{ maskPhone(row.phone) }}
<span>{{ phoneVisibleMap[row.memberId] ? row.phone : maskPhone(row.phone) }}</span>
<el-button
type="primary"
link
:icon="phoneVisibleMap[row.memberId] ? Hide : View"
style="margin-left: 4px"
@click="togglePhoneVisible(row.memberId)"
/>
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" min-width="100" show-overflow-tooltip />
@ -60,7 +67,11 @@
{{ row.gender === '1' ? '男' : row.gender === '2' ? '女' : '未知' }}
</template>
</el-table-column>
<el-table-column prop="birthday" label="出生日期" width="110" />
<el-table-column prop="birthday" label="出生日期" width="110">
<template #default="{ row }">
{{ formatBirthday(row.birthday) }}
</template>
</el-table-column>
<el-table-column prop="identityType" label="身份类型" width="85" align="center">
<template #default="{ row }">
<el-tag :type="row.identityType === '1' ? 'success' : 'warning'">
@ -129,7 +140,7 @@
* 会员管理页面
* @author pangu
*/
import { Delete, Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { Delete, Edit, Hide, Key, Plus, Refresh, Search, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import request from '@/utils/request'
@ -153,6 +164,14 @@ const queryParams = ref({
const memberDialogRef = ref()
const resetPwdDialogRef = ref()
// ID
const phoneVisibleMap = ref({})
// /
const togglePhoneVisible = (memberId) => {
phoneVisibleMap.value[memberId] = !phoneVisibleMap.value[memberId]
}
//
const maskPhone = (phone) => {
if (!phone || phone.length !== 11) return phone
@ -165,6 +184,22 @@ const formatRegisterSource = (source) => {
return map[source] || source
}
//
const formatBirthday = (birthday) => {
if (!birthday) return ''
// YYYY-MM-DD 10
if (typeof birthday === 'string' && birthday.length >= 10) {
return birthday.substring(0, 10)
}
// Date
const d = new Date(birthday)
if (isNaN(d.getTime())) return birthday
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
//
const getList = async () => {
loading.value = true

View File

@ -2,7 +2,7 @@
<el-dialog
v-model="visible"
title="批量导入学生"
width="500px"
width="550px"
:close-on-click-modal="false"
destroy-on-close
>
@ -16,34 +16,42 @@
<div style="line-height: 1.8">
1. 请先下载导入模板按模板格式填写数据<br>
2. 支持 xlsxxls 格式文件单次最多导入500条<br>
3. 必填字段姓名学校年级班级
3. 必填字段姓名学号会员手机号区域学校年级班级<br>
4. 若会员手机号已存在则挂载到已有会员否则自动创建家长账号
</div>
</template>
</el-alert>
<div style="margin-bottom: 16px;">
<el-button type="primary" :icon="Download" @click="handleDownloadTemplate">下载模板</el-button>
<el-button type="primary" :icon="Download" @click="handleDownloadTemplate" :loading="downloadLoading">
下载模板
</el-button>
</div>
<el-upload
ref="uploadRef"
:action="uploadUrl"
:headers="uploadHeaders"
:http-request="customUpload"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:show-file-list="true"
:limit="1"
accept=".xlsx,.xls"
drag
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 xlsx/xls 文件</div>
<div class="el-upload__tip">只能上传 xlsx/xls 文件文件大小不超过 10MB</div>
</template>
</el-upload>
<!-- 上传进度 -->
<div v-if="uploading" style="margin-top: 16px;">
<el-progress :percentage="uploadProgress" :status="uploadProgress === 100 ? 'success' : ''" />
<div style="margin-top: 8px; color: #909399; font-size: 12px;">
{{ uploadProgress === 100 ? '上传完成,正在处理数据...' : '正在上传...' }}
</div>
</div>
<!-- 导入结果 -->
<div v-if="importResult" style="margin-top: 16px;">
<el-alert
@ -52,15 +60,16 @@
:closable="false"
/>
<div v-if="importResult.failList && importResult.failList.length > 0" style="margin-top: 12px;">
<div style="margin-bottom: 8px; font-size: 14px; color: #606266;">失败明细</div>
<el-table :data="importResult.failList" border size="small" max-height="200">
<el-table-column prop="row" label="行号" width="80" />
<el-table-column prop="reason" label="失败原因" min-width="200" />
<el-table-column prop="row" label="行号" width="80" align="center" />
<el-table-column prop="reason" label="失败原因" min-width="200" show-overflow-tooltip />
</el-table>
</div>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
@ -70,44 +79,56 @@
* 学生批量导入弹窗
* @author pangu
*/
import { Download, Upload } from '@element-plus/icons-vue'
import { Download, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import request from '@/utils/request'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const emit = defineEmits(['success'])
const visible = ref(false)
const uploadRef = ref(null)
const importResult = ref(null)
//
const uploadUrl = '/business/student/import'
//
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return token ? { Authorization: 'Bearer ' + token } : {}
})
const downloadLoading = ref(false)
const uploading = ref(false)
const uploadProgress = ref(0)
//
const handleDownloadTemplate = () => {
//
ElMessage.info('模板下载功能需要对接后端下载接口')
const handleDownloadTemplate = async () => {
downloadLoading.value = true
try {
// 使 axios Vite
const response = await request({
url: '/business/student/template',
method: 'get',
responseType: 'blob'
})
// response blob
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '学生导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('模板下载成功')
} catch (e) {
console.error('下载模板失败:', e)
ElMessage.error('下载模板失败,请重试')
} finally {
downloadLoading.value = false
}
}
//
const beforeUpload = (file) => {
// ""
uploadRef.value?.clearFiles()
const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls')
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件')
@ -122,25 +143,73 @@ const beforeUpload = (file) => {
return true
}
//
const handleSuccess = (response) => {
if (response.code === 200) {
importResult.value = response.data
if (response.data.failCount === 0) {
ElMessage.success('导入成功')
emit('success')
// 使 request Vite token
const customUpload = async ({ file, onProgress, onSuccess, onError }) => {
uploading.value = true
uploadProgress.value = 0
const formData = new FormData()
formData.append('file', file)
try {
const response = await request({
url: '/business/student/import',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
uploadProgress.value = percent
onProgress({ percent })
}
})
uploading.value = false
uploadProgress.value = 0
// response axios
if (response.code === 200) {
importResult.value = response.data
if (response.data.failCount === 0) {
ElMessage.success(`导入成功,共导入 ${response.data.successCount} 条数据`)
emit('success')
} else {
ElMessage.warning('部分数据导入失败,请查看失败原因')
}
onSuccess(response)
} else {
ElMessage.warning('部分数据导入失败,请查看失败原因')
ElMessage.error(response.msg || '导入失败')
onError(new Error(response.msg || '导入失败'))
}
} else {
ElMessage.error(response.msg || '导入失败')
} catch (error) {
uploading.value = false
uploadProgress.value = 0
console.error('上传失败:', error)
ElMessage.error('文件上传失败,请重试')
onError(error)
}
}
//
const handleError = () => {
ElMessage.error('文件上传失败,请重试')
//
const handleClose = () => {
visible.value = false
//
importResult.value = null
uploading.value = false
uploadProgress.value = 0
}
//
const open = () => {
visible.value = true
importResult.value = null
uploading.value = false
uploadProgress.value = 0
}
defineExpose({ open })
</script>
<style scoped>

View File

@ -0,0 +1,156 @@
<template>
<el-dialog
v-model="visible"
title="选择归属用户"
width="700px"
:close-on-click-modal="false"
append-to-body
>
<!-- 搜索区域 -->
<el-form :inline="true" style="margin-bottom: 16px">
<el-form-item label="手机号">
<el-input v-model="queryParams.phone" placeholder="请输入手机号" clearable style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="queryParams.nickname" placeholder="请输入昵称" clearable style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table
v-loading="loading"
:data="tableData"
border
stripe
highlight-current-row
@current-change="handleCurrentChange"
style="width: 100%"
max-height="400"
>
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="nickname" label="昵称" width="120" />
<el-table-column prop="identityType" label="身份" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.identityType === '1' ? 'success' : 'primary'" size="small">
{{ row.identityType === '1' ? '家长' : '教师' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="gender" label="性别" width="60" align="center">
<template #default="{ row }">
{{ row.gender === '1' ? '男' : row.gender === '2' ? '女' : '未知' }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="注册时间" width="160" />
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
style="margin-top: 16px; justify-content: flex-end"
@size-change="getList"
@current-change="getList"
/>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedRow">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
/**
* 会员选择弹窗
* @author pangu
*/
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
import request from '@/utils/request'
const emit = defineEmits(['select'])
const visible = ref(false)
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const selectedRow = ref(null)
const queryParams = ref({
pageNum: 1,
pageSize: 10,
phone: '',
nickname: ''
})
//
const getList = async () => {
loading.value = true
try {
const res = await request.get('/business/member/list', { params: queryParams.value })
if (res.code === 200) {
tableData.value = res.rows || []
total.value = res.total || 0
}
} catch (e) {
console.error('获取会员列表失败:', e)
} finally {
loading.value = false
}
}
//
const handleQuery = () => {
queryParams.value.pageNum = 1
getList()
}
//
const resetQuery = () => {
queryParams.value = {
pageNum: 1,
pageSize: 10,
phone: '',
nickname: ''
}
getList()
}
//
const handleCurrentChange = (row) => {
selectedRow.value = row
}
//
const handleConfirm = () => {
if (!selectedRow.value) {
ElMessage.warning('请选择一个会员')
return
}
emit('select', selectedRow.value)
visible.value = false
}
//
const open = () => {
visible.value = true
selectedRow.value = null
queryParams.value = {
pageNum: 1,
pageSize: 10,
phone: '',
nickname: ''
}
getList()
}
defineExpose({ open })
</script>

View File

@ -5,19 +5,19 @@
width="600px"
:close-on-click-modal="false"
destroy-on-close
@open="handleOpen"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入学生姓名" maxlength="20" />
<el-form-item label="姓名" prop="studentName">
<el-input v-model="form.studentName" placeholder="请输入学生姓名" maxlength="50" />
</el-form-item>
<el-form-item label="学号" prop="studentNo">
<el-input v-model="form.studentNo" placeholder="请输入学号" maxlength="30" />
<el-input v-model="form.studentNo" placeholder="请输入学号(选填)" maxlength="30" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="form.gender">
@ -29,39 +29,32 @@
<el-form-item label="出生日期" prop="birthday">
<el-date-picker
v-model="form.birthday"
type="month"
placeholder="请选择出生年月"
format="YYYY-MM"
value-format="YYYY-MM"
type="date"
placeholder="请选择出生日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="学校信息" prop="schoolPath" required>
<el-form-item label="学校信息" prop="schoolClassId" required>
<el-cascader
v-model="form.schoolPath"
:options="schoolTree"
:options="schoolTreeData"
:props="{
value: 'id',
label: 'label',
label: 'name',
children: 'children',
checkStrictly: false
}"
placeholder="请选择学校/年级/班级"
clearable
filterable
style="width: 100%"
@change="handleSchoolChange"
/>
</el-form-item>
<el-form-item label="学科" prop="subject">
<el-select v-model="form.subject" placeholder="请选择学科" clearable style="width: 100%">
<el-option v-for="item in subjectList" :key="item.id" :label="item.name" :value="item.name" />
</el-select>
</el-form-item>
<el-form-item label="归属用户" prop="userId">
<el-input v-model="form.userNickname" placeholder="请输入用户昵称搜索" readonly>
<template #append>
<el-button @click="handleSelectUser">选择</el-button>
</template>
</el-input>
<el-form-item label="归属用户">
<span style="color: #F56C6C;">{{ form.memberDisplay || '暂无归属用户' }}</span>
</el-form-item>
</el-form>
<template #footer>
@ -76,95 +69,123 @@
* 学生新增/编辑弹窗
* @author pangu
*/
import { addStudent, getStudent, updateStudent } from '@/api/pangu/student'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue'
import { reactive, ref } from 'vue'
import request from '@/utils/request'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
studentId: {
type: [Number, null],
default: null
},
schoolTree: {
type: Array,
default: () => []
},
subjectList: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const isEdit = computed(() => !!props.studentId)
const emit = defineEmits(['success'])
const visible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const formLoading = ref(false)
const submitLoading = ref(false)
//
const schoolTreeData = ref([])
const initialForm = {
id: null,
name: '',
studentId: null,
studentName: '',
studentNo: '',
gender: '1',
birthday: '',
schoolPath: [],
subject: '',
userId: null,
userNickname: ''
schoolId: null,
schoolGradeId: null,
schoolClassId: null,
memberId: null,
memberDisplay: ''
}
const form = reactive({ ...initialForm })
const rules = {
name: [
studentName: [
{ required: true, message: '请输入学生姓名', trigger: 'blur' }
],
schoolPath: [
schoolClassId: [
{ required: true, message: '请选择学校/年级/班级', trigger: 'change' }
]
}
//
const handleOpen = async () => {
Object.assign(form, initialForm)
formRef.value?.clearValidate()
//
const getSchoolTree = async () => {
try {
const res = await request.get('/business/student/schoolTree')
if (res.code === 200) {
schoolTreeData.value = res.data || []
}
} catch (e) {
console.error('获取学校树失败:', e)
}
}
if (props.studentId) {
//
const formatBirthday = (date) => {
if (!date) return ''
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
//
const handleSchoolChange = (value) => {
if (value && value.length === 3) {
form.schoolId = value[0]
form.schoolGradeId = value[1]
form.schoolClassId = value[2]
} else {
form.schoolId = null
form.schoolGradeId = null
form.schoolClassId = null
}
}
//
const open = async (row = null) => {
visible.value = true
isEdit.value = !!row
formLoading.value = true
//
Object.assign(form, initialForm)
form.schoolPath = []
formRef.value?.clearValidate()
//
await getSchoolTree()
//
if (row) {
try {
const res = await getStudent(props.studentId)
if (res.data) {
const res = await request.get(`/business/student/${row.studentId}`)
if (res.code === 200 && res.data) {
const data = res.data
form.id = data.id
form.name = data.name
form.studentId = data.studentId
form.studentName = data.studentName
form.studentNo = data.studentNo
form.gender = data.gender
form.birthday = data.birthday
form.subject = data.subject
form.userId = data.userId
form.userNickname = data.userNickname
// schoolPath
form.schoolPath = [data.schoolId]
form.gender = data.gender || '0'
form.birthday = data.birthday ? formatBirthday(data.birthday) : ''
form.memberId = data.memberId
form.memberDisplay = data.memberNickname ? `${data.memberNickname}${data.memberPhone || ''}` : ''
//
if (data.schoolId && data.schoolGradeId && data.schoolClassId) {
form.schoolPath = [data.schoolId, data.schoolGradeId, data.schoolClassId]
form.schoolId = data.schoolId
form.schoolGradeId = data.schoolGradeId
form.schoolClassId = data.schoolClassId
}
}
} catch (e) {
console.error('获取学生详情失败:', e)
}
}
}
//
const handleSelectUser = () => {
//
ElMessage.info('用户选择功能需要对接会员管理模块')
formLoading.value = false
}
//
@ -174,22 +195,32 @@ const handleSubmit = async () => {
} catch (e) {
return
}
//
if (!form.schoolClassId) {
ElMessage.warning('请选择完整的学校/年级/班级信息')
return
}
submitLoading.value = true
try {
const submitData = {
...form,
schoolId: form.schoolPath[0],
gradeId: form.schoolPath[1],
classId: form.schoolPath[2]
studentId: form.studentId,
studentName: form.studentName,
studentNo: form.studentNo,
gender: form.gender,
birthday: form.birthday,
schoolId: form.schoolId,
schoolGradeId: form.schoolGradeId,
schoolClassId: form.schoolClassId,
memberId: form.memberId
}
delete submitData.schoolPath
if (isEdit.value) {
await updateStudent(submitData)
await request.put('/business/student', submitData)
ElMessage.success('修改成功')
} else {
await addStudent(submitData)
await request.post('/business/student', submitData)
ElMessage.success('新增成功')
}
visible.value = false
@ -200,4 +231,6 @@ const handleSubmit = async () => {
submitLoading.value = false
}
}
defineExpose({ open })
</script>

View File

@ -12,7 +12,7 @@
<el-tree
ref="treeRef"
:data="schoolTree"
:props="{ label: 'label', children: 'children' }"
:props="{ label: 'name', children: 'children' }"
node-key="id"
highlight-current
:filter-node-method="filterNode"
@ -28,7 +28,7 @@
<el-card shadow="never" class="search-wrapper">
<el-form :model="queryParams" :inline="true">
<el-form-item label="学生姓名">
<el-input v-model="queryParams.name" placeholder="请输入学生姓名" clearable style="width: 150px" @keyup.enter="handleQuery" />
<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" />
@ -59,18 +59,27 @@
<el-table v-loading="loading" :data="tableData" border stripe :header-cell-style="{ background: '#f5f7fa', color: '#606266' }" style="width: 100%">
<el-table-column prop="studentNo" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="studentName" label="姓名" width="100" />
<el-table-column prop="gender" label="性别" width="60" align="center">
<template #default="{ row }">
{{ row.gender === '1' ? '男' : row.gender === '2' ? '女' : '未知' }}
</template>
</el-table-column>
<el-table-column prop="birthday" label="出生年月" width="100" />
<el-table-column prop="birthday" label="出生日期" width="110">
<template #default="{ row }">
{{ row.birthday ? formatDate(row.birthday) : '' }}
</template>
</el-table-column>
<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="80" />
<el-table-column prop="subject" label="学科" width="80" />
<el-table-column prop="userNickname" label="归属用户" width="100" show-overflow-tooltip />
<el-table-column prop="memberNickname" label="归属用户" width="120" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.memberNickname">{{ row.memberNickname }}</span>
<span v-else-if="row.memberPhone">{{ row.memberPhone }}</span>
<span v-else style="color: #909399">未绑定</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="160" />
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
@ -129,12 +138,12 @@ const total = ref(0)
const queryParams = ref({
pageNum: 1,
pageSize: 10,
name: '',
studentName: '',
studentNo: '',
gender: '',
schoolId: '',
gradeId: '',
classId: ''
schoolId: null,
schoolGradeId: null,
schoolClassId: null
})
//
@ -149,7 +158,7 @@ watch(treeFilterText, (val) => {
//
const filterNode = (value, data) => {
if (!value) return true
return data.label.includes(value)
return data.name?.includes(value)
}
//
@ -176,16 +185,22 @@ const getList = async () => {
//
const handleNodeClick = (data) => {
//
//
if (data.type === 'school') {
//
queryParams.value.schoolId = data.id
queryParams.value.gradeId = ''
queryParams.value.classId = ''
queryParams.value.schoolGradeId = null
queryParams.value.schoolClassId = null
} else if (data.type === 'grade') {
queryParams.value.gradeId = data.id
queryParams.value.classId = ''
// +
queryParams.value.schoolId = data.schoolId
queryParams.value.schoolGradeId = data.id
queryParams.value.schoolClassId = null
} else if (data.type === 'class') {
queryParams.value.classId = data.id
// + +
queryParams.value.schoolId = data.schoolId
queryParams.value.schoolGradeId = data.schoolGradeId
queryParams.value.schoolClassId = data.id
}
queryParams.value.pageNum = 1
getList()
@ -202,12 +217,12 @@ const resetQuery = () => {
queryParams.value = {
pageNum: 1,
pageSize: 10,
name: '',
studentName: '',
studentNo: '',
gender: '',
schoolId: '',
gradeId: '',
classId: ''
schoolId: null,
schoolGradeId: null,
schoolClassId: null
}
treeRef.value?.setCurrentKey(null)
getList()
@ -228,14 +243,24 @@ const handleImport = () => {
importDialogRef.value?.open()
}
//
const formatDate = (date) => {
if (!date) return ''
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
//
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除学生"${row.name}"吗?`, '提示', {
ElMessageBox.confirm(`确定要删除学生"${row.studentName}"吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await request.delete(`/business/student/${row.id}`)
const res = await request.delete(`/business/student/${row.studentId}`)
if (res.code === 200) {
ElMessage.success('删除成功')
getList()