fix: 修复学生管理模块问题

1. 后端修复:
   - 添加 StudentVo 视图类,包含学校名、年级名、班级名、学科名、会员昵称等关联数据
   - Service 返回 StudentVo,关联查询各表数据

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

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

4. index.vue 修复:
   - 表格列字段名适配后端返回数据
   - 添加日期格式化函数
This commit is contained in:
神码-方晓辉 2026-02-02 19:57:14 +08:00
parent 9883fddb67
commit 6784e32e1e
8 changed files with 666 additions and 132 deletions

View File

@ -12,6 +12,7 @@ import org.dromara.pangu.school.domain.PgSchool;
import org.dromara.pangu.school.domain.vo.SchoolTreeNode; import org.dromara.pangu.school.domain.vo.SchoolTreeNode;
import org.dromara.pangu.school.service.IPgSchoolService; import org.dromara.pangu.school.service.IPgSchoolService;
import org.dromara.pangu.student.domain.PgStudent; import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.domain.vo.StudentVo;
import org.dromara.pangu.student.service.IPgStudentService; import org.dromara.pangu.student.service.IPgStudentService;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -34,7 +35,7 @@ public class PgStudentController extends BaseController {
@SaCheckPermission("business:student:list") @SaCheckPermission("business:student:list")
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo<PgStudent> list(PgStudent student, PageQuery pageQuery) { public TableDataInfo<StudentVo> list(PgStudent student, PageQuery pageQuery) {
return studentService.selectPageList(student, pageQuery); return studentService.selectPageList(student, pageQuery);
} }
@ -48,7 +49,7 @@ public class PgStudentController extends BaseController {
@SaCheckPermission("business:student:query") @SaCheckPermission("business:student:query")
@GetMapping("/{studentId}") @GetMapping("/{studentId}")
public R<PgStudent> getInfo(@PathVariable Long studentId) { public R<StudentVo> getInfo(@PathVariable Long studentId) {
return R.ok(studentService.selectById(studentId)); return R.ok(studentService.selectById(studentId));
} }

View File

@ -0,0 +1,77 @@
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 subjectId;
/**
* 学科名称
*/
private String subjectName;
private Long memberId;
/**
* 会员昵称
*/
private String memberNickname;
/**
* 会员手机号
*/
private String memberPhone;
private String status;
private Date createTime;
private String remark;
}

View File

@ -3,6 +3,7 @@ package org.dromara.pangu.student.service;
import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo; import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.pangu.student.domain.PgStudent; import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.domain.vo.StudentVo;
import java.util.List; import java.util.List;
@ -12,9 +13,9 @@ import java.util.List;
* @author pangu * @author pangu
*/ */
public interface IPgStudentService { public interface IPgStudentService {
TableDataInfo<PgStudent> selectPageList(PgStudent student, PageQuery pageQuery); TableDataInfo<StudentVo> selectPageList(PgStudent student, PageQuery pageQuery);
List<PgStudent> selectList(PgStudent student); List<PgStudent> selectList(PgStudent student);
PgStudent selectById(Long studentId); StudentVo selectById(Long studentId);
int insert(PgStudent student); int insert(PgStudent student);
int update(PgStudent student); int update(PgStudent student);
int deleteByIds(Long[] studentIds); int deleteByIds(Long[] studentIds);

View File

@ -1,18 +1,36 @@
package org.dromara.pangu.student.service.impl; package org.dromara.pangu.student.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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 com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.common.mybatis.core.page.PageQuery; import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo; 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.base.domain.PgSubject;
import org.dromara.pangu.base.mapper.PgSubjectMapper;
import org.dromara.pangu.student.domain.PgStudent; import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.domain.vo.StudentVo;
import org.dromara.pangu.student.mapper.PgStudentMapper; import org.dromara.pangu.student.mapper.PgStudentMapper;
import org.dromara.pangu.student.service.IPgStudentService; import org.dromara.pangu.student.service.IPgStudentService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Arrays; import java.util.*;
import java.util.List; import java.util.function.Function;
import java.util.stream.Collectors;
/** /**
* 学生 Service 实现 * 学生 Service 实现
@ -24,12 +42,25 @@ import java.util.List;
public class PgStudentServiceImpl implements IPgStudentService { public class PgStudentServiceImpl implements IPgStudentService {
private final PgStudentMapper baseMapper; 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 PgSubjectMapper subjectMapper;
private final PgMemberMapper memberMapper;
@Override @Override
public TableDataInfo<PgStudent> selectPageList(PgStudent student, PageQuery pageQuery) { public TableDataInfo<StudentVo> selectPageList(PgStudent student, PageQuery pageQuery) {
LambdaQueryWrapper<PgStudent> lqw = buildQueryWrapper(student); LambdaQueryWrapper<PgStudent> lqw = buildQueryWrapper(student);
Page<PgStudent> page = baseMapper.selectPage(pageQuery.build(), lqw); 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 @Override
@ -38,8 +69,12 @@ public class PgStudentServiceImpl implements IPgStudentService {
} }
@Override @Override
public PgStudent selectById(Long studentId) { public StudentVo selectById(Long studentId) {
return baseMapper.selectById(studentId); PgStudent student = baseMapper.selectById(studentId);
if (student == null) {
return null;
}
return convertToVo(student);
} }
@Override @Override
@ -61,6 +96,7 @@ public class PgStudentServiceImpl implements IPgStudentService {
LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>();
lqw.like(StrUtil.isNotBlank(student.getStudentName()), PgStudent::getStudentName, student.getStudentName()); lqw.like(StrUtil.isNotBlank(student.getStudentName()), PgStudent::getStudentName, student.getStudentName());
lqw.like(StrUtil.isNotBlank(student.getStudentNo()), PgStudent::getStudentNo, student.getStudentNo()); 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.getSchoolId() != null, PgStudent::getSchoolId, student.getSchoolId());
lqw.eq(student.getSchoolGradeId() != null, PgStudent::getSchoolGradeId, student.getSchoolGradeId()); lqw.eq(student.getSchoolGradeId() != null, PgStudent::getSchoolGradeId, student.getSchoolGradeId());
lqw.eq(student.getSchoolClassId() != null, PgStudent::getSchoolClassId, student.getSchoolClassId()); lqw.eq(student.getSchoolClassId() != null, PgStudent::getSchoolClassId, student.getSchoolClassId());
@ -69,20 +105,130 @@ public class PgStudentServiceImpl implements IPgStudentService {
return lqw; 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> subjectIds = 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.getSubjectId() != null) subjectIds.add(s.getSubjectId());
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, PgSubject> subjectMap = subjectIds.isEmpty() ? Collections.emptyMap() :
subjectMapper.selectByIds(subjectIds).stream()
.collect(Collectors.toMap(PgSubject::getSubjectId, 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()));
}
// 填充学科名称
PgSubject subject = subjectMap.get(s.getSubjectId());
if (subject != null) {
vo.setSubjectName(subject.getSubjectName());
}
// 填充会员信息
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 @Override
public TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery) { public TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery) {
LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>(); LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>();
// 姓名模糊查询
lqw.like(StrUtil.isNotBlank(studentName), PgStudent::getStudentName, studentName); lqw.like(StrUtil.isNotBlank(studentName), PgStudent::getStudentName, studentName);
// 学号模糊查询
lqw.like(StrUtil.isNotBlank(studentNo), PgStudent::getStudentNo, studentNo); lqw.like(StrUtil.isNotBlank(studentNo), PgStudent::getStudentNo, studentNo);
// 可绑定条件未被绑定 已绑定当前会员
lqw.and(wrapper -> wrapper lqw.and(wrapper -> wrapper
.isNull(PgStudent::getMemberId) .isNull(PgStudent::getMemberId)
.or() .or()
.eq(memberId != null, PgStudent::getMemberId, memberId) .eq(memberId != null, PgStudent::getMemberId, memberId)
); );
// 教师身份限制只能选本校学生
lqw.eq(schoolId != null, PgStudent::getSchoolId, schoolId); lqw.eq(schoolId != null, PgStudent::getSchoolId, schoolId);
lqw.orderByDesc(PgStudent::getCreateTime); lqw.orderByDesc(PgStudent::getCreateTime);
@ -122,9 +268,8 @@ public class PgStudentServiceImpl implements IPgStudentService {
if (studentId == null) { if (studentId == null) {
return 0; return 0;
} }
// 使用原生SQL更新memberId为null
return baseMapper.update(null, return baseMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PgStudent>() new LambdaUpdateWrapper<PgStudent>()
.eq(PgStudent::getStudentId, studentId) .eq(PgStudent::getStudentId, studentId)
.set(PgStudent::getMemberId, null) .set(PgStudent::getMemberId, null)
); );

View File

@ -2,7 +2,7 @@
<el-dialog <el-dialog
v-model="visible" v-model="visible"
title="批量导入学生" title="批量导入学生"
width="500px" width="550px"
:close-on-click-modal="false" :close-on-click-modal="false"
destroy-on-close destroy-on-close
> >
@ -16,13 +16,16 @@
<div style="line-height: 1.8"> <div style="line-height: 1.8">
1. 请先下载导入模板按模板格式填写数据<br> 1. 请先下载导入模板按模板格式填写数据<br>
2. 支持 xlsxxls 格式文件单次最多导入500条<br> 2. 支持 xlsxxls 格式文件单次最多导入500条<br>
3. 必填字段姓名学校年级班级 3. 必填字段姓名学号用户手机号区域学校年级班级<br>
4. 若用户手机号已存在则挂载到已有用户否则自动创建家长账号
</div> </div>
</template> </template>
</el-alert> </el-alert>
<div style="margin-bottom: 16px;"> <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> </div>
<el-upload <el-upload
@ -32,18 +35,27 @@
:before-upload="beforeUpload" :before-upload="beforeUpload"
:on-success="handleSuccess" :on-success="handleSuccess"
:on-error="handleError" :on-error="handleError"
:on-progress="handleProgress"
:show-file-list="true" :show-file-list="true"
:limit="1" :limit="1"
accept=".xlsx,.xls" accept=".xlsx,.xls"
drag 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> <div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip> <template #tip>
<div class="el-upload__tip">只能上传 xlsx/xls 文件</div> <div class="el-upload__tip">只能上传 xlsx/xls 文件文件大小不超过 10MB</div>
</template> </template>
</el-upload> </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;"> <div v-if="importResult" style="margin-top: 16px;">
<el-alert <el-alert
@ -52,15 +64,16 @@
:closable="false" :closable="false"
/> />
<div v-if="importResult.failList && importResult.failList.length > 0" style="margin-top: 12px;"> <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 :data="importResult.failList" border size="small" max-height="200">
<el-table-column prop="row" label="行号" width="80" /> <el-table-column prop="row" label="行号" width="80" align="center" />
<el-table-column prop="reason" label="失败原因" min-width="200" /> <el-table-column prop="reason" label="失败原因" min-width="200" show-overflow-tooltip />
</el-table> </el-table>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<el-button @click="visible = false">关闭</el-button> <el-button @click="handleClose">关闭</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@ -70,40 +83,45 @@
* 学生批量导入弹窗 * 学生批量导入弹窗
* @author pangu * @author pangu
*/ */
import { Download, Upload } from '@element-plus/icons-vue' import { Download, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { getToken } from '@/utils/auth'
const props = defineProps({ const emit = defineEmits(['success'])
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const visible = ref(false)
const uploadRef = ref(null) const uploadRef = ref(null)
const importResult = ref(null) const importResult = ref(null)
const downloadLoading = ref(false)
const uploading = ref(false)
const uploadProgress = ref(0)
// // 使
const uploadUrl = '/business/student/import' const uploadUrl = computed(() => {
return import.meta.env.VITE_APP_BASE_API + '/business/student/import'
})
// //
const uploadHeaders = computed(() => { const uploadHeaders = computed(() => {
const token = localStorage.getItem('token') const token = getToken()
return token ? { Authorization: 'Bearer ' + token } : {} return token ? { Authorization: 'Bearer ' + token } : {}
}) })
// //
const handleDownloadTemplate = () => { const handleDownloadTemplate = async () => {
// downloadLoading.value = true
ElMessage.info('模板下载功能需要对接后端下载接口') try {
// 使 window.open
const baseApi = import.meta.env.VITE_APP_BASE_API || ''
const token = getToken()
const url = `${baseApi}/business/student/template?token=${encodeURIComponent(token)}`
window.open(url, '_blank')
} catch (e) {
ElMessage.error('下载模板失败')
} finally {
downloadLoading.value = false
}
} }
// //
@ -119,15 +137,25 @@ const beforeUpload = (file) => {
return false return false
} }
importResult.value = null importResult.value = null
uploading.value = true
uploadProgress.value = 0
return true return true
} }
//
const handleProgress = (event) => {
uploadProgress.value = Math.round(event.percent)
}
// //
const handleSuccess = (response) => { const handleSuccess = (response) => {
uploading.value = false
uploadProgress.value = 0
if (response.code === 200) { if (response.code === 200) {
importResult.value = response.data importResult.value = response.data
if (response.data.failCount === 0) { if (response.data.failCount === 0) {
ElMessage.success('导入成功') ElMessage.success(`导入成功,共导入 ${response.data.successCount} 条数据`)
emit('success') emit('success')
} else { } else {
ElMessage.warning('部分数据导入失败,请查看失败原因') ElMessage.warning('部分数据导入失败,请查看失败原因')
@ -138,9 +166,31 @@ const handleSuccess = (response) => {
} }
// //
const handleError = () => { const handleError = (error) => {
uploading.value = false
uploadProgress.value = 0
console.error('上传失败:', error)
ElMessage.error('文件上传失败,请重试') 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> </script>
<style scoped> <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" width="600px"
:close-on-click-modal="false" :close-on-click-modal="false"
destroy-on-close destroy-on-close
@open="handleOpen"
> >
<el-form <el-form
ref="formRef" ref="formRef"
:model="form" :model="form"
:rules="rules" :rules="rules"
label-width="100px" label-width="100px"
v-loading="formLoading"
> >
<el-form-item label="姓名" prop="name"> <el-form-item label="姓名" prop="studentName">
<el-input v-model="form.name" placeholder="请输入学生姓名" maxlength="20" /> <el-input v-model="form.studentName" placeholder="请输入学生姓名" maxlength="50" />
</el-form-item> </el-form-item>
<el-form-item label="学号" prop="studentNo"> <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>
<el-form-item label="性别" prop="gender"> <el-form-item label="性别" prop="gender">
<el-radio-group v-model="form.gender"> <el-radio-group v-model="form.gender">
@ -26,7 +26,7 @@
<el-radio value="0">未知</el-radio> <el-radio value="0">未知</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="出生日期" prop="birthday"> <el-form-item label="出生年月" prop="birthday">
<el-date-picker <el-date-picker
v-model="form.birthday" v-model="form.birthday"
type="month" type="month"
@ -36,38 +36,48 @@
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="学校信息" prop="schoolPath" required> <el-form-item label="学校信息" prop="schoolClassId" required>
<el-cascader <el-cascader
v-model="form.schoolPath" v-model="form.schoolPath"
:options="schoolTree" :options="schoolTreeData"
:props="{ :props="{
value: 'id', value: 'id',
label: 'label', label: 'name',
children: 'children', children: 'children',
checkStrictly: false checkStrictly: false
}" }"
placeholder="请选择学校/年级/班级" placeholder="请选择学校/年级/班级"
clearable clearable
filterable
style="width: 100%" style="width: 100%"
@change="handleSchoolChange"
/> />
</el-form-item> </el-form-item>
<el-form-item label="学科" prop="subject"> <el-form-item label="学科" prop="subjectId">
<el-select v-model="form.subject" placeholder="请选择学科" clearable style="width: 100%"> <el-select v-model="form.subjectId" placeholder="请选择学科(选填)" clearable style="width: 100%">
<el-option v-for="item in subjectList" :key="item.id" :label="item.name" :value="item.name" /> <el-option v-for="item in subjectList" :key="item.subjectId" :label="item.subjectName" :value="item.subjectId" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="归属用户" prop="userId"> <el-form-item label="归属用户" prop="memberId">
<el-input v-model="form.userNickname" placeholder="请输入用户昵称搜索" readonly> <div style="display: flex; gap: 8px; width: 100%">
<template #append> <el-input
<el-button @click="handleSelectUser">选择</el-button> v-model="form.memberDisplay"
</template> placeholder="点击选择归属用户"
</el-input> readonly
style="flex: 1"
/>
<el-button @click="handleSelectMember">选择</el-button>
<el-button v-if="form.memberId" @click="handleClearMember">清除</el-button>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visible = false">取消</el-button> <el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button> <el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template> </template>
<!-- 会员选择弹窗 -->
<MemberSelectDialog ref="memberSelectRef" @select="handleMemberSelected" />
</el-dialog> </el-dialog>
</template> </template>
@ -76,95 +86,157 @@
* 学生新增/编辑弹窗 * 学生新增/编辑弹窗
* @author pangu * @author pangu
*/ */
import { addStudent, getStudent, updateStudent } from '@/api/pangu/student'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import request from '@/utils/request'
import MemberSelectDialog from './MemberSelectDialog.vue'
const props = defineProps({ const emit = defineEmits(['success'])
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 visible = ref(false)
const isEdit = ref(false)
const formRef = ref(null) const formRef = ref(null)
const formLoading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const memberSelectRef = ref()
//
const schoolTreeData = ref([])
//
const subjectList = ref([])
const initialForm = { const initialForm = {
id: null, studentId: null,
name: '', studentName: '',
studentNo: '', studentNo: '',
gender: '1', gender: '1',
birthday: '', birthday: '',
schoolPath: [], schoolPath: [],
subject: '', schoolId: null,
userId: null, schoolGradeId: null,
userNickname: '' schoolClassId: null,
subjectId: null,
memberId: null,
memberDisplay: ''
} }
const form = reactive({ ...initialForm }) const form = reactive({ ...initialForm })
const rules = { const rules = {
name: [ studentName: [
{ required: true, message: '请输入学生姓名', trigger: 'blur' } { required: true, message: '请输入学生姓名', trigger: 'blur' }
], ],
schoolPath: [ schoolClassId: [
{ required: true, message: '请选择学校/年级/班级', trigger: 'change' } { required: true, message: '请选择学校/年级/班级', trigger: 'change' }
] ]
} }
// //
const handleOpen = async () => { const getSchoolTree = async () => {
Object.assign(form, initialForm) try {
formRef.value?.clearValidate() 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 getSubjectList = async () => {
try {
const res = await request.get('/business/subject/list', { params: { status: '0' } })
if (res.code === 200) {
subjectList.value = res.rows || []
}
} catch (e) {
console.error('获取学科列表失败:', e)
}
}
//
const formatBirthday = (date) => {
if (!date) return ''
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
//
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 Promise.all([getSchoolTree(), getSubjectList()])
//
if (row) {
try { try {
const res = await getStudent(props.studentId) const res = await request.get(`/business/student/${row.studentId}`)
if (res.data) { if (res.code === 200 && res.data) {
const data = res.data const data = res.data
form.id = data.id form.studentId = data.studentId
form.name = data.name form.studentName = data.studentName
form.studentNo = data.studentNo form.studentNo = data.studentNo
form.gender = data.gender form.gender = data.gender || '0'
form.birthday = data.birthday form.birthday = data.birthday ? formatBirthday(data.birthday) : ''
form.subject = data.subject form.subjectId = data.subjectId
form.userId = data.userId form.memberId = data.memberId
form.userNickname = data.userNickname form.memberDisplay = data.memberNickname ? `${data.memberNickname}${data.memberPhone || ''}` : ''
// schoolPath
form.schoolPath = [data.schoolId] //
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) { } catch (e) {
console.error('获取学生详情失败:', e) console.error('获取学生详情失败:', e)
} }
} }
formLoading.value = false
} }
// //
const handleSelectUser = () => { const handleSelectMember = () => {
// memberSelectRef.value?.open()
ElMessage.info('用户选择功能需要对接会员管理模块') }
//
const handleMemberSelected = (member) => {
form.memberId = member.memberId
form.memberDisplay = `${member.nickname || '未设置昵称'}${member.phone}`
}
//
const handleClearMember = () => {
form.memberId = null
form.memberDisplay = ''
} }
// //
@ -174,22 +246,33 @@ const handleSubmit = async () => {
} catch (e) { } catch (e) {
return return
} }
//
if (!form.schoolClassId) {
ElMessage.warning('请选择完整的学校/年级/班级信息')
return
}
submitLoading.value = true submitLoading.value = true
try { try {
const submitData = { const submitData = {
...form, studentId: form.studentId,
schoolId: form.schoolPath[0], studentName: form.studentName,
gradeId: form.schoolPath[1], studentNo: form.studentNo,
classId: form.schoolPath[2] gender: form.gender,
birthday: form.birthday,
schoolId: form.schoolId,
schoolGradeId: form.schoolGradeId,
schoolClassId: form.schoolClassId,
subjectId: form.subjectId,
memberId: form.memberId
} }
delete submitData.schoolPath
if (isEdit.value) { if (isEdit.value) {
await updateStudent(submitData) await request.put('/business/student', submitData)
ElMessage.success('修改成功') ElMessage.success('修改成功')
} else { } else {
await addStudent(submitData) await request.post('/business/student', submitData)
ElMessage.success('新增成功') ElMessage.success('新增成功')
} }
visible.value = false visible.value = false
@ -200,4 +283,6 @@ const handleSubmit = async () => {
submitLoading.value = false submitLoading.value = false
} }
} }
defineExpose({ open })
</script> </script>

View File

@ -59,18 +59,28 @@
<el-table v-loading="loading" :data="tableData" border stripe :header-cell-style="{ background: '#f5f7fa', color: '#606266' }" style="width: 100%"> <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="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"> <el-table-column prop="gender" label="性别" width="60" align="center">
<template #default="{ row }"> <template #default="{ row }">
{{ row.gender === '1' ? '男' : row.gender === '2' ? '女' : '未知' }} {{ row.gender === '1' ? '男' : row.gender === '2' ? '女' : '未知' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="birthday" label="出生年月" width="100" /> <el-table-column prop="birthday" label="出生年月" width="100">
<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="schoolName" label="学校" min-width="150" show-overflow-tooltip />
<el-table-column prop="gradeName" label="年级" width="80" /> <el-table-column prop="gradeName" label="年级" width="80" />
<el-table-column prop="className" label="班级" width="80" /> <el-table-column prop="className" label="班级" width="80" />
<el-table-column prop="subject" label="学科" width="80" /> <el-table-column prop="subjectName" 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 prop="createTime" label="创建时间" width="160" />
<el-table-column label="操作" width="150" fixed="right" align="center"> <el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
@ -228,14 +238,23 @@ const handleImport = () => {
importDialogRef.value?.open() 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')
return `${year}-${month}`
}
// //
const handleDelete = (row) => { const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除学生"${row.name}"吗?`, '提示', { ElMessageBox.confirm(`确定要删除学生"${row.studentName}"吗?`, '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}).then(async () => { }).then(async () => {
const res = await request.delete(`/business/student/${row.id}`) const res = await request.delete(`/business/student/${row.studentId}`)
if (res.code === 200) { if (res.code === 200) {
ElMessage.success('删除成功') ElMessage.success('删除成功')
getList() getList()