diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainFaceApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainFaceApiController.java index 4da2551b1..86a301423 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainFaceApiController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainFaceApiController.java @@ -37,6 +37,7 @@ import org.slf4j.LoggerFactory; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.spring.SpringUtils; import com.ruoyi.train.domain.TrainTeacher; import com.ruoyi.train.domain.query.StudentQuery; @@ -260,6 +261,42 @@ public class TrainFaceApiController { return doDelete("/employees"); } + /** + * 4.8 POST /recognize 人脸识别 + */ + @Operation(summary = "人脸识别", description = "调用人脸识别设备 POST /recognize,按 image_path 或 image_base64(支持 data URL 与纯 Base64)识别图中人脸,threshold 为相似度阈值,默认 0.44。") + @PostMapping("/recognize") + public AjaxResult recognize(@RequestBody Map payload) + { + log.info("recognize 收到请求 payload={}", payload); + + if (!faceEnabled) + { + return AjaxResult.error("人脸服务未启用,无法进行人脸识别"); + } + String imagePath = payload.get("image_path") == null ? null : payload.get("image_path").toString(); + String imageBase64 = payload.get("image_base64") == null ? null : payload.get("image_base64").toString(); + if (StringUtils.isEmpty(imagePath) && StringUtils.isEmpty(imageBase64)) + { + return AjaxResult.error("参数 image_path 与 image_base64 至少提供一个"); + } + Map body = new HashMap<>(3); + if (StringUtils.isNotEmpty(imagePath)) + { + body.put("image_path", imagePath); + } + if (StringUtils.isNotEmpty(imageBase64)) + { + body.put("image_base64", imageBase64); + } + Object threshold = payload.get("threshold"); + if (threshold != null) + { + body.put("threshold", threshold); + } + return doPost("/recognize", body); + } + // ===================== 内部:真实调用人脸设备 ===================== private AjaxResult doPost(String path, Map body) diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java index 21531eb58..31a47acbb 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/TrainRegisterApiController.java @@ -460,6 +460,41 @@ public class TrainRegisterApiController { return AjaxResult.success(bindingAdminService.toStudentAdminVO(detail)); } + /** + * 2.1 按手机号查询学员信息(报到机) + * 手机号即 train_student.s_phone,精确匹配后返回与 /student/{sId} 完全一致的学员视图。 + * 手机号同时用于学员登录账号,正常应唯一;若历史数据存在同号多人,按学员列表顺序取第一条并记录告警日志。 + */ + @Operation(summary = "按手机号查询学员信息", description = "按 train_student.s_phone 精确匹配手机号,命中则返回学员信息(结构同 /student/{sId}),未命中返回“学员不存在”。") + @GetMapping("/student/phone/{phone}") + public AjaxResult getInfoByPhone(@PathVariable("phone") String phone) + { + String mobile = phone == null ? null : phone.trim(); + if (StringUtils.isEmpty(mobile)) + { + return AjaxResult.error("手机号不能为空"); + } + + StudentQuery query = new StudentQuery(); + query.setsPhone(mobile); + List matched = studentService.selectStudentList(query); + if (matched == null || matched.isEmpty()) + { + return AjaxResult.error("学员不存在,手机号:" + mobile); + } + if (matched.size() > 1) + { + log.warn("手机号 {} 匹配到 {} 名学员,按列表首条返回 sId={}", mobile, matched.size(), matched.get(0).getsId()); + } + + StudentDetailVO detail = studentService.selectStudentDetail(matched.get(0).getsId()); + if (detail == null) + { + return AjaxResult.error("学员不存在,手机号:" + mobile); + } + return AjaxResult.success(bindingAdminService.toStudentAdminVO(detail)); + } + /** * 3. 获取学员基础信息(宿舍/教室/开班/) */ @@ -820,89 +855,156 @@ public class TrainRegisterApiController { log.info("已为学员 sId={} 在门锁 roomId={} 创建人脸授权", sId, dorm.getDormRoom()); } } + return AjaxResult.success(data); + } - - - // 往宿舍门锁下发人脸:sId → train_enrollment(enrollmentId) → train_accommodation(dormId) - // → train_dormitory_info(deviceSn) → lock_device(deviceId) - /*TrainStudent stud = trainStudentMapper.selectTrainStudentBySId(sId); - log.info("学员信息 stud={}", stud ); - // 步骤1/2:根据 sId 取当前有效住宿记录(含 enrollmentId、dormId;enrollmentId 即 train_enrollment 报到记录主键) - TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sId); - if (acc != null && StringUtils.isNotEmpty(acc.getDormId())) + /** + * 6.1 人脸识别后授权门锁(报到机) + * 接收 JSON:sId(学员ID)、faceImage(base64 人脸图片,可带 data URI 前缀)。 + * 处理:先调用人脸识别接口判断该学员人脸是否已存在于人脸服务; + *
    + *
  • 识别结果的 employee_id 等于 sId:仅执行宿舍门锁授权;
  • + *
  • 识别不到人脸或 employee_id 不等于 sId:转由上传人脸流程处理(写头像 + 下发设备 + 报到授权)。
  • + *
+ */ + @Operation(summary = "人脸识别后授权门锁", description = "先识别 faceImage,命中 sId 时仅授权宿舍门锁;未命中时按上传人脸流程补录并授权。") + @PostMapping("/student/face-auth") + public AjaxResult authorizeByFace(@RequestBody Map body) + { + Object sIdObj = body.get("sId"); + Object faceObj = body.get("faceImage"); + if (sIdObj == null || faceObj == null) { - // 回查 train_enrollment 报到信息 - TrainEnrollment enrollment = enrollmentService.selectTrainEnrollmentById(acc.getEnrollmentId()); - enrollment.setEnrollmentStatus(1); - Date enrollmentDate = new Date(); - enrollment.setJoinTime(enrollmentDate); - enrollmentService.updateTrainEnrollment(enrollment); - log.info("学员 sId={} 报到记录 enrollmentId={}, dormId={}", sId, - enrollment != null ? enrollment.getEnrollmentId() : null, acc.getDormId()); - // 步骤3:宿舍 → 门锁设备序列号 - TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(acc.getDormId()); - if (dorm != null && StringUtils.isNotEmpty(dorm.getLockDeviceSn())) - { - // 步骤4:门锁 SN → lock_device 的 deviceId - LockDevice lockDevice = lockDeviceMapper.selectDeviceBySn(dorm.getLockDeviceSn()); - if (lockDevice != null) - { - //data.put("dormDeviceId", lockDevice.getDeviceId()); - //data.put("dormDeviceSn", lockDevice.getDeviceSn()); - log.info("宿舍门锁下发人脸目标 deviceId={}, deviceSn={}, sId={}", - lockDevice.getDeviceId(), lockDevice.getDeviceSn(), sId); - // 宿舍门锁下发人脸:提取人脸特征 → 创建门锁授权 - try - { - String feature = this.studentService.extractStudent(sId, lockDevice.getDeviceId()); - log.warn("学员 feature={}", feature); - if (StringUtils.isEmpty(feature)) - { - log.warn("特征提取失败,请确认已上传人脸图片且门锁已配置人脸算法版本"); - } - else if (stud == null || StringUtils.isEmpty(stud.getPersonNo())) - { - log.warn("学员 sId={} 主档缺失或未配置 personNo,跳过门锁授权创建", sId); - } - else - { - LockAuth auth = new LockAuth(); - auth.setAuthType("01"); // 01=人脸 - auth.setPersonNo(stud.getPersonNo()); - auth.setPersonName(stud.getsName()); - auth.setDeviceId(lockDevice.getDeviceId()); - auth.setAuthValue(feature); // 人脸特征 - // 截断到整秒:避免 MySQL DATETIME 对毫秒四舍五入,导致落库授权时间与下发帧相差 1 秒而触发 Payload mismatch - Date now = new Date(System.currentTimeMillis() / 1000 * 1000); - auth.setBeginTime(now); - auth.setEndTime(new Date(now.getTime() + 14L * 24 * 60 * 60 * 1000)); // 默认两周有效 - log.info("学员 auth={}",auth); - //lockAuthService.insertAuth(auth); + return AjaxResult.error("参数缺失:sId 与 faceImage 均为必填"); + } + String sId = sIdObj.toString(); + if (StringUtils.isEmpty(sId)) + { + return AjaxResult.error("sId 不能为空"); + } + boolean faceRegistered = isFaceRegistered(sId, faceObj.toString()); + if (!faceRegistered) + { + log.info("人脸识别未命中 sId={},转入人脸上传播链路", sId); + return uploadStudentFace(body); + } + log.info("人脸识别命中 sId={},仅执行宿舍门锁授权", sId); + Map data = authorizeDormLock(sId); + data.put("faceRecognized", Boolean.TRUE); + return AjaxResult.success(data); + } - } - } - catch (Exception e) - { - log.warn("宿舍门锁人脸下发失败 sId={}, deviceId={}, msg={}", sId, lockDevice.getDeviceId(), e.getMessage()); - } - } - else - { - log.warn("宿舍门锁 SN={} 在 lock_device 中未找到对应设备", dorm.getLockDeviceSn()); - } - } - else + /** + * 授权学员宿舍门锁(报到机)。 + * 步骤与上传人脸后的报到授权保持一致: + * 1) 回查 train_enrollment 报到记录并置为已报到(列表为空则跳过); + * 2) 按 create_time 降序取第一条住宿记录作为报到判定依据; + * 3) 存在宿舍时生成门锁业务流水号并调用门禁入住接口 /openapi/iot/stay/checkin。 + * + * @param sId 学员ID + * @return 授权结果(lockCheckin / lockAuthId / joinTime / sId) + */ + private Map authorizeDormLock(String sId) + { + Map data = new HashMap<>(4); + // 回查 train_enrollment 报到信息(已加入班级即有记录,更新报到状态即可),取第一条记录办理报到 + List enrollmentList = enrollmentService.selectEnrollmentsByStudentId(sId); + TrainEnrollment enrollment = null; + Date enrollmentDate = null; + if (enrollmentList != null && !enrollmentList.isEmpty()) + { + enrollment = enrollmentService.selectTrainEnrollmentById(enrollmentList.get(0).getEnrollmentId()); + if (enrollment != null) { - log.warn("宿舍 dormId={} 未配置门锁序列号", acc.getDormId()); + enrollment.setEnrollmentStatus(1); + enrollmentDate = new Date(); + enrollment.setJoinTime(enrollmentDate); + enrollmentService.updateTrainEnrollment(enrollment); } } - else + // 报到判定依据:按 create_time 降序排序后的第一条住宿记录 + TrainAccommodation acc = accommodationService.selectActiveAccommodationByStudentId(sId); + log.info("授权门锁 sId={} 报到记录 enrollmentId={}, dormId={}", sId, + enrollment != null ? enrollment.getEnrollmentId() : null, + acc != null ? acc.getDormId() : null); + if (acc != null && StringUtils.isNotEmpty(acc.getDormId())) { - log.info("学员 sId={} 暂无有效住宿记录,跳过宿舍门锁下发", sId); - }*/ + // 步骤3:宿舍 → 门锁设备序列号 + TrainDormitoryInfo dorm = dormitoryInfoService.selectTrainDormitoryInfoByDormId(acc.getDormId()); + log.info("授权门锁 sId={} 宿舍信息 dorm={}", sId, dorm); + if (dorm != null) + { + // 调用对外开放房间门禁服务的入住接口(/openapi/iot/stay/checkin,逻辑统一由住宿 Service 实现) + String lockApiBizNo = accommodationService.generateLockApiBizNo(); + acc.setLockApiBizNo(lockApiBizNo); + accommodationService.updateTrainAccommodation(acc); + log.info("学员 sId={} 入住门锁 bizNo={}", sId, lockApiBizNo); + LockStayCheckinResult lockCheckinResult = accommodationService.callStayCheckin(lockApiBizNo, enrollment, dorm); + data.put("lockCheckin", lockCheckinResult); + data.put("lockAuthId", ""); + log.info("已为学员 sId={} 在门锁 roomId={} 创建人脸授权", sId, dorm.getDormRoom()); + } + } + data.put("sId", sId); + data.put("joinTime", enrollmentDate); + return data; + } - return AjaxResult.success(data); + /** + * 调用人脸识别接口判断该学员人脸是否已存在于人脸服务。 + * 识别结果中任一 face 的 employee_id 与 sId 相同即视为已存在。 + * + * @param sId 学员ID + * @param faceImage base64 人脸图片(支持 data URL) + * @return true=人脸服务中已存在该学员 + */ + private boolean isFaceRegistered(String sId, String faceImage) + { + try + { + TrainFaceApiController faceController = SpringUtils.getBean(TrainFaceApiController.class); + Map payload = new HashMap<>(1); + payload.put("image_base64", faceImage); + AjaxResult recResult = faceController.recognize(payload); + if (recResult == null || !recResult.isSuccess()) + { + log.warn("人脸识别未成功 sId={} msg={}", sId, recResult == null ? "null" : recResult.get(AjaxResult.MSG_TAG)); + return false; + } + Object recData = recResult.get(AjaxResult.DATA_TAG); + if (!(recData instanceof Map)) + { + return false; + } + Object inner = ((Map) recData).get("data"); + if (!(inner instanceof Map)) + { + return false; + } + Object faces = ((Map) inner).get("faces"); + if (!(faces instanceof List)) + { + return false; + } + for (Object face : (List) faces) + { + if (face instanceof Map) + { + Object employeeId = ((Map) face).get("employee_id"); + if (employeeId != null && sId.equals(employeeId.toString())) + { + return true; + } + } + } + return false; + } + catch (Exception ex) + { + log.warn("人脸识别异常 sId={}", sId, ex); + return false; + } } /** diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainBatchAvatarAuthController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainBatchAvatarAuthController.java new file mode 100644 index 000000000..a98c3f23f --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainBatchAvatarAuthController.java @@ -0,0 +1,102 @@ +package com.ruoyi.web.controller.train; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.web.service.train.TrainBatchAvatarAuthService; + +/** + * 班级名单「批量上传头像 / 批量授权」接口。 + * + *

与既有单个授权链路(/api/lock/v1/stay/checkin)相互独立: + * 头像导入只更新 train_student.avatar;批量授权由后台任务逐人执行, + * 接口立即返回受理结果,页面不需要等待授权完成。

+ * + * @author ruoyi + */ +@RestController +@RequestMapping("/train/batch-auth") +public class TrainBatchAvatarAuthController extends BaseController +{ + @Autowired + private TrainBatchAvatarAuthService batchAvatarAuthService; + + /** + * 上传「手机号.png」命名的头像压缩包,按手机号匹配本班人员并写入 avatar。 + * + * @param planId 培训班ID + * @param file 头像 ZIP 包 + * @return data: { total, successCount, failureCount, successList, failureList } + */ + @PreAuthorize("@ss.hasPermi('train:enrollment:import')") + @Log(title = "班级-批量上传头像", businessType = BusinessType.IMPORT) + @PostMapping("/avatar-zip") + public AjaxResult uploadAvatarZip(@RequestParam("planId") String planId, + @RequestParam(value = "file", required = false) MultipartFile file) + { + Map data = batchAvatarAuthService.importAvatarsFromZip(planId, file); + Integer successCount = (Integer) data.get("successCount"); + Integer failureCount = (Integer) data.get("failureCount"); + String message = "头像上传完成:成功 " + successCount + " 条,失败 " + failureCount + " 条"; + return AjaxResult.success(message, data); + } + + /** + * 查询本班「已有头像但门锁未授权」的人员,供批量授权勾选。 + * + * @param planId 培训班ID + * @return 候选人员列表 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @GetMapping("/candidates") + public AjaxResult listCandidates(@RequestParam("planId") String planId) + { + List list = batchAvatarAuthService.listAuthCandidates(planId); + return AjaxResult.success((Object) list); + } + + /** + * 提交批量授权任务:立即返回受理人数,循环授权在后台线程池执行, + * 单人完成后把 lock_result 回写为 2(已授权)。 + * + * @param body { planId: 培训班ID, studentIds: [学员主档ID] } + * @return 受理人数 + */ + @PreAuthorize("@ss.hasPermi('train:accommodation:edit')") + @Log(title = "班级-批量授权", businessType = BusinessType.UPDATE) + @PostMapping("/authorize") + public AjaxResult authorize(@RequestBody Map body) + { + String planId = body.get("planId") == null ? null : body.get("planId").toString(); + List studentIds = new ArrayList<>(); + Object idsObj = body.get("studentIds"); + if (idsObj instanceof List) + { + for (Object idObj : (List) idsObj) + { + if (idObj != null) + { + studentIds.add(idObj.toString()); + } + } + } + int count = batchAvatarAuthService.authorizeBatchAsync(planId, studentIds, getUsername()); + return AjaxResult.success("已发起授权任务(" + count + " 人),请稍后在列表中查看授权结果", count); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java index ce54ebdbb..5f5f1ab34 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainEnrollmentController.java @@ -28,6 +28,7 @@ import com.ruoyi.train.domain.vo.BatchImportResultVO; import com.ruoyi.train.domain.vo.EnrollmentDetailVO; import com.ruoyi.train.domain.vo.EnrollmentItemVO; import com.ruoyi.train.service.EnrollmentAppService; +import com.ruoyi.train.service.IEnrollmentBatchImportService; /** * 参训人员全生命周期管理 Controller (API Contract Module B 第 3 部分 / ISSUE-03-01 / P0)。 @@ -42,6 +43,9 @@ public class TrainEnrollmentController extends BaseController { private final EnrollmentAppService enrollmentAppService; + @Autowired + private IEnrollmentBatchImportService enrollmentBatchImportService; + @Autowired public TrainEnrollmentController(EnrollmentAppService enrollmentAppService) { @@ -100,6 +104,35 @@ public class TrainEnrollmentController extends BaseController enrollmentAppService.exportImportTemplate(response); } + /** + * 学员批量导入预检(10 列学员模板)。 + * + *

只解析文件并校验第 1 列学员姓名、第 2 列联系电话是否为空,不写入任何业务数据。

+ */ + @PreAuthorize("@ss.hasPermi('train:enrollment:import')") + @PostMapping("/plans/{planId}/enrollments/import/precheck") + public AjaxResult precheckImport(@PathVariable String planId, @RequestParam("file") MultipartFile file) + { + return success(enrollmentBatchImportService.precheck(planId, file)); + } + + /** + * 学员批量导入(10 列学员模板)。 + * + *

逐行独立事务处理:学员建档、账号开通、参训关联、宿舍匹配与床位分配, + * 单行失败只回滚该行,结果按成功/跳过/失败/忽略分类返回。

+ */ + @PreAuthorize("@ss.hasPermi('train:enrollment:import')") + @Log(title = "批量导入参训学员", businessType = BusinessType.IMPORT) + @PostMapping("/plans/{planId}/enrollments/import") + public AjaxResult importEnrollments(@PathVariable String planId, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "ignoreInvalid", required = false, defaultValue = "false") Boolean ignoreInvalid) + { + return success(enrollmentBatchImportService.importEnrollments( + planId, file, Boolean.TRUE.equals(ignoreInvalid), SecurityUtils.getUsername())); + } + /** * 获取参训档案详情 (P0)。 */ diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java index 65d35ed97..8ab37521f 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java @@ -1,6 +1,7 @@ package com.ruoyi.web.controller.train; import java.util.List; +import java.util.Map; import java.util.regex.Pattern; import com.ruoyi.common.core.domain.entity.SysUser; @@ -124,6 +125,24 @@ public class TrainStudentController extends BaseController return success(bindingAdminService.updateStudentWithAccount(dto)); } + /** + * 仅修改学员头像 (PUT /train/student/{sId}/avatar)。 + * + *

供班级名单「点击头像」弹窗使用:只更新 train_student.avatar, + * 不触发姓名校验、学员其他字段与账号创建/同步逻辑; + * body 中 avatar 传空串表示移除头像。

+ */ + @PreAuthorize("@ss.hasPermi('train:student:edit')") + @Log(title = "学员主档-头像", businessType = BusinessType.UPDATE) + @PutMapping("/{sId}/avatar") + public AjaxResult updateAvatar(@PathVariable("sId") String sId, + @RequestBody(required = false) Map body) + { + Object avatarObj = body == null ? null : body.get("avatar"); + String avatar = avatarObj == null ? "" : avatarObj.toString(); + return toAjax(trainStudentService.updateStudentAvatar(sId, avatar)); + } + /** * 下载学员批量导入模板 (POST /train/student/importTemplate)。 */ diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/StudentAccountProvisionerImpl.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/StudentAccountProvisionerImpl.java new file mode 100644 index 000000000..109899967 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/StudentAccountProvisionerImpl.java @@ -0,0 +1,77 @@ +package com.ruoyi.web.service.train; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.ITrainingIdentityService; +import com.ruoyi.train.service.StudentAccountProvisioner; + +/** + * 学员登录账号开通实现(admin 模块)。 + * + *

复用现有账号绑定能力,仅在手机号账号不存在时新建;已存在则跳过, + * 不修改 {@link TrainAccountBindingAdminService} 的任何既有逻辑。

+ * + * @author ruoyi + */ +@Service +public class StudentAccountProvisionerImpl implements StudentAccountProvisioner +{ + private final TrainStudentMapper studentMapper; + + private final ISysUserService userService; + + private final ITrainingIdentityService identityService; + + private final TrainAccountBindingAdminService bindingAdminService; + + public StudentAccountProvisionerImpl(TrainStudentMapper studentMapper, + ISysUserService userService, + ITrainingIdentityService identityService, + TrainAccountBindingAdminService bindingAdminService) + { + this.studentMapper = studentMapper; + this.userService = userService; + this.identityService = identityService; + this.bindingAdminService = bindingAdminService; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long ensureAccount(String sId, String phone, String name) + { + if (StringUtils.isEmpty(phone)) + { + return null; + } + TrainStudent student = studentMapper.selectTrainStudentBySId(sId); + if (student == null) + { + return null; + } + if (student.getUserId() != null) + { + return student.getUserId(); + } + + SysUser existed = userService.selectUserByUserName(phone); + if (existed != null) + { + // 账号已存在:已绑定其他学员时按要求跳过,未绑定则直接关联当前学员 + TrainStudent bound = studentMapper.selectTrainStudentByUserId(existed.getUserId()); + if (bound == null) + { + identityService.bindStudentUser(sId, existed.getUserId()); + return existed.getUserId(); + } + return null; + } + + return bindingAdminService.ensureStudentAccount(sId, phone, name); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainBatchAvatarAuthService.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainBatchAvatarAuthService.java new file mode 100644 index 000000000..5233f2470 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainBatchAvatarAuthService.java @@ -0,0 +1,556 @@ +package com.ruoyi.web.service.train; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.constant.Constants; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.spring.SpringUtils; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.query.EnrollmentQuery; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.integration.lock.client.TrainLockStayApiClient; +import com.ruoyi.train.integration.lock.dto.LockStayCheckinResult; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.ITrainAccommodationService; +import com.ruoyi.train.service.ITrainDormitoryInfoService; +import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.web.controller.api.TrainFaceApiController; + +/** + * 班级名单「批量上传头像/授权」业务服务。 + * + *

职责:

+ *
    + *
  1. 解析「手机号.png / 手机号.jpg」命名的 ZIP 包,按本班参训人员手机号匹配并写入 train_student.avatar;
  2. + *
  3. 查询本班「已有头像但门锁未授权」的候选人员;
  4. + *
  5. 提交异步批量授权任务:逐人调用门禁开放接口,成功后把 lock_result 回写为 2(已授权)。
  6. + *
+ * + *

批量授权为独立实现(逐人组装参数后直接调用门禁开放客户端), + * 不复用既有的单个入住方法 callStayCheckin,避免影响原授权链路的行为。

+ * + * @author ruoyi + */ +@Service +public class TrainBatchAvatarAuthService +{ + private static final Logger log = LoggerFactory.getLogger(TrainBatchAvatarAuthService.class); + + /** 单张头像最大字节数(5MB) */ + private static final long MAX_AVATAR_BYTES = 5L * 1024 * 1024; + + /** 门锁授权有效期(天),与单个入住授权的默认区间保持一致 */ + private static final int AUTH_VALID_DAYS = 15; + + /** 允许的图片扩展名 */ + private static final Set ALLOWED_EXTENSIONS = + new HashSet<>(Arrays.asList("png", "jpg", "jpeg")); + + /** 无登录上下文时的操作人标识 */ + private static final String SYSTEM_OPERATOR = "system"; + + @Autowired + private TrainEnrollmentMapper trainEnrollmentMapper; + + @Autowired + private TrainStudentMapper trainStudentMapper; + + @Autowired + private ITrainStudentService trainStudentService; + + @Autowired + private ITrainAccommodationService trainAccommodationService; + + @Autowired + private ITrainDormitoryInfoService trainDormitoryInfoService; + + @Autowired + private TrainLockStayApiClient trainLockStayApiClient; + + @Autowired + @Qualifier("threadPoolTaskExecutor") + private TaskExecutor taskExecutor; + + // ==================== 一、批量上传头像 ==================== + + /** + * 解析 ZIP 头像包并按手机号匹配本班人员写入 avatar。 + * + *

压缩包内文件命名规则:{@code 手机号.png}(也支持 jpg/jpeg),可按目录分组; + * 手机号取文件名中第一个点号之前的部分。

+ * + * @param planId 培训班ID + * @param file 头像压缩包 + * @return 含成功/失败明细的统计结果 + */ + public Map importAvatarsFromZip(String planId, MultipartFile file) + { + if (StringUtils.isEmpty(planId)) + { + throw new ServiceException("培训班ID不能为空"); + } + if (file == null || file.isEmpty()) + { + throw new ServiceException("请上传头像压缩包"); + } + + // 1. 本班参训人员:手机号 -> 人员(同一手机号重复时保留首个) + EnrollmentQuery query = new EnrollmentQuery(); + query.setPlanId(planId); + List enrollments = trainEnrollmentMapper.selectEnrollmentItemList(query); + Map phoneIndex = new HashMap<>(); + for (EnrollmentItemVO item : enrollments) + { + String phone = normalizePhone(item.getPhone()); + if (phone != null && !phoneIndex.containsKey(phone)) + { + phoneIndex.put(phone, item); + } + } + if (phoneIndex.isEmpty()) + { + throw new ServiceException("本班暂无可用手机号的参训人员,无法按手机号匹配头像"); + } + + List> successList = new ArrayList<>(); + List> failureList = new ArrayList<>(); + int total = 0; + String operator = currentOperator(); + + try (ZipInputStream zip = new ZipInputStream(file.getInputStream())) + { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) + { + if (entry.isDirectory()) + { + continue; + } + String fileName = extractFileName(entry.getName()); + if (fileName.isEmpty() || fileName.startsWith(".")) + { + // 忽略隐藏文件与压缩工具生成的元数据 + continue; + } + total++; + + int dot = fileName.lastIndexOf('.'); + if (dot <= 0 || dot == fileName.length() - 1) + { + addFailure(failureList, fileName, null, "文件名缺少扩展名,应为「手机号.png」"); + continue; + } + String ext = fileName.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!ALLOWED_EXTENSIONS.contains(ext)) + { + addFailure(failureList, fileName, null, "仅支持 png / jpg / jpeg 格式"); + continue; + } + String phone = normalizePhone(fileName.substring(0, dot)); + if (phone == null) + { + addFailure(failureList, fileName, null, "文件名不是有效手机号"); + continue; + } + EnrollmentItemVO item = phoneIndex.get(phone); + if (item == null) + { + addFailure(failureList, fileName, phone, "本班无匹配该手机号的参训人员"); + continue; + } + + byte[] bytes = readEntry(zip); + if (bytes.length == 0) + { + addFailure(failureList, fileName, phone, "图片内容为空"); + continue; + } + if (bytes.length > MAX_AVATAR_BYTES) + { + addFailure(failureList, fileName, phone, "图片超过 5MB"); + continue; + } + + try + { + String avatarPath = saveAvatarFile(bytes, ext); + int rows = trainStudentService.updateStudentAvatar(item.getStudentId(), avatarPath); + if (rows <= 0) + { + addFailure(failureList, fileName, phone, "头像写入失败:学员档案不存在"); + continue; + } + Map ok = new LinkedHashMap<>(); + ok.put("studentId", item.getStudentId()); + ok.put("studentName", item.getStudentName()); + ok.put("phone", phone); + ok.put("avatar", avatarPath); + // 头像已入库,随后同步人脸到人脸识别设备(以 sId 作为 employee_id),口径同报到机单张上传 + syncFaceToDevice(item.getStudentId(), ok); + successList.add(ok); + } + catch (IOException ioEx) + { + log.error("[批量头像] 头像落盘失败, phone={}", phone, ioEx); + addFailure(failureList, fileName, phone, "头像保存失败:" + ioEx.getMessage()); + } + catch (Exception ex) + { + log.error("[批量头像] 头像写入异常, phone={}", phone, ex); + addFailure(failureList, fileName, phone, "头像写入异常:" + ex.getMessage()); + } + } + } + catch (IOException ioEx) + { + log.error("[批量头像] 压缩包解析失败, planId={}", planId, ioEx); + throw new ServiceException("压缩包解析失败:" + ioEx.getMessage()); + } + + log.info("[批量头像] 处理完成 planId={}, total={}, success={}, failure={}, operator={}", + planId, total, successList.size(), failureList.size(), operator); + + Map data = new LinkedHashMap<>(); + data.put("total", total); + data.put("successCount", successList.size()); + data.put("failureCount", failureList.size()); + data.put("successList", successList); + data.put("failureList", failureList); + return data; + } + + /** + * 把单个学员的头像同步到人脸识别设备。 + * + *

处理口径与报到机单张上传({@code TrainRegisterApiController#uploadStudentFace})一致: + * 头像先写入 train_student,再以 sId 作为 employee_id 调用设备接口, + * 由设备侧读取该头像转 Base64 后下发。设备同步与头像入库相互独立, + * 同步结果写入 {@code faceSync} / {@code faceMsg},不改变原本的上传成功判定。

+ * + * @param sId 学员ID,作为设备侧 employee_id + * @param row 本次上传的成功记录,用于回写设备同步状态 + */ + private void syncFaceToDevice(String sId, Map row) + { + row.put("faceSync", "skipped"); + try + { + TrainFaceApiController faceController = SpringUtils.getBean(TrainFaceApiController.class); + Map facePayload = new HashMap<>(1); + facePayload.put("employee_id", sId); + log.info("[批量头像] 同步人脸到设备 sId={}", sId); + AjaxResult faceResult = faceController.addEmployeeFace(facePayload); + if (faceResult != null && faceResult.isSuccess()) + { + row.put("faceSync", "success"); + } + else + { + row.put("faceSync", "failed"); + row.put("faceMsg", faceResult == null ? "设备无响应" : (String) faceResult.get(AjaxResult.MSG_TAG)); + log.warn("[批量头像] 人脸设备同步未成功 sId={} msg={}", sId, + faceResult == null ? "null" : faceResult.get(AjaxResult.MSG_TAG)); + } + } + catch (Exception ex) + { + row.put("faceSync", "failed"); + row.put("faceMsg", ex.getMessage()); + log.warn("[批量头像] 人脸设备同步异常 sId={}", sId, ex); + } + } + + // ==================== 二、已有头像但未授权的候选人员 ==================== + + /** + * 查询本班「已有头像但门锁未授权」的人员,供批量授权勾选。 + * + * @param planId 培训班ID + * @return 候选人员列表 + */ + public List listAuthCandidates(String planId) + { + if (StringUtils.isEmpty(planId)) + { + throw new ServiceException("培训班ID不能为空"); + } + return trainEnrollmentMapper.selectAuthCandidateList(planId); + } + + // ==================== 三、批量授权(异步任务) ==================== + + /** + * 提交异步批量授权任务,立即返回受理人数,页面无需等待执行结果。 + * + *

循环授权在独立线程池任务中执行;每个人授权完成后按单个授权口径 + * 把 train_accommodation.lock_result 回写为 2(已授权)。

+ * + * @param planId 培训班ID + * @param studentIds 学员主档ID集合 + * @param operator 操作人 + * @return 本次受理的人数 + */ + public int authorizeBatchAsync(String planId, List studentIds, String operator) + { + if (StringUtils.isEmpty(planId)) + { + throw new ServiceException("培训班ID不能为空"); + } + List ids = new ArrayList<>(); + if (studentIds != null) + { + for (String id : studentIds) + { + if (StringUtils.isNotEmpty(id)) + { + String trimmed = id.trim(); + if (!ids.contains(trimmed)) + { + ids.add(trimmed); + } + } + } + } + if (ids.isEmpty()) + { + throw new ServiceException("请先选择需要授权的人员"); + } + + final String finalPlanId = planId; + final String finalOperator = StringUtils.isNotEmpty(operator) ? operator : SYSTEM_OPERATOR; + taskExecutor.execute(() -> runBatchAuthorize(finalPlanId, ids, finalOperator)); + return ids.size(); + } + + /** + * 批量授权任务主体:逐人授权,单个人失败不中断整体任务。 + * + * @param planId 培训班ID + * @param ids 学员主档ID集合 + * @param operator 操作人 + */ + private void runBatchAuthorize(String planId, List ids, String operator) + { + log.info("[批量授权] 任务开始 planId={}, count={}, operator={}", planId, ids.size(), operator); + int success = 0; + int failure = 0; + for (String studentId : ids) + { + try + { + if (authorizeOne(planId, studentId, operator)) + { + success++; + } + else + { + failure++; + } + } + catch (Exception ex) + { + failure++; + log.error("[批量授权] 单人授权异常 planId={}, studentId={}", planId, studentId, ex); + } + } + log.info("[批量授权] 任务结束 planId={}, success={}, failure={}", planId, success, failure); + } + + /** + * 单个人员门锁授权(批量任务专用的独立实现)。 + * + *

与单个入住方法 callStayCheckin 的区别:本方法按 planId + studentId 自行定位 + * 参训档案、有效住宿与宿舍,不复用原方法,且在本方法内直接完成 lock_result 回写。

+ * + * @param planId 培训班ID + * @param studentId 学员主档ID + * @param operator 操作人 + * @return true 表示授权成功 + */ + private boolean authorizeOne(String planId, String studentId, String operator) + { + TrainEnrollment enrollment = trainEnrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, studentId); + if (enrollment == null) + { + log.warn("[批量授权] 跳过:本班无该学员参训档案 planId={}, studentId={}", planId, studentId); + return false; + } + TrainAccommodation accommodation = + trainAccommodationService.selectActiveAccommodationByEnrollmentId(enrollment.getEnrollmentId()); + if (accommodation == null) + { + log.warn("[批量授权] 跳过:学员无有效住宿安排 studentId={}, enrollmentId={}", + studentId, enrollment.getEnrollmentId()); + return false; + } + TrainStudent student = trainStudentMapper.selectTrainStudentBySId(studentId); + if (student == null) + { + log.warn("[批量授权] 跳过:学员档案不存在 studentId={}", studentId); + return false; + } + if (StringUtils.isEmpty(student.getAvatar()) || student.getAvatar().trim().isEmpty()) + { + log.warn("[批量授权] 跳过:学员缺少头像,无法下发人脸授权 studentId={}", studentId); + return false; + } + TrainDormitoryInfo dorm = + trainDormitoryInfoService.selectTrainDormitoryInfoByDormId(accommodation.getDormId()); + if (dorm == null || StringUtils.isEmpty(dorm.getDormRoom())) + { + log.warn("[批量授权] 跳过:宿舍或房间号缺失,dormId={}, studentId={}", + accommodation.getDormId(), studentId); + return false; + } + + // 门锁业务流水号:已有则复用,缺失时生成并回写,保证回调能按 bizNo 定位住宿记录 + String bizNo = trainAccommodationService.generateLockApiBizNo(); + trainAccommodationService.updateLockApiBizNo(accommodation.getAccommodationId(), bizNo, operator); + + + Date beginTime = DateUtils.getNowDate(); + Date endTime = Date.from(beginTime.toInstant().plus(AUTH_VALID_DAYS, ChronoUnit.DAYS)); + LockStayCheckinResult result = trainLockStayApiClient.checkinWithResult(bizNo, student.getsId(), + student.getsName(), dorm.getDormRoom(), beginTime, endTime, student.getAvatar()); + if (result == null || !result.isSuccess()) + { + log.warn("[批量授权] 授权失败 studentId={}, bizNo={}, status={}, msg={}", studentId, bizNo, + result == null ? null : result.getStatus(), + result == null ? null : result.getMessage()); + return false; + } + + // 参照单个授权口径:受理成功后把住宿记录的门锁授权结果置为 2(已授权) + trainAccommodationService.updateLockResult(accommodation.getAccommodationId(), + TrainAccommodation.LOCK_RESULT_AUTHORIZED, operator); + log.info("[批量授权] 授权成功 studentId={}, accommodationId={}, bizNo={}", + studentId, accommodation.getAccommodationId(), bizNo); + return true; + } + + // ==================== 内部工具 ==================== + + /** 取当前登录用户名,无登录上下文时回落 system */ + private String currentOperator() + { + try + { + String username = SecurityUtils.getUsername(); + return StringUtils.isNotEmpty(username) ? username : SYSTEM_OPERATOR; + } + catch (Exception ignored) + { + return SYSTEM_OPERATOR; + } + } + + /** 取压缩包内的纯文件名(兼容 / 与 \ 两种分隔符) */ + private String extractFileName(String entryName) + { + if (entryName == null) + { + return ""; + } + String name = entryName.trim(); + int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + if (slash >= 0) + { + name = name.substring(slash + 1); + } + return name; + } + + /** + * 手机号归一化:仅保留数字,长度 6~20 视为有效,其余返回 null。 + * 文件名与学员手机号两侧都做同样归一化,提升带分隔符场景的匹配成功率。 + */ + private String normalizePhone(String value) + { + if (value == null) + { + return null; + } + String digits = value.replaceAll("[^0-9]", ""); + if (digits.length() < 6 || digits.length() > 20) + { + return null; + } + return digits; + } + + /** 读取当前 ZIP entry 的全部字节 */ + private byte[] readEntry(InputStream input) throws IOException + { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int len; + while ((len = input.read(chunk)) > 0) + { + buffer.write(chunk, 0, len); + } + return buffer.toByteArray(); + } + + /** + * 头像落盘:写入 profile/upload/yyyy/MM/dd 目录,返回入库用的相对路径 + * (形如 /profile/upload/2026/11/12/xxx.png,与单个上传口径一致)。 + */ + private String saveAvatarFile(byte[] bytes, String ext) throws IOException + { + String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); + File dir = new File(RuoYiConfig.getProfile() + File.separator + "upload" + File.separator + datePath); + if (!dir.exists() && !dir.mkdirs()) + { + throw new IOException("创建头像存储目录失败:" + dir.getAbsolutePath()); + } + String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; + Files.write(new File(dir, fileName).toPath(), bytes); + return Constants.RESOURCE_PREFIX + "/upload/" + datePath + "/" + fileName; + } + + /** 追加一条失败明细 */ + private void addFailure(List> failureList, String fileName, String phone, String reason) + { + Map failure = new LinkedHashMap<>(); + failure.put("fileName", fileName); + failure.put("phone", phone); + failure.put("reason", reason); + failureList.add(failure); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml index 099ba1eca..7e3e37bf2 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml +++ b/RuoYi-Vue/ruoyi-admin/src/main/resources/application.yml @@ -189,7 +189,7 @@ iot: #- http://192.168.1.230:8802/bdws_api/Third/Api/Easen/getFaceFeatureData # 调用超时(毫秒) timeout: 10000 - lock_endpoint: http://192.168.1.203:8080 + lock_endpoint: http://192.168.1.202:8085 #lock_endpoint: http://192.168.1.202:8085 api_key: RiviwlSWGDVjY7ktsRsn718R6oDNmrQy avatar_profile: http://192.168.1.106:8096 diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentImportRowDTO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentImportRowDTO.java new file mode 100644 index 000000000..80389fca7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/dto/EnrollmentImportRowDTO.java @@ -0,0 +1,144 @@ +package com.ruoyi.train.domain.dto; + +/** + * 参训学员批量导入单行原始数据(对应 10 列学员导入模板)。 + * + *

列顺序:学员姓名 / 联系电话 / 身份证号 / 性别 / 出生日期 / 所属单位 / 学号 / 房间号 / 门禁卡号 / 备注。

+ * + * @author ruoyi + */ +public class EnrollmentImportRowDTO +{ + /** Excel 实际行号(从 1 开始,含表头偏移)。 */ + private int rowNum; + + private String studentName; + + private String phone; + + private String idCard; + + private String gender; + + private String birth; + + private String deptName; + + private String personNo; + + private String roomNo; + + private String cardNo; + + private String remark; + + public int getRowNum() + { + return rowNum; + } + + public void setRowNum(int rowNum) + { + this.rowNum = rowNum; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getPhone() + { + return phone; + } + + public void setPhone(String phone) + { + this.phone = phone; + } + + public String getIdCard() + { + return idCard; + } + + public void setIdCard(String idCard) + { + this.idCard = idCard; + } + + public String getGender() + { + return gender; + } + + public void setGender(String gender) + { + this.gender = gender; + } + + public String getBirth() + { + return birth; + } + + public void setBirth(String birth) + { + this.birth = birth; + } + + public String getDeptName() + { + return deptName; + } + + public void setDeptName(String deptName) + { + this.deptName = deptName; + } + + public String getPersonNo() + { + return personNo; + } + + public void setPersonNo(String personNo) + { + this.personNo = personNo; + } + + public String getRoomNo() + { + return roomNo; + } + + public void setRoomNo(String roomNo) + { + this.roomNo = roomNo; + } + + public String getCardNo() + { + return cardNo; + } + + public void setCardNo(String cardNo) + { + this.cardNo = cardNo; + } + + public String getRemark() + { + return remark; + } + + public void setRemark(String remark) + { + this.remark = remark; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportPrecheckVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportPrecheckVO.java new file mode 100644 index 000000000..75cc1ad49 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportPrecheckVO.java @@ -0,0 +1,60 @@ +package com.ruoyi.train.domain.vo; + +import java.util.ArrayList; +import java.util.List; + +/** + * 参训学员批量导入预检结果(只读校验,不落库)。 + * + * @author ruoyi + */ +public class EnrollmentImportPrecheckVO +{ + /** 解析到的数据行总数。 */ + private int total; + + /** 缺少必填项(学员姓名、联系电话)的行数。 */ + private int invalidCount; + + private List invalidRows = new ArrayList<>(); + + public void appendInvalid(EnrollmentImportRowResultVO row) + { + if (row == null) + { + return; + } + invalidRows.add(row); + invalidCount++; + } + + public int getTotal() + { + return total; + } + + public void setTotal(int total) + { + this.total = total; + } + + public int getInvalidCount() + { + return invalidCount; + } + + public void setInvalidCount(int invalidCount) + { + this.invalidCount = invalidCount; + } + + public List getInvalidRows() + { + return invalidRows; + } + + public void setInvalidRows(List invalidRows) + { + this.invalidRows = invalidRows == null ? new ArrayList<>() : invalidRows; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportResultVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportResultVO.java new file mode 100644 index 000000000..45db65d68 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportResultVO.java @@ -0,0 +1,116 @@ +package com.ruoyi.train.domain.vo; + +import java.util.ArrayList; +import java.util.List; + +/** + * 参训学员批量导入汇总结果。 + * + * @author ruoyi + */ +public class EnrollmentImportResultVO +{ + /** 解析到的数据行总数。 */ + private int total; + + private int successCount; + + private int skipCount; + + private int failureCount; + + private int ignoredCount; + + private List rows = new ArrayList<>(); + + /** + * 追加一行结果并同步累计计数。 + * + * @param row 单行结果 + */ + public void append(EnrollmentImportRowResultVO row) + { + if (row == null) + { + return; + } + rows.add(row); + total++; + if (EnrollmentImportRowResultVO.RESULT_SUCCESS.equals(row.getResult())) + { + successCount++; + } + else if (EnrollmentImportRowResultVO.RESULT_SKIPPED.equals(row.getResult())) + { + skipCount++; + } + else if (EnrollmentImportRowResultVO.RESULT_IGNORED.equals(row.getResult())) + { + ignoredCount++; + } + else + { + failureCount++; + } + } + + public int getTotal() + { + return total; + } + + public void setTotal(int total) + { + this.total = total; + } + + public int getSuccessCount() + { + return successCount; + } + + public void setSuccessCount(int successCount) + { + this.successCount = successCount; + } + + public int getSkipCount() + { + return skipCount; + } + + public void setSkipCount(int skipCount) + { + this.skipCount = skipCount; + } + + public int getFailureCount() + { + return failureCount; + } + + public void setFailureCount(int failureCount) + { + this.failureCount = failureCount; + } + + public int getIgnoredCount() + { + return ignoredCount; + } + + public void setIgnoredCount(int ignoredCount) + { + this.ignoredCount = ignoredCount; + } + + public List getRows() + { + return rows; + } + + public void setRows(List rows) + { + this.rows = rows == null ? new ArrayList<>() : rows; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportRowResultVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportRowResultVO.java new file mode 100644 index 000000000..b6da4c302 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/vo/EnrollmentImportRowResultVO.java @@ -0,0 +1,211 @@ +package com.ruoyi.train.domain.vo; + +import java.util.ArrayList; +import java.util.List; + +/** + * 参训学员批量导入的单行处理结果。 + * + *

result 取值固定,便于前端分类提示: + * SUCCESS 成功、SKIPPED 跳过、FAILED 失败、IGNORED 忽略、INVALID 预检不合法。

+ * + * @author ruoyi + */ +public class EnrollmentImportRowResultVO +{ + public static final String RESULT_SUCCESS = "SUCCESS"; + + public static final String RESULT_SKIPPED = "SKIPPED"; + + public static final String RESULT_FAILED = "FAILED"; + + public static final String RESULT_IGNORED = "IGNORED"; + + public static final String RESULT_INVALID = "INVALID"; + + /** Excel 实际行号。 */ + private int rowNum; + + private String studentName; + + private String phone; + + /** SUCCESS / SKIPPED / FAILED / IGNORED / INVALID。 */ + private String result; + + /** 结果说明,失败与跳过必填。 */ + private String reason; + + private String sId; + + private Long enrollmentId; + + /** 是否为本次新建的学员主档。 */ + private boolean newStudent; + + /** 是否本次新建了登录账号。 */ + private boolean accountCreated; + + private String dormId; + + private String bedNo; + + /** 非阻断性提示,例如账号已存在、宿舍被自动创建。 */ + private List warnings = new ArrayList<>(); + + public static EnrollmentImportRowResultVO of(int rowNum, String studentName, String phone, String result, String reason) + { + EnrollmentImportRowResultVO vo = new EnrollmentImportRowResultVO(); + vo.setRowNum(rowNum); + vo.setStudentName(studentName); + vo.setPhone(phone); + vo.setResult(result); + vo.setReason(reason); + return vo; + } + + public static EnrollmentImportRowResultVO success(int rowNum, String studentName, String phone) + { + return of(rowNum, studentName, phone, RESULT_SUCCESS, null); + } + + public static EnrollmentImportRowResultVO skipped(int rowNum, String studentName, String phone, String reason) + { + return of(rowNum, studentName, phone, RESULT_SKIPPED, reason); + } + + public static EnrollmentImportRowResultVO failed(int rowNum, String studentName, String phone, String reason) + { + return of(rowNum, studentName, phone, RESULT_FAILED, reason); + } + + public static EnrollmentImportRowResultVO ignored(int rowNum, String studentName, String phone, String reason) + { + return of(rowNum, studentName, phone, RESULT_IGNORED, reason); + } + + public static EnrollmentImportRowResultVO invalid(int rowNum, String studentName, String phone, String reason) + { + return of(rowNum, studentName, phone, RESULT_INVALID, reason); + } + + public int getRowNum() + { + return rowNum; + } + + public void setRowNum(int rowNum) + { + this.rowNum = rowNum; + } + + public String getStudentName() + { + return studentName; + } + + public void setStudentName(String studentName) + { + this.studentName = studentName; + } + + public String getPhone() + { + return phone; + } + + public void setPhone(String phone) + { + this.phone = phone; + } + + public String getResult() + { + return result; + } + + public void setResult(String result) + { + this.result = result; + } + + public String getReason() + { + return reason; + } + + public void setReason(String reason) + { + this.reason = reason; + } + + public String getsId() + { + return sId; + } + + public void setsId(String sId) + { + this.sId = sId; + } + + public Long getEnrollmentId() + { + return enrollmentId; + } + + public void setEnrollmentId(Long enrollmentId) + { + this.enrollmentId = enrollmentId; + } + + public boolean isNewStudent() + { + return newStudent; + } + + public void setNewStudent(boolean newStudent) + { + this.newStudent = newStudent; + } + + public boolean isAccountCreated() + { + return accountCreated; + } + + public void setAccountCreated(boolean accountCreated) + { + this.accountCreated = accountCreated; + } + + public String getDormId() + { + return dormId; + } + + public void setDormId(String dormId) + { + this.dormId = dormId; + } + + public String getBedNo() + { + return bedNo; + } + + public void setBedNo(String bedNo) + { + this.bedNo = bedNo; + } + + public List getWarnings() + { + return warnings; + } + + public void setWarnings(List warnings) + { + this.warnings = warnings == null ? new ArrayList<>() : warnings; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentImportMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentImportMapper.java new file mode 100644 index 000000000..ff646312d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentImportMapper.java @@ -0,0 +1,33 @@ +package com.ruoyi.train.mapper; + +import org.apache.ibatis.annotations.Param; + +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainStudent; + +/** + * 参训学员批量导入专用只读查询 Mapper。 + * + *

为避免影响既有学员、宿舍、住宿分配流程,本接口只提供导入必需的两个查询, + * 不复用也不修改任何已有 Mapper 方法。写入操作仍调用既有 Mapper 的插入方法。

+ * + * @author ruoyi + */ +public interface TrainEnrollmentImportMapper +{ + /** + * 按联系电话匹配学员主档(未删除),用于判定是否为复训老学员。 + * + * @param sPhone 联系电话 + * @return 学员主档,不存在返回 null + */ + TrainStudent selectStudentByPhone(@Param("sPhone") String sPhone); + + /** + * 按房间号匹配宿舍(未删除)。 + * + * @param dormRoom 房间号 + * @return 宿舍信息,不存在返回 null + */ + TrainDormitoryInfo selectDormitoryByRoomNo(@Param("dormRoom") String dormRoom); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java index e88bab253..431caefea 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java @@ -66,6 +66,17 @@ public interface TrainEnrollmentMapper */ List selectEnrollmentItemList(EnrollmentQuery query); + /** + * 查询指定班级「已有头像但门锁未授权」的人员(批量授权候选)。 + * + *

取数为当前有效住宿(0 预分配 / 1 已入住),且 avatar 非空、lock_result 不等于 2(已授权)。 + * 供班级名单「批量上传头像/授权」使用,不改变既有列表查询逻辑。

+ * + * @param planId 培训班ID + * @return 候选人员列表(含 avatar、dormRoom、lockApiBizNo、lockResult) + */ + List selectAuthCandidateList(@Param("planId") String planId); + /** * 统计指定培训计划下的参训人数(排除已放弃3的学员统计,或统计全部)。 * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IEnrollmentBatchImportService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IEnrollmentBatchImportService.java new file mode 100644 index 000000000..7817a3d70 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IEnrollmentBatchImportService.java @@ -0,0 +1,34 @@ +package com.ruoyi.train.service; + +import org.springframework.web.multipart.MultipartFile; + +import com.ruoyi.train.domain.vo.EnrollmentImportPrecheckVO; +import com.ruoyi.train.domain.vo.EnrollmentImportResultVO; + +/** + * 参训学员批量导入服务(10 列学员导入模板)。 + * + * @author ruoyi + */ +public interface IEnrollmentBatchImportService +{ + /** + * 预检:只解析与校验第 1、2 列必填项,不写库。 + * + * @param planId 开班计划ID(班级ID) + * @param file 上传的 xlsx 文件 + * @return 预检结果 + */ + EnrollmentImportPrecheckVO precheck(String planId, MultipartFile file); + + /** + * 执行导入:逐行独立事务处理。 + * + * @param planId 开班计划ID(班级ID) + * @param file 上传的 xlsx 文件 + * @param ignoreInvalid 是否忽略缺少必填项的行 + * @param operator 操作人 + * @return 导入明细结果 + */ + EnrollmentImportResultVO importEnrollments(String planId, MultipartFile file, boolean ignoreInvalid, String operator); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java index 0c85606c4..8495c19f3 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainStudentService.java @@ -43,6 +43,18 @@ public interface ITrainStudentService */ String updateTrainStudent(StudentUpdateDTO dto); + /** + * 仅更新学员头像(不触发姓名校验、账号创建/同步等其他逻辑)。 + * + *

与 {@link #updateTrainStudent(StudentUpdateDTO)} 的差异:不对空串做归一化, + * 传空串即显式清空 avatar(Mapper 动态 SQL 对 null 会跳过该字段,无法清空)。

+ * + * @param sId 学员主档ID + * @param avatar 头像相对路径;空串表示移除头像 + * @return 影响行数 + */ + int updateStudentAvatar(String sId, String avatar); + /** * 安全删除学员档案(ZERO-REFERENCE SAFE DELETE) * diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/StudentAccountProvisioner.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/StudentAccountProvisioner.java new file mode 100644 index 000000000..0e5f4f53f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/StudentAccountProvisioner.java @@ -0,0 +1,22 @@ +package com.ruoyi.train.service; + +/** + * 学员登录账号开通能力。 + * + *

由于 sys_user 相关服务位于 system/admin 模块,ruoyi-train 无法直接依赖, + * 这里只定义能力接口,由 admin 模块提供实现,保证导入流程与现有账号绑定逻辑一致。

+ * + * @author ruoyi + */ +public interface StudentAccountProvisioner +{ + /** + * 确保学员已绑定登录账号(账号名为手机号)。 + * + * @param sId 学员ID + * @param phone 手机号(账号名) + * @param name 学员姓名(昵称) + * @return 绑定的用户ID;账号已存在且无需新建时返回 null + */ + Long ensureAccount(String sId, String phone, String name); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentBatchImportServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentBatchImportServiceImpl.java new file mode 100644 index 000000000..d8ab81396 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentBatchImportServiceImpl.java @@ -0,0 +1,272 @@ +package com.ruoyi.train.service.impl; + +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.train.domain.dto.EnrollmentImportRowDTO; +import com.ruoyi.train.domain.vo.EnrollmentImportPrecheckVO; +import com.ruoyi.train.domain.vo.EnrollmentImportResultVO; +import com.ruoyi.train.domain.vo.EnrollmentImportRowResultVO; +import com.ruoyi.train.service.IEnrollmentBatchImportService; + +/** + * 参训学员批量导入服务实现。 + * + *

文件按本页面下载的 10 列学员模板解析;预检只读不落库,正式导入逐行独立事务处理, + * 单行失败只回滚该行并记录原因,不影响其它行。

+ * + * @author ruoyi + */ +@Service +public class EnrollmentBatchImportServiceImpl implements IEnrollmentBatchImportService +{ + /** 上传文件大小上限 5MB。 */ + private static final long MAX_FILE_SIZE = 5L * 1024 * 1024; + + /** 单次最多处理行数。 */ + private static final int MAX_ROW_COUNT = 500; + + private static final DataFormatter CELL_FORMATTER = new DataFormatter(); + + private final EnrollmentImportRowProcessor rowProcessor; + + public EnrollmentBatchImportServiceImpl(EnrollmentImportRowProcessor rowProcessor) + { + this.rowProcessor = rowProcessor; + } + + @Override + public EnrollmentImportPrecheckVO precheck(String planId, MultipartFile file) + { + List rows = parseRows(file); + EnrollmentImportPrecheckVO result = new EnrollmentImportPrecheckVO(); + result.setTotal(rows.size()); + for (EnrollmentImportRowDTO row : rows) + { + String reason = invalidReason(row); + if (reason != null) + { + result.appendInvalid(EnrollmentImportRowResultVO.invalid( + row.getRowNum(), row.getStudentName(), row.getPhone(), reason)); + } + } + return result; + } + + @Override + public EnrollmentImportResultVO importEnrollments(String planId, MultipartFile file, boolean ignoreInvalid, + String operator) + { + if (StringUtils.isEmpty(planId)) + { + throw new ServiceException("班级ID不能为空"); + } + List rows = parseRows(file); + + if (!ignoreInvalid) + { + int invalidCount = 0; + for (EnrollmentImportRowDTO row : rows) + { + if (invalidReason(row) != null) + { + invalidCount++; + } + } + if (invalidCount > 0) + { + throw new ServiceException("存在 " + invalidCount + + " 行缺少学员姓名或联系电话,请完善后导入或选择忽略不合法记录"); + } + } + + EnrollmentImportResultVO result = new EnrollmentImportResultVO(); + for (EnrollmentImportRowDTO row : rows) + { + String reason = invalidReason(row); + if (reason != null) + { + result.append(EnrollmentImportRowResultVO.ignored( + row.getRowNum(), row.getStudentName(), row.getPhone(), reason)); + continue; + } + try + { + result.append(rowProcessor.process(row, planId, operator)); + } + catch (Exception e) + { + result.append(EnrollmentImportRowResultVO.failed( + row.getRowNum(), row.getStudentName(), row.getPhone(), resolveMessage(e))); + } + } + return result; + } + + /** + * 校验文件并解析出数据行(跳过表头与全空行)。 + */ + private List parseRows(MultipartFile file) + { + validateFile(file); + List rows = new ArrayList<>(); + try (InputStream inputStream = file.getInputStream(); + Workbook workbook = WorkbookFactory.create(inputStream)) + { + if (workbook.getNumberOfSheets() == 0) + { + throw new ServiceException("Excel 中没有可用的工作表"); + } + Sheet sheet = workbook.getSheetAt(0); + Row header = sheet.getRow(sheet.getFirstRowNum()); + String firstName = getCellString(header == null ? null : header.getCell(0)); + String firstPhone = getCellString(header == null ? null : header.getCell(1)); + if (!firstName.contains("学员姓名") || !firstPhone.contains("联系电话")) + { + throw new ServiceException("模板不匹配,请使用本页面下载的学员导入模板"); + } + + for (int index = sheet.getFirstRowNum() + 1; index <= sheet.getLastRowNum(); index++) + { + Row row = sheet.getRow(index); + if (row == null) + { + continue; + } + EnrollmentImportRowDTO dto = readRow(row, index + 1); + if (isBlankRow(dto)) + { + continue; + } + rows.add(dto); + if (rows.size() > MAX_ROW_COUNT) + { + throw new ServiceException("单次最多导入 " + MAX_ROW_COUNT + " 行,请拆分后重试"); + } + } + } + catch (ServiceException e) + { + throw e; + } + catch (Exception e) + { + throw new ServiceException("Excel 解析失败:" + e.getMessage()); + } + if (rows.isEmpty()) + { + throw new ServiceException("未读取到有效数据行"); + } + return rows; + } + + private EnrollmentImportRowDTO readRow(Row row, int rowNum) + { + EnrollmentImportRowDTO dto = new EnrollmentImportRowDTO(); + dto.setRowNum(rowNum); + dto.setStudentName(getCellString(row.getCell(0))); + dto.setPhone(getCellString(row.getCell(1))); + dto.setIdCard(getCellString(row.getCell(2))); + dto.setGender(getCellString(row.getCell(3))); + dto.setBirth(getCellString(row.getCell(4))); + dto.setDeptName(getCellString(row.getCell(5))); + dto.setPersonNo(getCellString(row.getCell(6))); + dto.setRoomNo(getCellString(row.getCell(7))); + dto.setCardNo(getCellString(row.getCell(8))); + dto.setRemark(getCellString(row.getCell(9))); + return dto; + } + + private String getCellString(Cell cell) + { + if (cell == null) + { + return ""; + } + CellType cellType = cell.getCellType(); + if (cellType == CellType.FORMULA) + { + cellType = cell.getCachedFormulaResultType(); + } + if (cellType == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) + { + return new SimpleDateFormat("yyyy-MM-dd").format(cell.getDateCellValue()); + } + return CELL_FORMATTER.formatCellValue(cell).trim(); + } + + private boolean isBlankRow(EnrollmentImportRowDTO row) + { + return StringUtils.isEmpty(row.getStudentName()) + && StringUtils.isEmpty(row.getPhone()) + && StringUtils.isEmpty(row.getIdCard()) + && StringUtils.isEmpty(row.getRoomNo()); + } + + /** + * 必填项校验:第 1 列学员姓名、第 2 列联系电话。 + * + * @return 不合法的原因,合法返回 null + */ + private String invalidReason(EnrollmentImportRowDTO row) + { + boolean blankName = StringUtils.isEmpty(row.getStudentName()); + boolean blankPhone = StringUtils.isEmpty(row.getPhone()); + if (blankName && blankPhone) + { + return "学员姓名、联系电话不能为空"; + } + if (blankName) + { + return "学员姓名不能为空"; + } + if (blankPhone) + { + return "联系电话不能为空"; + } + return null; + } + + private void validateFile(MultipartFile file) + { + if (file == null || file.isEmpty()) + { + throw new ServiceException("请选择要导入的 Excel 文件"); + } + String fileName = file.getOriginalFilename(); + if (fileName == null || !fileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) + { + throw new ServiceException("仅支持 .xlsx 格式的学员导入模板"); + } + if (file.getSize() > MAX_FILE_SIZE) + { + throw new ServiceException("文件大小不能超过 5MB"); + } + } + + private String resolveMessage(Exception e) + { + String message = e.getMessage(); + if (StringUtils.isEmpty(message)) + { + message = e.getClass().getSimpleName(); + } + return message.length() > 200 ? message.substring(0, 200) : message; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentImportRowProcessor.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentImportRowProcessor.java new file mode 100644 index 000000000..9076a092f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentImportRowProcessor.java @@ -0,0 +1,355 @@ +package com.ruoyi.train.service.impl; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.dto.EnrollmentImportRowDTO; +import com.ruoyi.train.domain.vo.EnrollmentImportRowResultVO; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainEnrollmentImportMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.security.StudentIdentityProtection; +import com.ruoyi.train.service.StudentAccountProvisioner; + +/** + * 参训学员批量导入单行处理器。 + * + *

每一行在一个独立事务中处理:学员建档、账号开通、参训关联、宿舍匹配、床位分配, + * 任一步失败都会回滚整行,不影响其它行。该类必须由外部 Bean 调用,保证事务代理生效。

+ * + * @author ruoyi + */ +@Service +public class EnrollmentImportRowProcessor +{ + /** 单间宿舍最多可分配的床位数。 */ + private static final int MAX_BED_NO = 4; + + /** 床位号后缀,统一形如「1号床」。 */ + private static final String BED_SUFFIX = "号床"; + + private final TrainEnrollmentImportMapper importMapper; + + private final TrainStudentMapper studentMapper; + + private final TrainEnrollmentMapper enrollmentMapper; + + private final TrainDormitoryInfoMapper dormitoryMapper; + + private final TrainAccommodationMapper accommodationMapper; + + private final StudentIdentityProtection identityProtection; + + private final StudentAccountProvisioner accountProvisioner; + + public EnrollmentImportRowProcessor(TrainEnrollmentImportMapper importMapper, + TrainStudentMapper studentMapper, + TrainEnrollmentMapper enrollmentMapper, + TrainDormitoryInfoMapper dormitoryMapper, + TrainAccommodationMapper accommodationMapper, + StudentIdentityProtection identityProtection, + StudentAccountProvisioner accountProvisioner) + { + this.importMapper = importMapper; + this.studentMapper = studentMapper; + this.enrollmentMapper = enrollmentMapper; + this.dormitoryMapper = dormitoryMapper; + this.accommodationMapper = accommodationMapper; + this.identityProtection = identityProtection; + this.accountProvisioner = accountProvisioner; + } + + /** + * 处理单行导入数据。 + * + * @param row 行数据(学员姓名、联系电话已通过必填校验) + * @param planId 开班计划ID(即班级ID) + * @param operator 操作人 + * @return 单行结果 + */ + @Transactional(rollbackFor = Exception.class) + public EnrollmentImportRowResultVO process(EnrollmentImportRowDTO row, String planId, String operator) + { + String name = trim(row.getStudentName()); + String phone = trim(row.getPhone()); + EnrollmentImportRowResultVO result = EnrollmentImportRowResultVO.success(row.getRowNum(), name, phone); + + // 1. 以联系电话匹配学员主档:命中视为复训老学员,不重复建档 + TrainStudent student = importMapper.selectStudentByPhone(phone); + if (student == null) + { + student = buildStudent(row, operator); + studentMapper.insertTrainStudent(student); + result.setNewStudent(true); + } + result.setsId(student.getsId()); + + // 2. 账号同步:以手机号为账号,已存在则跳过 + syncAccount(student, row, result); + + // 3. 班级关联:同班同手机号重复导入直接跳过 + TrainEnrollment existed = enrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, student.getsId()); + if (existed != null) + { + result.setResult(EnrollmentImportRowResultVO.RESULT_SKIPPED); + result.setReason("该学员已在本班参训名单中"); + result.setEnrollmentId(existed.getEnrollmentId()); + return result; + } + + String roomNo = trim(row.getRoomNo()); + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setPlanId(planId); + enrollment.setStudentId(student.getsId()); + enrollment.setSourceType(1); + enrollment.setEnrollmentStatus(0); + enrollment.setAccommodationRequired(StringUtils.isEmpty(roomNo) ? 0 : 1); + enrollment.setCreateBy(operator); + enrollment.setCreateTime(new Date()); + enrollmentMapper.insertTrainEnrollment(enrollment); + + TrainEnrollment created = enrollmentMapper.selectTrainEnrollmentByPlanIdAndStudentId(planId, student.getsId()); + if (created == null || created.getEnrollmentId() == null) + { + throw new ServiceException("参训记录写入后回读失败"); + } + result.setEnrollmentId(created.getEnrollmentId()); + + if (StringUtils.isEmpty(roomNo)) + { + return result; + } + + try + { + // 4、5. 宿舍匹配与床位分配 + assignAccommodation(result, roomNo, created.getEnrollmentId(), operator); + } + catch (ServiceException e) + { + // 仅宿舍分配失败:保留学员、账号与参训记录,不回滚,转人工分配 + result.setReason("宿舍未分配成功,需手动分配;"); + result.getWarnings().add(e.getMessage()); + } + return result; + } + + /** + * 宿舍匹配与床位分配。 + * + *

业务校验失败(房间禁用、床位已满等)抛出 {@link ServiceException}, + * 由 {@link #process} 降级为「宿舍未分配成功,需手动分配」,不回滚其它已写入数据。

+ */ + private void assignAccommodation(EnrollmentImportRowResultVO result, String roomNo, Long enrollmentId, + String operator) + { + TrainDormitoryInfo dormitory = importMapper.selectDormitoryByRoomNo(roomNo); + if (dormitory == null) + { + dormitory = buildDormitory(roomNo, operator); + dormitoryMapper.insertTrainDormitoryInfo(dormitory); + result.getWarnings().add("房间号 " + roomNo + " 不存在,已自动创建宿舍"); + } + else if (dormitory.getDormStatus() != null && dormitory.getDormStatus() != 1) + { + throw new ServiceException("房间 " + roomNo + " 已被禁用,无法分配住宿"); + } + + // 行级排他锁,避免并发导入抢占同一床位 + dormitoryMapper.selectTrainDormitoryInfoForUpdate(dormitory.getDormId()); + + // 1号床起,已占用则顺延 + String bedNo = allocateBed(dormitory, roomNo); + TrainAccommodation accommodation = new TrainAccommodation(); + accommodation.setEnrollmentId(enrollmentId); + accommodation.setDormId(dormitory.getDormId()); + accommodation.setBedNo(bedNo); + accommodation.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + accommodation.setAssignedTime(new Date()); + accommodation.setCreateBy(operator); + accommodation.setCreateTime(new Date()); + accommodationMapper.insertTrainAccommodation(accommodation); + + result.setDormId(dormitory.getDormId()); + result.setBedNo(bedNo); + } + + /** + * 同步学员登录账号;手机号对应的账号已存在时跳过创建。 + */ + private void syncAccount(TrainStudent student, EnrollmentImportRowDTO row, EnrollmentImportRowResultVO result) + { + if (student.getUserId() != null) + { + return; + } + Long userId = accountProvisioner.ensureAccount(student.getsId(), trim(row.getPhone()), trim(row.getStudentName())); + if (userId == null) + { + result.getWarnings().add("手机号对应的登录账号已存在,已跳过新建账号"); + } + else + { + result.setAccountCreated(true); + } + } + + /** + * 组装学员主档。身份证允许为空;备注写入 train_student.remark。 + */ + private TrainStudent buildStudent(EnrollmentImportRowDTO row, String operator) + { + TrainStudent student = new TrainStudent(); + student.setsId(IdUtils.fastSimpleUUID()); + student.setsName(trim(row.getStudentName())); + student.setsPhone(trim(row.getPhone())); + student.setsGender(toStudentGender(row.getGender())); + student.setsBirth(parseBirth(row.getBirth())); + student.setDeptName(trim(row.getDeptName())); + + String personNo = trim(row.getPersonNo()); + student.setPersonNo(StringUtils.isEmpty(personNo) ? generatePersonNo() : personNo); + + // card_no 非空约束且无默认值,空值写入空串 + String cardNo = trim(row.getCardNo()); + student.setCardNo(StringUtils.isEmpty(cardNo) ? "" : cardNo); + + student.setRemark(trim(row.getRemark())); + student.setSourceType(1); + student.setStatus(1); + student.setDelFlag("0"); + student.setCreateBy(operator); + student.setCreateTime(new Date()); + + String idCard = trim(row.getIdCard()); + if (StringUtils.isNotEmpty(idCard)) + { + String normalized = idCard.toUpperCase(Locale.ROOT); + student.setIdCardCiphertext(identityProtection.encrypt(normalized)); + student.setIdCardHash(identityProtection.deterministicHash(normalized)); + student.setIdCardLast4(normalized.substring(Math.max(0, normalized.length() - 4))); + } + return student; + } + + /** + * 按房间号创建宿舍:4 位房间号取第 1 位为楼栋、第 2 位为楼层; + * 不足 4 位时楼栋与楼层均取首位数字,整体作为房间号存储。 + */ + private TrainDormitoryInfo buildDormitory(String roomNo, String operator) + { + String digits = roomNo.replaceAll("[^0-9]", ""); + String building = "0"; + String floor = "0"; + if (StringUtils.isNotEmpty(digits)) + { + building = digits.substring(0, 1); + floor = digits.length() >= 4 ? digits.substring(1, 2) : digits.substring(0, 1); + } + + TrainDormitoryInfo dormitory = new TrainDormitoryInfo(); + dormitory.setDormId(IdUtils.fastSimpleUUID()); + dormitory.setDormBuilding(building); + dormitory.setDormFloor(floor); + dormitory.setDormRoom(roomNo); + dormitory.setCapacity(MAX_BED_NO); + dormitory.setGenderType(0); + dormitory.setDormStatus(1); + dormitory.setDelFlag("0"); + dormitory.setCreateBy(operator); + dormitory.setCreateTime(new Date()); + return dormitory; + } + + /** + * 从 1 号床开始查找空闲床位,全部占用时抛出业务异常(整行回滚)。 + */ + private String allocateBed(TrainDormitoryInfo dormitory, String roomNo) + { + int limit = MAX_BED_NO; + if (dormitory.getCapacity() != null && dormitory.getCapacity() > 0 && dormitory.getCapacity() < MAX_BED_NO) + { + limit = dormitory.getCapacity(); + } + for (int bed = 1; bed <= limit; bed++) + { + String bedNo = bed + BED_SUFFIX; + if (accommodationMapper.countActiveByDormIdAndBedNo(dormitory.getDormId(), bedNo, null) == 0) + { + return bedNo; + } + } + throw new ServiceException("房间 " + roomNo + " 床位已满(" + limit + " 个床位均已占用)"); + } + + /** + * 性别归一化:男=0、女=1、其它或空=2。 + */ + private String toStudentGender(String gender) + { + String value = trim(gender); + if ("男".equals(value) || "0".equals(value)) + { + return "0"; + } + if ("女".equals(value) || "1".equals(value)) + { + return "1"; + } + return "2"; + } + + /** + * 出生日期解析,支持 yyyy-MM-dd / yyyy/MM/dd / yyyy.MM.dd;无法识别时返回 null(不阻断导入)。 + */ + private Date parseBirth(String value) + { + String text = trim(value); + if (StringUtils.isEmpty(text)) + { + return null; + } + String[] patterns = { "yyyy-MM-dd", "yyyy/MM/dd", "yyyy.MM.dd" }; + for (String pattern : patterns) + { + SimpleDateFormat format = new SimpleDateFormat(pattern); + format.setLenient(false); + try + { + return format.parse(text); + } + catch (ParseException ignored) + { + // 继续尝试下一种格式 + } + } + return null; + } + + /** + * 学号兜底生成,仅在模板未填写学号时使用。 + */ + private String generatePersonNo() + { + return "AA" + IdUtils.fastSimpleUUID().substring(0, 6).toUpperCase(Locale.ROOT); + } + + private String trim(String value) + { + return value == null ? "" : value.trim(); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java index 9a42475a2..78af4f4f8 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainStudentServiceImpl.java @@ -374,6 +374,42 @@ public class TrainStudentServiceImpl implements ITrainStudentService return student.getsId(); } + /** + * 仅更新学员头像。 + * + *

与 updateTrainStudent 不同,此处刻意不做 trimToNull:Mapper 动态 SQL 的条件是 + * {@code avatar != null},若把空串归一化为 null 就再也无法清空头像。

+ */ + @Override + @Transactional(rollbackFor = Exception.class) + public int updateStudentAvatar(String sId, String avatar) + { + if (StringUtils.isEmpty(sId)) + { + throw new ServiceException("学员ID不能为空"); + } + TrainStudent existing = trainStudentMapper.selectTrainStudentBySId(sId); + if (existing == null) + { + throw new ServiceException(StudentErrorCode.STUDENT_NOT_FOUND_MESSAGE, + StudentErrorCode.STUDENT_NOT_FOUND); + } + TrainStudent student = new TrainStudent(); + student.setsId(existing.getsId()); + // 空串原样落库(移除头像);null 仍表示“不修改该字段” + student.setAvatar(avatar == null ? null : avatar.trim()); + student.setUpdateTime(DateUtils.getNowDate()); + try + { + student.setUpdateBy(SecurityUtils.getUsername()); + } + catch (Exception ignored) + { + // 非 Web 单元测试无登录上下文;不使用任何请求数据作为审计用户兜底。 + } + return trainStudentMapper.updateTrainStudent(student); + } + private StudentItemVO toItemVO(TrainStudent student) { StudentItemVO item = new StudentItemVO(); diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentImportMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentImportMapper.xml new file mode 100644 index 000000000..294116ada --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentImportMapper.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml index 30daf970d..2b5d255c6 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml @@ -121,6 +121,49 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by e.enrollment_id asc + + + diff --git a/build_ui/public/files/template/enrollment_import_template.xlsx b/build_ui/public/files/template/enrollment_import_template.xlsx index 8aa2f7c6f..6c054b7a8 100644 Binary files a/build_ui/public/files/template/enrollment_import_template.xlsx and b/build_ui/public/files/template/enrollment_import_template.xlsx differ diff --git a/build_ui/src/api/train/batchAuth.js b/build_ui/src/api/train/batchAuth.js new file mode 100644 index 000000000..2965a8e53 --- /dev/null +++ b/build_ui/src/api/train/batchAuth.js @@ -0,0 +1,31 @@ +import request from '@/utils/request' + +// 批量上传头像压缩包(内部文件名:手机号.png),按手机号匹配本班人员写入 avatar +export function uploadAvatarZip(planId, data) { + return request({ + url: '/train/batch-auth/avatar-zip', + method: 'post', + params: { planId }, + data: data, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000 + }) +} + +// 查询本班「已有头像但门锁未授权」的候选人员 +export function listAuthCandidates(planId) { + return request({ + url: '/train/batch-auth/candidates', + method: 'get', + params: { planId } + }) +} + +// 提交批量授权任务(后台异步执行,逐人通过门禁入住 checkin 接口下发,接口立即返回受理结果) +export function authorizeBatch(planId, studentIds) { + return request({ + url: '/train/batch-auth/authorize', + method: 'post', + data: { planId: planId, studentIds: studentIds } + }) +} diff --git a/build_ui/src/api/train/enrollment.js b/build_ui/src/api/train/enrollment.js index fc7975c57..e26e395fa 100644 --- a/build_ui/src/api/train/enrollment.js +++ b/build_ui/src/api/train/enrollment.js @@ -30,6 +30,30 @@ export function importPlanEnrollments(planId, data) { }) } +// 3.3.1 学员批量导入预检(10 列学员模板,仅解析校验不落库) +export function precheckEnrollmentImport(planId, data) { + return request({ + url: `/train/plans/${planId}/enrollments/import/precheck`, + method: 'post', + data: data, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + +// 3.3.2 学员批量导入(10 列学员模板,逐行独立事务) +export function importEnrollmentsV2(planId, data) { + return request({ + url: `/train/plans/${planId}/enrollments/import`, + method: 'post', + data: data, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + // 3.4 获取参训档案详情 【P0】 export function getEnrollmentDetail(enrollmentId) { return request({ diff --git a/build_ui/src/api/train/student.js b/build_ui/src/api/train/student.js index 1660079a5..11c4f0e25 100644 --- a/build_ui/src/api/train/student.js +++ b/build_ui/src/api/train/student.js @@ -35,6 +35,15 @@ export function updateStudent(data) { }) } +// 仅修改学员头像(avatar 传空串表示移除头像,仅更新 train_student.avatar) +export function updateStudentAvatar(sId, avatar) { + return request({ + url: '/train/student/' + sId + '/avatar', + method: 'put', + data: { avatar: avatar || '' } + }) +} + // 生成唯一学号(AA + 6位数字) export function genPersonNo() { return request({ diff --git a/build_ui/src/views/train/class/components/EnrollmentPanel.vue b/build_ui/src/views/train/class/components/EnrollmentPanel.vue index 5f3371aa0..bb40f358b 100644 --- a/build_ui/src/views/train/class/components/EnrollmentPanel.vue +++ b/build_ui/src/views/train/class/components/EnrollmentPanel.vue @@ -64,7 +64,16 @@ size="mini" :disabled="isPlanLocked" @click="handleOpenBatchModal" - >批量导入 + >批量导入人员 + 批量上传头像 @@ -77,13 +86,15 @@ style="width: 100%" > + --> - - + + @@ -852,13 +1128,21 @@ import { listPlanEnrollments, addPlanEnrollment, importPlanEnrollments, + importEnrollmentsV2, getEnrollmentDetail, updateEnrollmentStatus, removeEnrollment, confirmReport, resetReport } from '@/api/train/enrollment' -import { listStudent, extractStudent } from '@/api/train/student' +import * as XLSX from 'xlsx' +import { listStudent, extractStudent, updateStudentAvatar } from '@/api/train/student' +// 批量上传头像 / 批量授权 +// 说明:「一键授权」调用后台异步任务(batch-auth/authorize),后端逐人通过门禁入住 checkin 接口下发, +// 本页面只做前置校验、提交与状态轮询,不等待执行结果 +import { uploadAvatarZip, listAuthCandidates, authorizeBatch } from '@/api/train/batchAuth' +// 复用「修改头像」弹窗的裁剪上传组件,保持与学员主档修改一致 +import studentAvatar from '@/views/train/student/studentAvatar.vue' import { getActiveAccommodation, getAccommodationHistory, @@ -904,10 +1188,19 @@ const LOCK_RESULT_ICON_RULES = { /** 授权结果默认图标:空值与字典未匹配时使用(灰色问号) */ const LOCK_RESULT_ICON_DEFAULT = { icon: 'el-icon-question', color: '#C0C4CC' } +/** + * 批量上传头像/授权:需要依据「本班已有」信息补全的字段。 + * 学号(personNo)、宿舍三要素(dormBuilding/dormRoom/bedNo)、参训记录ID(enrollmentId) + * 均按学员ID严格匹配后补全;enrollmentId 是「一键授权」反查住宿记录的必要参数。 + */ +const BATCH_AUTH_FILL_FIELDS = ['personNo', 'dormBuilding', 'dormRoom', 'bedNo', 'enrollmentId'] + export default { name: 'EnrollmentPanel', - // 字典注入:lock_auth_type 门禁授权类型;train_accommodation_lock_result 门禁授权结果(详情弹窗展示) - dicts: ['lock_auth_type', 'train_accommodation_lock_result'], + components: { studentAvatar }, + // 字典注入:lock_auth_type 门禁授权类型;train_accommodation_lock_result 门禁授权结果(详情弹窗展示); + // sys_user_sex 性别(档案详情按字典 label 展示) + dicts: ['lock_auth_type', 'train_accommodation_lock_result', 'sys_user_sex'], props: { planId: { type: [String, Number], @@ -942,6 +1235,63 @@ export default { accDormOptions: [], // 每个学员行的住宿编辑态(key = enrollmentId) accRowState: {}, + // 修改头像弹窗状态(仅修改 train_student.avatar,不涉及其他学员字段) + avatarDialog: { + open: false, + saving: false, + studentId: null, + studentName: '', + avatar: '' + }, + // ===== 拍照(浏览器摄像头,逻辑与学员主档修改中的拍照一致) ===== + cameraOpen: false, + // 当前摄像头视频流 + cameraStream: null, + // 摄像头是否正在启动 + cameraStarting: false, + // 拍摄后的照片 DataURL + cameraPhoto: '', + // 摄像头前置/后置 + cameraFacing: 'user', + // 摄像头不可用时的提示 + cameraError: '', + // 拍摄照片上传中 + cameraUploading: false, + // 批量上传头像 / 授权弹窗 + batchAvatarDialog: { + open: false, + loading: false, + uploading: false, + authorizing: false, + // 待上传的 ZIP 文件 + file: null, + fileName: '', + // 是否已完成一次上传(控制结果区展示) + uploadDone: false, + // 本次上传成功的明细(仅用于结果统计) + successList: [], + failureList: [], + // 执行结果:成功记录(含人脸同步状态)与失败记录一并写入 + resultList: [], + // 本次弹窗内累计上传成功的人员,用于合并进「选中授权」列表 + uploadedRows: [], + // 后端返回的授权候选原始数据(来源=本班已有),作为学号/宿舍的补全依据之一 + candidateRows: [], + // 本班全量参训人员名单(含学号、宿舍),作为学号/宿舍的补全依据之一 + rosterRows: [], + // 授权候选列表(本班已有头像但未授权 + 本次上传成功) + candidates: [], + selectedStudentIds: [], + // ===== 一键授权(独立入口,与「住宿安排」父页面状态完全隔离)===== + // 前端校验阶段被跳过的人员明细:status 固定为 skipped + authResults: [], + // accepted=后端任务受理人数,skipped=前端前置校验跳过人数 + authSummary: { accepted: 0, failed: 0, skipped: 0 }, + authRunDone: false, + // 授权结果轮询定时器(仅刷新列表观察 lock_result,不改动住宿数据) + authPollTimer: null, + authPollRound: 0 + }, // 单名添加学员对话框 addDialog: { open: false, @@ -988,7 +1338,18 @@ export default { open: false, submitting: false, file: null, - autoCreateStudent: true + autoCreateStudent: true, + ignoreInvalid: false, + totalRows: 0, + invalidRows: [], + result: { + successCount: 0, + skipCount: 0, + failureCount: 0, + ignoredCount: 0, + rows: [] + }, + resultVisible: false }, // 档案详情 detailDialog: { @@ -1103,6 +1464,10 @@ export default { return dormGender === 1 } return false + }, + /** 一键授权未成功明细(失败 + 跳过):仅用于结果区展示,与住宿模块无关 */ + batchAuthProblemRows() { + return (this.batchAvatarDialog.authResults || []).filter(item => item.status && item.status !== 'success') } }, watch: { @@ -1116,6 +1481,10 @@ export default { } } }, + beforeDestroy() { + // 组件销毁时停止一键授权的状态轮询,避免定时器残留 + this.stopBatchAuthRefresh() + }, methods: { statusLabel(status) { const found = this.statusOptions.find(item => item.value === status) @@ -1125,6 +1494,23 @@ export default { const map = { 0: 'info', 1: 'success', 2: 'warning', 3: 'danger', 4: 'primary' } return map[status] || 'info' }, + /** + * 性别展示:从字典 sys_user_sex 取 label。 + * 优先按字典 value 命中(0/1/2),再按 label 反查以兼容库中直接存「男/女」的历史数据, + * 均未命中时原样返回,避免字典缺失时误显示为空。 + */ + genderLabel(gender) { + if (gender === null || gender === undefined || String(gender).trim() === '') { + return '-' + } + const raw = String(gender).trim() + const options = (this.dict && this.dict.type && this.dict.type.sys_user_sex) || [] + let matched = options.find(item => String(item.value) === raw) + if (!matched) { + matched = options.find(item => String(item.label) === raw) + } + return matched ? matched.label : raw + }, /** 学员头像地址解析:空值回退学员工装默认头像;相对路径(如 /profile/upload/...)自动补 VUE_APP_BASE_API 前缀,已是 http(s)/data 则原样返回 */ avatarUrl(avatar) { return getStudentAvatar(avatar) @@ -1587,9 +1973,24 @@ export default { }) }, handleOpenBatchModal() { - this.batchDialog.file = null + this.resetImportDialog() this.batchDialog.autoCreateStudent = true this.batchDialog.open = true + }, + /** 重置导入状态,避免上一次的文件、校验结果与结果明细残留 */ + resetImportDialog() { + this.batchDialog.file = null + this.batchDialog.ignoreInvalid = false + this.batchDialog.totalRows = 0 + this.batchDialog.invalidRows = [] + this.batchDialog.result = { + successCount: 0, + skipCount: 0, + failureCount: 0, + ignoredCount: 0, + rows: [] + } + this.batchDialog.resultVisible = false if (this.$refs.upload) { this.$refs.upload.clearFiles() } @@ -1600,6 +2001,203 @@ export default { handleFileRemove() { this.batchDialog.file = null }, + /** 新导入流程:选择文件后即时校验格式并解析第 1、2 列必填项 */ + handleImportFileChange(file) { + const raw = file && file.raw + if (!raw) { + return + } + if (!/\.xlsx$/i.test(raw.name || '')) { + this.rejectImportFile('仅支持 .xlsx 格式的学员导入模板') + return + } + if (raw.size > 5 * 1024 * 1024) { + this.rejectImportFile('文件大小不能超过 5MB') + return + } + this.batchDialog.file = raw + this.batchDialog.invalidRows = [] + this.batchDialog.totalRows = 0 + this.parseImportRows(raw).then(info => { + this.batchDialog.totalRows = info.total + this.batchDialog.invalidRows = info.invalidRows + if (info.invalidRows.length) { + this.$modal.msgWarning('共 ' + info.invalidRows.length + ' 行缺少学员姓名或联系电话,提交时可选择忽略这些记录') + } else { + this.$modal.msgSuccess('已读取 ' + info.total + ' 行数据,必填项校验通过') + } + }).catch(error => { + this.rejectImportFile((error && error.message) || '文件解析失败,请使用本页面下载的模板重新填写') + }) + }, + /** 清空当前文件并提示原因 */ + rejectImportFile(message) { + this.batchDialog.file = null + this.batchDialog.invalidRows = [] + this.batchDialog.totalRows = 0 + if (this.$refs.upload) { + this.$refs.upload.clearFiles() + } + this.$modal.msgError(message) + }, + /** 解析 xlsx:仅取前两列做必填校验,业务数据仍由后端按模板列顺序解析 */ + parseImportRows(raw) { + return raw.arrayBuffer().then(buffer => { + const workbook = XLSX.read(buffer, { type: 'array' }) + const sheetName = workbook.SheetNames && workbook.SheetNames[0] + if (!sheetName) { + throw new Error('Excel 中没有可用的工作表') + } + const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { + header: 1, + blankrows: false, + defval: '' + }) + if (!rows || !rows.length) { + throw new Error('未读取到有效数据行') + } + const header = rows[0] || [] + if (String(header[0] || '').indexOf('学员姓名') < 0 || String(header[1] || '').indexOf('联系电话') < 0) { + throw new Error('模板不匹配,请使用本页面下载的学员导入模板') + } + const invalidRows = [] + let total = 0 + rows.slice(1).forEach((row, index) => { + const values = (row || []).map(cell => (cell == null ? '' : String(cell).trim())) + if (!values.some(Boolean)) { + return + } + total += 1 + const studentName = values[0] || '' + const phone = values[1] || '' + const missing = [] + if (!studentName) { + missing.push('学员姓名') + } + if (!phone) { + missing.push('联系电话') + } + if (missing.length) { + invalidRows.push({ + rowNum: index + 2, + studentName, + phone, + missing + }) + } + }) + if (!total) { + throw new Error('未读取到有效数据行') + } + if (total > 500) { + throw new Error('单次最多导入 500 行,请拆分后重试') + } + return { total, invalidRows } + }) + }, + /** 提交入口:存在缺少必填项的行时先让用户二选一 */ + submitEnrollmentImport() { + if (!this.batchDialog.file) { + this.$modal.msgWarning('请先选择要导入的 Excel 文件') + return + } + const invalidRows = this.batchDialog.invalidRows || [] + if (!invalidRows.length) { + this.doSubmitEnrollmentImport(false) + return + } + const detail = invalidRows.slice(0, 5) + .map(item => '第' + item.rowNum + '行(' + item.missing.join('、') + '为空)') + .join(';') + this.$confirm( + '共 ' + invalidRows.length + ' 行缺少必填项:' + detail + (invalidRows.length > 5 ? ' 等' : '') + '。请选择处理方式。', + '数据校验未通过', + { + distinguishCancelAndClose: true, + confirmButtonText: '忽略不合法记录继续导入', + cancelButtonText: '完善后导入', + type: 'warning' + } + ).then(() => { + this.doSubmitEnrollmentImport(true) + }).catch(action => { + if (action === 'cancel') { + this.batchDialog.file = null + this.batchDialog.invalidRows = [] + this.batchDialog.totalRows = 0 + if (this.$refs.upload) { + this.$refs.upload.clearFiles() + } + this.$modal.msgInfo('请完善文件后重新上传') + } + }) + }, + /** 执行导入并展示成功/跳过/失败/忽略明细 */ + doSubmitEnrollmentImport(ignoreInvalid) { + const formData = new FormData() + formData.append('file', this.batchDialog.file) + formData.append('ignoreInvalid', ignoreInvalid ? 'true' : 'false') + this.batchDialog.submitting = true + importEnrollmentsV2(this.planId, formData).then(res => { + this.batchDialog.submitting = false + this.batchDialog.open = false + this.batchDialog.result = (res && res.data) || { + successCount: 0, + skipCount: 0, + failureCount: 0, + ignoredCount: 0, + rows: [] + } + this.batchDialog.resultVisible = true + const accommodationPending = (this.batchDialog.result.rows || []).filter(row => + row.result === 'SUCCESS' && row.reason && row.reason.indexOf('宿舍未分配成功') >= 0 + ).length + if (accommodationPending > 0) { + this.$modal.msgWarning(accommodationPending + ' 名学员宿舍未分配成功,需手动分配') + } + this.getList() + // 导入会新建宿舍,重新加载宿舍备选项,确保已分配房间能正确回显 + this.loadAllDormOptions() + }).catch(() => { + this.batchDialog.submitting = false + }) + }, + importResultTagType(result) { + if (result === 'SUCCESS') { + return 'success' + } + if (result === 'SKIPPED') { + return 'info' + } + if (result === 'IGNORED') { + return 'warning' + } + return 'danger' + }, + importResultLabel(result) { + const labels = { + SUCCESS: '成功', + SKIPPED: '跳过', + FAILED: '失败', + IGNORED: '忽略', + INVALID: '不合法' + } + return labels[result] || result || '-' + }, + importRowRemark(row) { + const parts = [] + if (row && row.reason) { + parts.push(row.reason) + } + if (row && row.warnings && row.warnings.length) { + parts.push(row.warnings.join(';')) + } + return parts.join(';') || '-' + }, + /** 判断导入结果行是否为「宿舍未分配成功」 */ + isAccommodationPending(row) { + return !!(row && row.result === 'SUCCESS' && row.reason && row.reason.indexOf('宿舍未分配成功') >= 0) + }, submitBatchImport() { if (!this.batchDialog.file) { this.$modal.msgWarning('请先选择要导入的 Excel 文件') @@ -1919,20 +2517,36 @@ export default { // 换房间后床位需重新选择 this.$set(state, 'bedNo', null) }, - handleAccBedChange(row, bedNo) { + /** + * 床位下拉变更:仅在编辑态下提交。 + * + * @param {object} row 当前行 + * @param {string} bedNo 新床位号 + * @param {boolean} skipConfirm 是否跳过「确认住宿安排」弹窗,仅供已自行确认过的入住流程使用 + */ + handleAccBedChange(row, bedNo, skipConfirm = false) { const state = this.accRowState[row.enrollmentId] if (!state || !state.editing) return this.$set(state, 'bedNo', bedNo) - this.saveAccommodationRow(row) + this.saveAccommodationRow(row, skipConfirm) }, - /** 房间与床位均已选择后提交(新增分配 / 调房) */ - saveAccommodationRow(row) { + /** + * 房间与床位均已选择后提交(新增分配 / 调房)。 + * + * @param {object} row 当前行 + * @param {boolean} skipConfirm 为 true 时不再弹「确认住宿安排」框(入住流程已单独确认一次, + * 避免 handleAccCheckIn → handleAccBedChange → 此处 连弹两次);其他入口默认 false,确认行为不变 + */ + saveAccommodationRow(row, skipConfirm = false) { const state = this.accRowState[row.enrollmentId] if (!state || !state.editing || state.saving) return if (!state.dormId || !state.bedNo) return const dorm = this.accDormOptions.find(d => String(d.dormId) === String(state.dormId)) || {} const location = `${dorm.dormBuilding || ''} ${dorm.dormRoom || ''} ${state.bedNo}`.trim() - this.$modal.confirm(`确认将学员【${row.studentName || ''}】的住宿安排为【${location}】吗?`) + const confirmStep = skipConfirm + ? Promise.resolve() + : this.$modal.confirm(`确认将学员【${row.studentName || ''}】的住宿安排为【${location}】吗?`) + confirmStep .then(() => { state.saving = true // 已分配学员需先取有效住宿记录,走调房接口 @@ -2008,6 +2622,26 @@ export default { if (!detail) return code ? `${fallback}(错误码:${code})` : fallback return code ? `${fallback}(错误码:${code}):${detail}` : `${fallback}:${detail}` }, + /** + * 头像压缩包上传异常友好化:后端解析压缩包抛出的原始异常(如字符集解码失败) + * 对用户没有意义,这里按常见错误原文映射为可操作的中文提示,并保留原始信息便于排查。 + */ + describeAvatarZipError(err) { + const fallback = '头像上传失败' + const base = this.describeApiError(err, fallback) + // 字符集解码失败:文件名/条目内容非 UTF-8(如中文名被压缩成 GBK)时触发 + const isCharsetError = /malformed\s*input|input\s*length\s*=|malformedinput|unmappable|charset|unsupported\s*encoding|编码/i.test(base) + if (isCharsetError) { + return '压缩包内文件名编码无法识别(常见于中文文件名或 macOS 压缩产生的隐藏文件)。' + + '请将图片改为纯数字手机号命名(如 13800138000.jpg),用 7-Zip 或 zip 命令重新打包后再上传' + } + // 非 ZIP / 压缩包损坏 + if (/end\s*header|invalid\s*CEN|zip\s*file\s*is\s*empty|not\s*in\s*gzip|no\s*entries|压缩包损坏|不是有效/i.test(base)) { + return '文件不是有效的 ZIP 压缩包(或压缩包已损坏)。请确认文件为 .zip 格式且可正常解压后重试' + } + // 超出大小等限制,原文已足够友好,直接使用 + return base + }, /** 取学员当前有效住宿记录(供调房/退宿使用);fillSelection 为 true 时同步回填下拉框 */ resolveActiveAccommodation(row, state, fillSelection = false) { if (!state || state.accommodationId) return Promise.resolve(state) @@ -2104,6 +2738,995 @@ export default { this.$modal.msgError(this.describeApiError(err, '退宿办理失败')) } }) + }, + /** + * 是否「已分配宿舍」:宿舍房间号与床位号下拉框均已赋值。 + * 「住宿安排」列的入住/退房操作项仅在已分配宿舍时展示。 + */ + isAccBedAssigned(row) { + const state = row && this.accRowState[row.enrollmentId] + return !!(state && state.dormId && state.bedNo) + }, + /** 门锁授权结果是否「已授权」:字典值 2 / authorized / success */ + isAccLockAuthorized(row) { + const raw = row ? row.lockResult : null + if (raw === null || raw === undefined || String(raw).trim() === '') return false + return ['2', 'authorized', 'success'].indexOf(String(raw).trim().toLowerCase()) > -1 + }, + /** + * 入住(新增操作项,独立于「退宿」):门锁未授权时,携带本行宿舍信息复用 + * handleAccBedChange 提交住宿安排。人脸授权依赖学员头像,本行无头像时提示先补齐头像。 + * + *

确认框只在本方法弹一次:后续 handleAccBedChange → saveAccommodationRow 会传 + * skipConfirm=true 跳过「确认住宿安排」弹窗;其他入口(床位下拉手动选择)仍保留原有确认。

+ */ + handleAccCheckIn(row) { + if (!this.isAccBedAssigned(row)) { + this.$modal.msgWarning('该学员尚未分配宿舍房间号与床位,无法办理入住') + return + } + if (!row.avatar) { + this.$modal.msgWarning(`学员【${row.studentName || ''}】暂无头像,请先补齐头像后再办理入住`) + return + } + const state = this.accRowState[row.enrollmentId] + const dorm = this.accDormOptions.find(d => String(d.dormId) === String(state.dormId)) || {} + const location = `${dorm.dormBuilding || ''} ${dorm.dormRoom || ''} ${state.bedNo}`.trim() + this.$modal.confirm(`确认办理学员【${row.studentName || ''}】入住【${location}】并下发门锁授权吗?`) + .then(() => { + // 下拉框仅在编辑态下才会提交:临时进入编辑态后按当前床位触发 handleAccBedChange; + // 此处已确认过,skipConfirm=true 避免重复弹窗 + this.$set(state, 'editing', true) + this.handleAccBedChange(row, state.bedNo, true) + }) + .catch(() => { + if (state) this.$set(state, 'editing', false) + }) + }, + /** + * 退房(新增操作项,独立于「退宿」):流程与「退宿」类似,但仅调用门禁退房接口 + * 取消门锁授权,不改住宿状态、不删除学员 avatar。 + */ + handleAccCheckOutLock(row) { + if (!this.isAccBedAssigned(row)) { + this.$modal.msgWarning('该学员尚未分配宿舍房间号与床位,无法办理退房') + return + } + const state = this.accRowState[row.enrollmentId] + this.$modal.confirm(`确认办理学员【${row.studentName || ''}】的门禁退房吗?仅取消门锁授权,住宿安排与头像保持不变。`) + .then(() => { + const dorm = this.accDormOptions.find(d => String(d.dormId) === String(state && state.dormId)) || {} + const payload = { + bizNo: row.lockApiBizNo, + roomNo: row.dormRoom || dorm.dormRoom || '' + } + const bizNo = payload.bizNo === null || payload.bizNo === undefined ? '' : String(payload.bizNo).trim() + if (!bizNo) { + this.$modal.msgWarning( + `学员【${row.studentName || ''}】门禁退房未执行:bizNo 为空,请核对门锁授权信息后重试` + ) + return + } + payload.bizNo = bizNo + const requestModule = require('@/utils/request') + const request = requestModule.default || requestModule + return request({ + url: '/api/lock/v1/stay/checkout', + method: 'post', + data: payload + }).then(() => { + this.$modal.msgSuccess('退房成功,门锁授权已取消') + this.loadAllDormOptions().then(() => this.getList()) + }).catch(err => { + this.$modal.msgError(this.describeApiError(err, '退房办理失败')) + }) + }) + .catch(err => { + if (err !== 'cancel') { + this.$modal.msgError(this.describeApiError(err, '退房办理失败')) + } + }) + }, + // ===== 修改学员头像(点击头像列打开,交互与学员主档「修改」一致) ===== + /** 点击头像:打开修改头像弹窗,仅回填该学员主档ID、姓名与现有头像 */ + handleAccAvatarClick(row) { + if (!row || !row.studentId) { + this.$modal.msgWarning('未取到学员主档ID,无法修改头像') + return + } + this.avatarDialog.saving = false + this.avatarDialog.studentId = row.studentId + this.avatarDialog.studentName = row.studentName || '' + this.avatarDialog.avatar = row.avatar || '' + this.avatarDialog.open = true + }, + /** 选择图片:打开头像裁剪/上传弹窗(与学员主档修改的「选择图片」一致) */ + handleSelectAccAvatar() { + if (this.$refs.accStudentAvatar) { + this.$refs.accStudentAvatar.editCropper() + } + }, + /** 裁剪上传成功:回填待保存的头像地址 */ + handleAccAvatarSuccess(imgUrl) { + if (!imgUrl) return + this.avatarDialog.avatar = imgUrl + }, + /** 移除已有头像:提交时将 avatar 置空,保存后清除 train_student.avatar */ + handleRemoveAccAvatar() { + this.avatarDialog.avatar = '' + }, + /** + * 保存头像:调用专用接口 PUT /train/student/{sId}/avatar,只更新 train_student.avatar, + * 不会触碰学员其他字段与账号同步逻辑;avatar 传空串表示移除头像。 + */ + submitAccAvatar() { + const studentId = this.avatarDialog.studentId + if (!studentId) { + this.$modal.msgWarning('未取到学员主档ID,无法修改头像') + return + } + const avatar = this.avatarDialog.avatar || '' + this.avatarDialog.saving = true + updateStudentAvatar(studentId, avatar).then(res => { + const result = this.parseApiResult(res, '头像修改成功') + if (!result.success) { + const error = new Error(result.message) + error.code = result.code + error.msg = result.message + error.data = res + throw error + } + this.$modal.msgSuccess(result.message) + // 仅同步当前行头像,避免整表刷新丢失其他行的编辑态 + this.syncAccAvatarToRow(studentId, avatar) + this.avatarDialog.open = false + }).catch(err => { + this.$modal.msgError(this.describeApiError(err, '头像修改失败')) + }).finally(() => { + this.avatarDialog.saving = false + }) + }, + /** 按学员主档ID同步列表行头像 */ + syncAccAvatarToRow(studentId, avatar) { + this.enrollmentList.forEach(item => { + if (String(item.studentId) === String(studentId)) { + this.$set(item, 'avatar', avatar) + } + }) + }, + /** 弹窗关闭后复位,避免下次打开残留上次的头像 */ + resetAccAvatarDialog() { + this.avatarDialog.saving = false + this.avatarDialog.studentId = null + this.avatarDialog.studentName = '' + this.avatarDialog.avatar = '' + }, + // ===== 拍照:调用浏览器摄像头(与学员主档修改中的拍照逻辑一致,仅回填头像弹窗) ===== + /** 打开拍照弹窗 */ + openCamera() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + this.$modal.msgError('当前浏览器不支持拍照功能,请使用 Chrome/Edge 等现代浏览器,并确保以 https 或 localhost 访问') + return + } + this.cameraPhoto = '' + this.cameraError = '' + this.cameraFacing = 'user' + this.cameraOpen = true + }, + /** 拍照弹窗打开后启动摄像头 */ + handleCameraOpened() { + this.startCamera() + }, + /** 启动摄像头并播放视频流 */ + startCamera() { + this.stopCameraStream() + this.cameraStarting = true + this.cameraError = '' + const constraints = { + audio: false, + video: { + facingMode: this.cameraFacing, + width: { ideal: 1280 }, + height: { ideal: 720 } + } + } + navigator.mediaDevices.getUserMedia(constraints).then(stream => { + this.cameraStream = stream + const video = this.$refs.cameraVideo + if (!video) { + this.stopCameraStream() + return + } + video.srcObject = stream + // 部分浏览器需手动触发播放 + const playPromise = video.play() + if (playPromise && playPromise.catch) { + playPromise.catch(() => {}) + } + }).catch(error => { + const name = error && error.name + if (name === 'NotAllowedError' || name === 'SecurityError') { + this.cameraError = '摄像头权限被拒绝,请在浏览器地址栏允许摄像头权限后重试' + } else if (name === 'NotFoundError' || name === 'OverconstrainedError') { + this.cameraError = '未检测到可用的摄像头设备' + } else { + this.cameraError = '摄像头打开失败:' + ((error && (error.message || name)) || '未知错误') + } + }).finally(() => { + this.cameraStarting = false + }) + }, + /** 切换前置/后置摄像头 */ + switchCamera() { + this.cameraFacing = this.cameraFacing === 'user' ? 'environment' : 'user' + this.startCamera() + }, + /** 拍摄当前画面 */ + takePhoto() { + const video = this.$refs.cameraVideo + const canvas = this.$refs.cameraCanvas + if (!video || !canvas) { + this.$modal.msgError('摄像头尚未就绪,请稍候重试') + return + } + if (!video.videoWidth || !video.videoHeight) { + this.$modal.msgWarning('摄像头画面尚未加载完成,请稍候再试') + return + } + // 取正方形区域,避免头像被拉伸变形 + const size = Math.min(video.videoWidth, video.videoHeight) + canvas.width = size + canvas.height = size + const context = canvas.getContext('2d') + const sx = (video.videoWidth - size) / 2 + const sy = (video.videoHeight - size) / 2 + context.drawImage(video, sx, sy, size, size, 0, 0, size, size) + this.cameraPhoto = canvas.toDataURL('image/png') + // 拍摄完成即释放摄像头 + this.stopCameraStream() + }, + /** 重新拍摄 */ + retakePhoto() { + this.cameraPhoto = '' + this.cameraError = '' + this.startCamera() + }, + /** 使用拍摄照片:上传到 /common/upload 并回填头像弹窗(入库仍走 submitAccAvatar) */ + submitCameraPhoto() { + if (!this.cameraPhoto) { + this.$modal.msgWarning('请先拍摄照片') + return + } + const blob = this.dataURLtoBlob(this.cameraPhoto) + if (!blob) { + this.$modal.msgError('照片解析失败,请重新拍摄') + return + } + const formData = new FormData() + formData.append('file', new File([blob], 'avatar.png', { type: 'image/png' })) + this.cameraUploading = true + const requestModule = require('@/utils/request') + const request = requestModule.default || requestModule + request({ + url: '/common/upload', + method: 'post', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' } + }).then(response => { + const imgUrl = response.fileName || response.url + if (!imgUrl) { + this.$modal.msgError('上传失败,未获取到文件地址') + return + } + // 与「选择图片」走同一回填逻辑:只回填待保存头像,点确定后再入库 + this.avatarDialog.avatar = imgUrl + this.$modal.msgSuccess('拍照上传成功') + this.cameraOpen = false + }).catch(() => {}).finally(() => { + this.cameraUploading = false + }) + }, + /** DataURL -> Blob */ + dataURLtoBlob(dataURL) { + if (!dataURL) { + return null + } + const parts = dataURL.split(',') + if (parts.length < 2) { + return null + } + const mime = (parts[0].match(/:(.*?);/) || [])[1] || 'image/png' + const binary = window.atob(parts[1]) + const buffer = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i) + } + return new Blob([buffer], { type: mime }) + }, + /** 释放摄像头视频流 */ + stopCameraStream() { + if (this.cameraStream) { + this.cameraStream.getTracks().forEach(track => track.stop()) + this.cameraStream = null + } + const video = this.$refs.cameraVideo + if (video) { + video.srcObject = null + } + }, + /** 关闭拍照弹窗:释放摄像头并清理临时照片 */ + closeCamera() { + this.stopCameraStream() + this.cameraPhoto = '' + this.cameraError = '' + }, + // ===== 批量上传头像 / 授权 ===== + /** + * 打开弹窗:先执行完整初始化,把弹窗内所有状态与数据重置为默认值, + * 再打开并加载本班名单与授权候选,确保不残留上一次操作的数据。 + */ + handleOpenBatchAvatarDialog() { + this.resetBatchAvatarDialog() + this.batchAvatarDialog.open = true + this.loadRosterRows() + this.loadAuthCandidates() + }, + /** + * 弹窗初始化 / 关闭复位:把弹窗内全部状态与数据恢复为初始默认值, + * 包含已选文件、上传进度、上传结果、候选列表、勾选与授权执行结果, + * 同时清空 el-upload 内部文件列表,避免组件实例残留上一次选择的文件。 + */ + resetBatchAvatarDialog() { + // 先停止授权结果轮询,避免重置后仍有定时回调写入旧状态 + this.stopBatchAuthRefresh() + // 清空上传组件内部维护的文件列表(:show-file-list="false" 时界面不可见,但实例状态会保留) + const upload = this.$refs.batchAvatarUpload + if (upload && typeof upload.clearFiles === 'function') { + upload.clearFiles() + } + this.batchAvatarDialog = { + open: this.batchAvatarDialog.open, + loading: false, + uploading: false, + authorizing: false, + file: null, + fileName: '', + uploadDone: false, + successList: [], + failureList: [], + resultList: [], + uploadedRows: [], + candidateRows: [], + rosterRows: [], + candidates: [], + selectedStudentIds: [], + authResults: [], + authSummary: { accepted: 0, failed: 0, skipped: 0 }, + authRunDone: false, + authPollTimer: null, + authPollRound: 0 + } + }, + /** 选择 ZIP 压缩包(仅记录文件,点击「开始上传」后才提交) */ + handleAvatarZipChange(file) { + const fileName = (file && file.name) || '' + if (!/\.zip$/i.test(fileName)) { + this.$modal.msgWarning('请选择 .zip 格式的头像压缩包') + return + } + this.batchAvatarDialog.file = file.raw + this.batchAvatarDialog.fileName = fileName + }, + /** 手机号归一化:仅保留数字,避免分隔符/空格导致匹配失效 */ + normalizePhone(value) { + if (value === null || value === undefined) return '' + return String(value).replace(/\D/g, '') + }, + /** 转义 HTML,避免姓名等字段影响确认弹窗的渲染 */ + escapeHtml(text) { + if (text === null || text === undefined) return '' + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + }, + /** 读取压缩包内的文件名清单(仅解析中央目录,不解压文件内容) */ + listZipFileNames(file) { + return new Promise(resolve => { + const reader = new FileReader() + reader.onload = () => resolve(this.parseZipFileNames(reader.result)) + reader.onerror = () => resolve([]) + reader.readAsArrayBuffer(file) + }) + }, + /** + * 解析 ZIP 中央目录,取出全部条目文件名。 + * 只读文件名(不经解压),异常/不支持的压缩包(如 ZIP64)返回空数组,交由后端处理。 + */ + parseZipFileNames(buffer) { + if (!buffer) return [] + try { + const view = new DataView(buffer) + // 1) 从尾部向前定位 EOCD 签名 0x06054b50(注释最长 65535 字节) + let eocd = -1 + const minPos = Math.max(0, view.byteLength - 65557) + for (let i = view.byteLength - 22; i >= minPos; i -= 1) { + if (view.getUint32(i, true) === 0x06054b50) { + eocd = i + break + } + } + if (eocd < 0) return [] + const total = view.getUint16(eocd + 10, true) + const cdOffset = view.getUint32(eocd + 16, true) + // 条目数或偏移为 ZIP64 标记时不解析(避免误判),直接放行 + if (!total || cdOffset === 0xFFFFFFFF) return [] + const names = [] + let pos = cdOffset + for (let i = 0; i < total && pos + 46 <= view.byteLength; i += 1) { + if (view.getUint32(pos, true) !== 0x02014b50) break + const flags = view.getUint16(pos + 8, true) + const nameLen = view.getUint16(pos + 28, true) + const extraLen = view.getUint16(pos + 30, true) + const commentLen = view.getUint16(pos + 32, true) + if (pos + 46 + nameLen > view.byteLength) break + const nameBytes = new Uint8Array(buffer, pos + 46, nameLen) + names.push(this.decodeZipName(nameBytes, (flags & 0x0800) !== 0)) + pos += 46 + nameLen + extraLen + commentLen + } + return names + } catch (e) { + return [] + } + }, + /** ZIP 文件名解码:优先 UTF-8,失败回退 GBK,最后按 latin1 兜底 */ + decodeZipName(bytes, utf8Flag) { + const tryDecode = encoding => { + try { + return new TextDecoder(encoding, { fatal: true }).decode(bytes) + } catch (e) { + return null + } + } + if (utf8Flag) { + return tryDecode('utf-8') || tryDecode('gbk') || '' + } + return tryDecode('utf-8') || tryDecode('gbk') || tryDecode('iso-8859-1') || '' + }, + /** + * 检查本次上传是否会覆盖已有头像: + * 依据压缩包内「手机号.xxx」文件名,匹配本班参训人员的手机号, + * 返回当前已设置头像(将被覆盖)的人员清单;无法解析压缩包时返回空数组(不拦截)。 + */ + checkAvatarOverwrite(file) { + const ensureRoster = this.batchAvatarDialog.rosterRows.length + ? Promise.resolve() + : this.loadRosterRows() + return ensureRoster.then(() => this.listZipFileNames(file)).then(names => { + if (!names.length) return [] + const rosterMap = {} + ;(this.batchAvatarDialog.rosterRows || []).forEach(row => { + const phone = this.normalizePhone(row.phone) + if (phone) rosterMap[phone] = row + }) + // 候选列表(来源=本班已有)同样带头像,作为补充来源避免漏判 + const candidateMap = {} + ;(this.batchAvatarDialog.candidateRows || []).forEach(row => { + const phone = this.normalizePhone(row.phone) + if (phone) candidateMap[phone] = row + }) + const hasAvatar = row => !!row && !this.isBlankField(row.avatar) + const conflicts = [] + const seen = {} + names.forEach(name => { + const fileName = String(name || '').split('/').pop() + const matched = fileName.match(/^(\d{11})\.(jpe?g|png)$/i) + if (!matched) return + const phone = matched[1] + if (seen[phone]) return + seen[phone] = true + const student = [rosterMap[phone], candidateMap[phone]].find(hasAvatar) + if (!student) return + conflicts.push({ + phone: phone, + fileName: fileName, + studentName: student.studentName, + studentId: student.studentId + }) + }) + return conflicts + }).catch(() => []) + }, + /** 覆盖已有头像的二次确认:确认后继续上传,取消则不做任何修改 */ + confirmAvatarOverwrite(conflicts) { + const maxShow = 5 + const items = conflicts.slice(0, maxShow).map(item => + `
  • ${this.escapeHtml(item.studentName || '未知学员')}(${item.phone})
  • ` + ).join('') + const more = conflicts.length > maxShow ? `
  • ……共 ${conflicts.length} 人
  • ` : '' + const html = `
    以下 ${conflicts.length} 人在压缩包中存在头像文件,且当前已设置头像: +
      ${items}${more}
    +
    继续上传将覆盖原头像,是否继续?
    ` + return this.$confirm(html, '确认覆盖已有头像', { + dangerouslyUseHTMLString: true, + confirmButtonText: '覆盖并上传', + cancelButtonText: '取消', + type: 'warning' + }) + }, + /** 点击「开始上传」:先检测是否存在将被覆盖的已有头像,用户确认后再提交 */ + submitAvatarZip() { + if (this.batchAvatarDialog.uploading) return + const file = this.batchAvatarDialog.file + if (!file) { + this.$modal.msgWarning('请先选择头像压缩包') + return + } + this.batchAvatarDialog.uploading = true + this.checkAvatarOverwrite(file) + .then(conflicts => { + if (!conflicts.length) { + return this.doUploadAvatarZip(file) + } + // 存在已有头像:先确认,取消则保留原头像不做任何修改 + return this.confirmAvatarOverwrite(conflicts).then( + () => this.doUploadAvatarZip(file), + () => { + this.$modal.msg('已取消上传,原头像保持不变') + } + ) + }) + .catch(err => { + this.$modal.msgError(this.describeAvatarZipError(err)) + }) + .finally(() => { + this.batchAvatarDialog.uploading = false + }) + }, + /** 提交头像压缩包:按手机号匹配并写入 avatar,返回成功/失败明细 */ + doUploadAvatarZip(file) { + const formData = new FormData() + formData.append('file', file) + return uploadAvatarZip(this.planId, formData).then(res => { + const data = (res && res.data) || {} + const successList = data.successList || [] + const failureList = data.failureList || [] + this.batchAvatarDialog.successList = successList + this.batchAvatarDialog.failureList = failureList + // 执行结果:成功记录与失败记录一并写入,便于在结果区完整回溯本次上传 + this.batchAvatarDialog.resultList = this.buildAvatarResultList(successList, failureList) + // 成功人员累计记录,稍后合并进「选中授权」列表;失败明细留在上方结果区 + this.accumulateUploadedRows(successList) + this.batchAvatarDialog.uploadDone = true + this.$modal.msgSuccess((res && (res.msg || res.message)) || '头像上传完成') + // 列表刷新以显示新头像;同时刷新本班名单与授权候选,用于补全学号/宿舍并追加到「选中授权」列表 + this.getList() + this.loadRosterRows() + this.loadAuthCandidates() + }) + }, + /** + * 组装执行结果:成功记录与失败记录一并写入。 + * 成功记录保留头像信息与设备人脸同步状态,失败记录沿用后端返回的失败原因。 + */ + buildAvatarResultList(successList, failureList) { + const rows = [] + ;(successList || []).forEach((item, index) => { + rows.push({ + key: `success-${item.studentId || index}`, + target: item.studentName || item.phone || '', + phone: item.phone || '', + status: 'success', + reason: this.describeFaceSync(item) + }) + }) + ;(failureList || []).forEach((item, index) => { + rows.push({ + key: `failure-${index}`, + target: item.fileName || item.phone || '', + phone: item.phone || '', + status: 'failed', + reason: item.reason || '' + }) + }) + return rows + }, + /** 人脸同步状态文案:success=已同步,failed=带失败原因,其余视为未执行 */ + describeFaceSync(item) { + const status = (item && item.faceSync) || 'skipped' + if (status === 'success') { + return '头像已更新,人脸已同步至设备' + } + if (status === 'failed') { + return '头像已更新,人脸同步失败:' + ((item && item.faceMsg) || '未知原因') + } + return '头像已更新,未执行人脸同步' + }, + /** 累加本次上传成功的人员(同一学员以最新一次上传结果为准) */ + accumulateUploadedRows(rows) { + const list = this.batchAvatarDialog.uploadedRows || [] + const indexMap = {} + list.forEach((item, index) => { + const key = this.studentKey(item.studentId) + if (key) indexMap[key] = index + }) + ;(rows || []).forEach(item => { + const key = this.studentKey(item.studentId) + if (!key) { + // 无学员ID时无法去重,直接追加(后续也无法参与授权) + list.push(Object.assign({}, item)) + return + } + if (indexMap[key] !== undefined) { + list.splice(indexMap[key], 1, Object.assign({}, list[indexMap[key]], item)) + } else { + indexMap[key] = list.length + list.push(Object.assign({}, item)) + } + }) + this.batchAvatarDialog.uploadedRows = list + }, + /** 学员ID归一化:统一转字符串并去空格,保证后续严格相等匹配 */ + studentKey(value) { + if (value === null || value === undefined) return '' + return String(value).trim() + }, + /** 字段空值判断:null / undefined / 空串 / 纯空格 */ + isBlankField(value) { + return value === null || value === undefined || String(value).trim() === '' + }, + /** 仅补全目标行的空白字段,已有值不覆盖,确保接口返回的准确数据优先 */ + fillMissingStudentInfo(target, source) { + if (!target || !source) return target + BATCH_AUTH_FILL_FIELDS.forEach(field => { + if (this.isBlankField(target[field]) && !this.isBlankField(source[field])) { + target[field] = source[field] + } + }) + return target + }, + /** + * 组装「选中授权」列表数据: + * 1. 后端候选(来源=本班已有)在前,本次上传成功的人员打「本次上传」标记并覆盖为新头像; + * 2. 后端候选尚未包含本次上传成功的人员时,用上传结果本地追加一行,保证成功记录一定可见; + * 3. 学号、宿舍按学员ID严格匹配「本班已有」信息(候选来源=本班已有 > 本班参训名单)补全, + * 只补空值不覆盖已有值,匹配不上时保持为空,避免错配。 + */ + buildAuthCandidates() { + const uploadedRows = this.batchAvatarDialog.uploadedRows || [] + const candidateRows = this.batchAvatarDialog.candidateRows || [] + // 补全信息索引:key 为学员ID,仅按学员ID精确匹配,不做姓名/手机号等易错匹配 + const infoMap = {} + const knownMap = {} + const collectInfo = (rows, authoritative) => { + ;(rows || []).forEach(row => { + const key = this.studentKey(row && row.studentId) + if (!key) return + const target = infoMap[key] || (infoMap[key] = {}) + if (authoritative) knownMap[key] = true + BATCH_AUTH_FILL_FIELDS.forEach(field => { + if (this.isBlankField(target[field]) && !this.isBlankField(row[field])) { + target[field] = row[field] + } + }) + }) + } + // 优先级:后端候选(来源=本班已有)> 本班参训名单 > 上传结果自带字段 + collectInfo(candidateRows, true) + collectInfo(this.batchAvatarDialog.rosterRows, true) + collectInfo(uploadedRows, false) + + const uploadedMap = {} + uploadedRows.forEach(item => { + const key = this.studentKey(item.studentId) + if (key) uploadedMap[key] = item + }) + + // 保留上一次一键授权的行内执行态(列表刷新后仍能看到「授权中/失败原因」) + const prevTaskMap = {} + ;(this.batchAvatarDialog.candidates || []).forEach(item => { + const key = this.studentKey(item && item.studentId) + if (key && (item.authTaskState || item.authTaskReason)) { + prevTaskMap[key] = { state: item.authTaskState, reason: item.authTaskReason } + } + }) + const applyPrevTaskState = row => { + const key = this.studentKey(row.studentId) + const prev = prevTaskMap[key] + // pending 只在本地提交瞬间展示:刷新后以真实 lock_result 为准,避免长期停留在「授权中」 + if (prev && prev.state && prev.state !== 'pending') { + row.authTaskState = prev.state + row.authTaskReason = prev.reason + } + return row + } + + const existedMap = {} + const result = candidateRows.map(item => { + const key = this.studentKey(item.studentId) + if (key) existedMap[key] = true + const uploaded = uploadedMap[key] + const row = Object.assign({}, item, { + uploadedInBatch: !!uploaded, + avatar: (uploaded && uploaded.avatar) || item.avatar + }) + this.fillMissingStudentInfo(row, infoMap[key]) + return applyPrevTaskState(row) + }) + uploadedRows.forEach(item => { + const key = this.studentKey(item.studentId) + // 已由后端候选行承载的学员不再重复追加,避免同一学员出现两行 + if (key && existedMap[key]) return + const info = infoMap[key] || {} + const row = { + studentId: item.studentId, + studentName: item.studentName || info.studentName, + phone: item.phone || info.phone, + avatar: item.avatar || info.avatar, + personNo: undefined, + dormBuilding: undefined, + dormRoom: undefined, + bedNo: undefined, + lockResult: info.lockResult, + uploadedInBatch: true + } + this.fillMissingStudentInfo(row, info) + // 候选与本班名单均无该学员时,学号/宿舍无从校验,展示「-」而不是「未分配」 + row.localOnly = !knownMap[key] + result.push(applyPrevTaskState(row)) + if (key) existedMap[key] = true + }) + return result + }, + /** + * 用「后端候选 + 本班参训名单 + 上传结果」重建「选中授权」列表。 + * keepSelection 为 true 时保留用户已勾选的学员,否则默认勾选本次上传成功的人员。 + */ + refreshAuthCandidates(keepSelection) { + const previousSelected = keepSelection ? (this.batchAvatarDialog.selectedStudentIds || []).slice() : [] + this.batchAvatarDialog.candidates = this.buildAuthCandidates() + this.$nextTick(() => { + const table = this.$refs.batchAuthTable + if (!table) return + table.clearSelection() + const selectedSet = {} + previousSelected.forEach(id => { + const key = this.studentKey(id) + if (key) selectedSet[key] = true + }) + this.batchAvatarDialog.candidates.forEach(row => { + const key = this.studentKey(row.studentId) + const shouldSelect = previousSelected.length ? !!selectedSet[key] : row.uploadedInBatch + if (shouldSelect) table.toggleRowSelection(row, true) + }) + }) + }, + /** + * 加载本班「已有头像但未授权」的候选人员,并合并本次上传成功的人员。 + * keepSelection:为 true 时保留当前勾选(授权后轮询刷新时使用),否则默认勾选本次上传人员 + */ + loadAuthCandidates(keepSelection) { + if (!this.planId) return + this.batchAvatarDialog.loading = true + listAuthCandidates(this.planId).then(res => { + this.batchAvatarDialog.candidateRows = (res && res.data) || [] + this.refreshAuthCandidates(!!keepSelection) + }).catch(() => { + // 候选接口失败时仍展示本次上传成功的人员,避免结果丢失 + this.batchAvatarDialog.candidateRows = [] + this.refreshAuthCandidates(!!keepSelection) + }).finally(() => { + this.batchAvatarDialog.loading = false + }) + }, + /** + * 拉取本班全量参训人员名单(含学号、宿舍三要素),作为补全上传成功记录的权威信息来源。 + * 分页拉全避免遗漏;拉取失败不影响主流程,仅保留已有信息。 + */ + loadRosterRows() { + if (!this.planId) return Promise.resolve() + const pageSize = 200 + const rows = [] + const fetchPage = pageNum => listPlanEnrollments(this.planId, { pageNum, pageSize }).then(res => { + const pageRows = (res && res.rows) || [] + pageRows.forEach(row => rows.push(row)) + const total = Number((res && res.total) || 0) + if (pageRows.length && rows.length < total) { + return fetchPage(pageNum + 1) + } + return rows + }) + return fetchPage(1).then(list => { + this.batchAvatarDialog.rosterRows = list + // 名单到位后按学员ID回填学号/宿舍,并保留当前勾选 + this.refreshAuthCandidates(true) + }).catch(() => { + // 名单拉取失败:保持现有数据,避免影响上传结果展示 + }) + }, + /** 表格勾选变化 */ + handleBatchAuthSelectionChange(rows) { + this.batchAvatarDialog.selectedStudentIds = (rows || []).map(row => row.studentId) + }, + /** 宿舍展示文案:楼栋 房间 床位 */ + dormTextOf(row) { + if (!row) return '-' + const parts = [row.dormBuilding, row.dormRoom, row.bedNo].filter(item => + item !== null && item !== undefined && String(item).trim() !== '' + ) + if (parts.length) return parts.join(' ') + // 仅来自上传结果的行(后端候选列表尚未返回)宿舍信息暂缺,不臆造「未分配」 + return row.localOnly ? '-' : '未分配' + }, + // ===== 一键授权(独立门锁授权链路,不复用「住宿安排」的编辑态与提交方法)===== + /** + * 该行对应的参训记录ID:优先行内字段,其次本班名单按学员ID反查。 + * 仅做只读查询,不读写父页面 accRowState。 + */ + batchAuthEnrollmentOf(row) { + if (!row) return '' + if (!this.isBlankField(row.enrollmentId)) return row.enrollmentId + const key = this.studentKey(row.studentId) + if (!key) return '' + const roster = (this.batchAvatarDialog.rosterRows || []).find(item => this.studentKey(item.studentId) === key) + return roster && !this.isBlankField(roster.enrollmentId) ? roster.enrollmentId : '' + }, + /** 行内宿舍ID(仅取行内字段,不读取 accRowState) */ + batchAuthDormId(row) { + return row && !this.isBlankField(row.dormId) ? row.dormId : '' + }, + /** + * 授权前置校验(同步、只读):与「住宿安排-入住」的口径一致, + * 必须有头像、有学号、有房间与床位,且不得是已授权状态。 + */ + batchAuthPrecheck(row) { + if (!row) return { ok: false, reason: '数据为空' } + if (this.isAccLockAuthorized(row)) return { ok: false, reason: '门锁已授权,无需重复下发' } + if (this.isBlankField(row.studentId)) return { ok: false, reason: '缺少学员ID,无法下发授权' } + if (this.isBlankField(this.batchAuthEnrollmentOf(row))) return { ok: false, reason: '未找到本班参训记录' } + if (this.isBlankField(row.avatar)) return { ok: false, reason: '学员暂无头像,请先上传头像' } + if (this.isBlankField(row.personNo)) return { ok: false, reason: '缺少学号(人员编号),无法下发授权' } + const hasRoom = !!this.batchAuthDormId(row) || + (!this.isBlankField(row.dormBuilding) && !this.isBlankField(row.dormRoom)) + if (!hasRoom) return { ok: false, reason: '未分配宿舍房间,请先在「住宿安排」中分配' } + if (this.isBlankField(row.bedNo)) return { ok: false, reason: '未分配床位,请先在「住宿安排」中分配' } + return { ok: true, reason: '' } + }, + /** 表格勾选可用性:已授权或前置条件不满足的行禁止勾选 */ + isBatchAuthSelectable(row) { + return this.batchAuthPrecheck(row).ok + }, + /** + * 授权状态单元格:已生效 > 进行中/已下发/失败/跳过 > 原始 lock_result。 + * 仅用于本弹窗展示,不写回父页面任何状态。 + */ + batchAuthStateCell(row) { + if (this.isAccLockAuthorized(row)) { + return { text: '已授权', color: '#67C23A', tip: '门锁授权已生效' } + } + const state = row && row.authTaskState + if (state === 'pending') { + return { text: '授权中…', color: '#409EFF', tip: '后台任务执行中(逐人调用门禁入住 checkin 接口),结果自动刷新' } + } + if (state === 'success') { + return { text: '已下发', color: '#409EFF', tip: '授权已下发,等待门锁回执(列表会自动刷新)' } + } + if (state === 'failed') { + return { text: '授权失败', color: '#F56C6C', tip: row.authTaskReason || '授权下发失败' } + } + if (state === 'skipped') { + return { text: '已跳过', color: '#E6A23C', tip: row.authTaskReason || '前置条件不满足,未下发授权' } + } + // 前置条件不满足(不可勾选):直接展示原因,避免用户不知道勾选框为何置灰 + const precheck = this.batchAuthPrecheck(row) + if (!precheck.ok) { + return { text: '不可授权', color: '#C0C4CC', tip: precheck.reason } + } + const cell = this.lockResultCell(row) + return { text: cell.text, color: cell.color, tip: cell.tip } + }, + /** 更新某行的一键授权执行态(仅作用于弹窗候选行) */ + setBatchAuthRowState(row, state, reason) { + const key = this.studentKey(row && row.studentId) + const target = (this.batchAvatarDialog.candidates || []).find(item => this.studentKey(item.studentId) === key) + if (!target) return + this.$set(target, 'authTaskState', state) + this.$set(target, 'authTaskReason', reason || '') + }, + /** 停止授权结果轮询 */ + stopBatchAuthRefresh() { + const timer = this.batchAvatarDialog.authPollTimer + if (timer) { + clearTimeout(timer) + this.batchAvatarDialog.authPollTimer = null + } + }, + /** + * 授权结果轮询:按 3s / 6s / 12s / 24s / 30s 阶梯间隔刷新列表与候选, + * 只读刷新(getList / listAuthCandidates),不触碰住宿数据。 + */ + scheduleBatchAuthRefresh() { + this.stopBatchAuthRefresh() + const delays = [3000, 6000, 12000, 24000, 30000] + const tick = index => { + if (index >= delays.length) { + // 阶梯轮询结束,本次授权流程结束:恢复「一键授权」按钮可点击 + this.batchAvatarDialog.authorizing = false + return + } + this.batchAvatarDialog.authPollTimer = setTimeout(() => { + this.batchAvatarDialog.authPollRound = index + 1 + this.getList() + this.loadAuthCandidates(true) + tick(index + 1) + }, delays[index]) + } + tick(0) + }, + /** + * 一键授权入口:提交后台异步任务,前端不等待执行结果。 + * + *

    职责边界:本方法只做「前端前置校验 + 提交任务 + 标记受理态 + 启动轮询」, + * 不做任何授权下发,也不读写「住宿安排」的 accRowState。 + * 逐人下发由后端批量授权任务完成,参照单条住宿安排的做法调用门禁入住 checkin 接口, + * 单人成功后回写 lock_result,页面通过轮询自动看到状态变化。

    + */ + submitBatchAuthorize() { + // 防重复授权:流程进行中(含后台轮询阶段)直接忽略后续点击 + if (this.batchAvatarDialog.authorizing) return + // 点击瞬间即置为不可用:按钮立即灰化并显示「授权进行中…」,不响应后续点击 + this.batchAvatarDialog.authorizing = true + const selectedIds = this.batchAvatarDialog.selectedStudentIds || [] + const rows = (this.batchAvatarDialog.candidates || []).filter(row => + selectedIds.some(id => this.studentKey(id) === this.studentKey(row.studentId)) + ) + if (!rows.length) { + this.batchAvatarDialog.authorizing = false + this.$modal.msgWarning('请先勾选需要授权的人员') + return + } + // 前端前置校验:不满足条件的行不提交,直接给出可读原因 + const preSkipped = [] + const runnable = [] + rows.forEach(row => { + const precheck = this.batchAuthPrecheck(row) + if (precheck.ok) { + runnable.push(row) + return + } + preSkipped.push({ + studentId: row.studentId, + studentName: row.studentName, + phone: row.phone, + status: 'skipped', + reason: precheck.reason + }) + }) + this.batchAvatarDialog.authResults = preSkipped + if (!runnable.length) { + this.batchAvatarDialog.authSummary = { accepted: 0, failed: 0, skipped: preSkipped.length } + this.batchAvatarDialog.authRunDone = true + const detail = preSkipped.slice(0, 3).map(item => `${item.studentName || item.phone}:${item.reason}`).join(';') + this.batchAvatarDialog.authorizing = false + this.$modal.msgWarning(`所选人员均不满足授权条件:${detail}`) + return + } + this.batchAvatarDialog.authRunDone = false + authorizeBatch(this.planId, runnable.map(row => row.studentId)).then(res => { + // 仅代表任务已受理,逐人 checkin 下发在后台线程池执行,前端不等待 + const accepted = Number((res && res.data) || runnable.length) || runnable.length + runnable.forEach(row => this.setBatchAuthRowState(row, 'pending')) + this.batchAvatarDialog.authSummary = { accepted: accepted, failed: 0, skipped: preSkipped.length } + this.batchAvatarDialog.authRunDone = true + this.$modal.msgSuccess((res && (res.msg || res.message)) || `已发起授权任务(${accepted} 人),后台执行中`) + // 提交成功:按钮保持禁用,直到轮询流程结束(scheduleBatchAuthRefresh 收尾时恢复) + this.scheduleBatchAuthRefresh() + }).catch(err => { + // 提交失败即流程结束:恢复按钮可点击 + this.batchAvatarDialog.authorizing = false + this.$modal.msgError(this.describeApiError(err, '提交一键授权任务失败')) + }) } } } @@ -2121,6 +3744,13 @@ export default { gap: 6px; white-space: nowrap; } +/* 导入结果说明列:宿舍未分配成功提示(红色醒目) */ +.import-unassigned-tip { + color: #f56c6c; + font-weight: 600; + font-size: 12px; + line-height: 18px; +} .dorm-cell-line ::v-deep .el-button { padding: 0; margin-left: 0; @@ -2158,6 +3788,43 @@ export default { background: #f5f7fa; overflow: hidden; } +/* 头像可点击:提示可打开「修改学员头像」弹窗 */ +.enroll-avatar-clickable { + cursor: pointer; +} +/* 修改头像弹窗:与学员主档修改弹窗的头像编辑区保持一致 */ +.avatar-edit { + display: flex; + align-items: center; +} +.avatar-edit-actions { + display: flex; + flex-direction: column; + margin-left: 20px; +} +.avatar-edit-actions .el-button + .el-button { + margin-left: 0; + margin-top: 8px; +} +/* 拍照弹窗:与学员主档修改中的拍照弹窗样式一致 */ +.camera-wrap { + width: 100%; + text-align: center; +} +.camera-video, +.camera-photo { + width: 100%; + max-height: 420px; + background: #000; + border-radius: 4px; + display: inline-block; +} +.camera-error { + margin-top: 12px; + color: #F56C6C; + font-size: 13px; + line-height: 1.6; +} .enroll-avatar-sm { width: 32px; height: 32px; @@ -2172,6 +3839,54 @@ export default { font-size: 12px; color: #909399; } +/* 批量上传头像/授权:分区标题、操作行与结果区 */ +.batch-auth-section { + margin-bottom: 14px; +} +.batch-auth-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; + font-weight: 600; +} +.batch-auth-hint { + font-weight: normal; + font-size: 12px; + color: #909399; +} +.batch-auth-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; + font-size: 12px; + color: #606266; +} +/* 上传提示:长文案多行展示,避免被裁切 */ +.batch-auth-tip { + line-height: 1.6; + white-space: normal; + text-align: left; +} +.batch-auth-result { + margin-top: 10px; +} +.batch-auth-subtitle { + margin: 10px 0 6px; + font-size: 12px; + color: #67C23A; +} +.batch-auth-subtitle-fail { + color: #F56C6C; +} +.batch-auth-count { + float: left; + line-height: 32px; + font-size: 12px; + color: #909399; +} .enroll-avatar ::v-deep .image-slot, .enroll-avatar-sm ::v-deep .image-slot { display: flex; @@ -2186,4 +3901,8 @@ export default { line-height: 1.5; white-space: normal; } +/* 导入模板列说明中的「(必填)」标记:统一标红提示必填项 */ +.import-required-mark { + color: #f56c6c; +} diff --git a/build_ui/src/views/train/student/index.vue b/build_ui/src/views/train/student/index.vue index a530f39e9..cfbbbdd41 100644 --- a/build_ui/src/views/train/student/index.vue +++ b/build_ui/src/views/train/student/index.vue @@ -253,6 +253,13 @@
    选择图片 拍照 + 移除头像
    @@ -627,7 +634,7 @@