diff --git a/build_ui/src/views/train/student/index.vue b/build_ui/src/views/train/student/index.vue
index 1ef8cae41..bbaf69bbe 100644
--- a/build_ui/src/views/train/student/index.vue
+++ b/build_ui/src/views/train/student/index.vue
@@ -87,13 +87,18 @@
-
+
+
+
+ {{ scope.row.className || '-' }}
+
@@ -170,7 +175,13 @@
-
+
@@ -240,17 +251,37 @@
+
+
+
+
+
+
![拍摄照片]()
+
{{ cameraError }}
+
+
+
+
+
- -
+ class="student-detail-avatar"
+ >
+
+
+
+
{{ detail.sName || '-' }}
{{ detail.sId || '-' }}
@@ -263,6 +294,9 @@
-
+ {{ detail.className || '-' }}
+ {{ detail.classStartTime || '-' }}
+ {{ detail.dormLocation || '-' }}
{{ detail.sPhone || '-' }}
{{ detail.sEmail || '-' }}
{{ detail.idCardMasked || '-' }}
@@ -342,6 +376,9 @@ import "@riophae/vue-treeselect/dist/vue-treeselect.css";
import {listDept} from "@/api/system/dept";
import studentAvatar from "./studentAvatar.vue";
import {addTeacher, updateTeacher} from "@/api/train/teacher";
+import {listPlan} from "@/api/train/plan";
+import {listPlanEnrollments} from "@/api/train/enrollment";
+import {getActiveAccommodation} from "@/api/train/accommodation";
export default {
name: 'Student',
components: { studentAvatar, Treeselect },
@@ -390,6 +427,24 @@ export default {
// 部门树选项
deptOptions: undefined,
deptArr: {},
+ // 学员所属班级映射:学员主档ID -> 所属开班计划数组 [{planId, planName, startTime, enrollmentId}]
+ planClassMap: {},
+ // 开班计划参训名单是否已加载(只需加载一次)
+ planClassLoaded: false,
+ // 拍照弹窗
+ cameraOpen: false,
+ // 当前摄像头视频流
+ cameraStream: null,
+ // 摄像头是否正在启动
+ cameraStarting: false,
+ // 拍摄后的照片 DataURL
+ cameraPhoto: '',
+ // 摄像头前置/后置
+ cameraFacing: 'user',
+ // 摄像头不可用时的提示
+ cameraError: '',
+ // 拍摄照片上传中
+ cameraUploading: false,
createForm: {},
// 头像上传组件用的学员对象(avatar 为入库的相对地址)
user: { avatar: '' },
@@ -440,11 +495,101 @@ export default {
//console.log(stud.orgName +"--"+ studOrgName);
this.$set(stud, "orgName", studOrgName);
});
+ // 所属班级取自开班计划的参训人员名单,补充后再回写列表
+ return this.loadPlanClasses();
+ }).then(() => {
+ this.applyClassNames();
}).finally(() => {
this.loading = false
})
},
+ /*
+ * 加载“所属班级”数据:
+ * 1) 查询全部开班计划,取计划ID与计划名称;
+ * 2) 逐个计划查询参训人员名单(参训记录中包含学员主档ID studentId);
+ * 3) 建立 studentId -> 所属开班计划数组 的映射(一名学员可属于多个班级)。
+ * 只加载一次并缓存;无权限或接口异常时静默失败,不影响学员列表展示。
+ */
+ loadPlanClasses() {
+ if (this.planClassLoaded) {
+ return Promise.resolve()
+ }
+ return listPlan({ pageNum: 1, pageSize: 1000 }).then(response => {
+ const plans = response.rows || []
+ const map = {}
+ return Promise.all(plans.map(plan => {
+ return listPlanEnrollments(plan.planId, { pageNum: 1, pageSize: 1000 }).then(res => {
+ (res.rows || []).forEach(item => {
+ const studentId = item.studentId
+ // 参训状态 3=放弃(退训),不再计入所属班级
+ if (studentId === null || studentId === undefined || studentId === '' || item.enrollmentStatus === 3) {
+ return
+ }
+ const key = String(studentId)
+ if (!map[key]) {
+ map[key] = []
+ }
+ if (map[key].some(p => p.planId === plan.planId)) {
+ return
+ }
+ map[key].push({
+ planId: plan.planId,
+ planName: plan.planName,
+ startTime: plan.startTime,
+ enrollmentId: item.enrollmentId
+ })
+ })
+ }).catch(() => {})
+ })).then(() => {
+ this.planClassMap = map
+ this.planClassLoaded = true
+ })
+ }).catch(() => {})
+ },
+ /** 把班级名称回写到学员列表行 */
+ applyClassNames() {
+ const map = this.planClassMap || {}
+ this.studentList.forEach(stud => {
+ this.$set(stud, "className", (map[String(stud.sId)] || []).map(p => p.planName).join('、'));
+ })
+ },
+ /** 取学员所属开班计划列表 */
+ getStudentPlans(studentId) {
+ return (this.planClassMap || {})[String(studentId)] || []
+ },
+ /** 多个所属班级名称(以“、”连接) */
+ formatClassNames(plans) {
+ return (plans || []).map(p => p.planName).filter(name => !!name).join('、')
+ },
+ /** 多个开班时间(yyyy-MM-dd,以“、”连接) */
+ formatClassStartTimes(plans) {
+ return (plans || []).map(p => this.formatDate(p.startTime)).filter(time => !!time).join('、')
+ },
+ /** 时间只取日期部分 */
+ formatDate(value) {
+ if (!value) {
+ return ''
+ }
+ return String(value).substring(0, 10)
+ },
+ /** 宿舍位置文案:楼栋-房间号-床位 */
+ formatDorm(accommodation) {
+ if (!accommodation) {
+ return ''
+ }
+ const parts = []
+ if (accommodation.dormBuilding) {
+ parts.push(accommodation.dormBuilding)
+ }
+ if (accommodation.dormRoom) {
+ parts.push(accommodation.dormRoom)
+ }
+ const location = parts.join('-')
+ const bedNo = accommodation.bedNo || ''
+ const dormId = accommodation.dormId || ''
+ return [location || dormId, bedNo].filter(item => !!item).join(' ')
+ },
/** 查询部门下拉树结构 */
getDeptTree() {
deptTreeSelect().then(response => {
@@ -559,6 +704,171 @@ export default {
}
},
// 接收子组件上传成功抛出的头像地址:写入新增表单,随其他学员信息一并提交入库
+ /*
+ * “选择图片”:与点击头像图片效果一致,打开头像裁剪/上传弹窗
+ * (弹窗内的“选择”按钮可挑选本地图片,提交后写入 form.avatar)
+ */
+ handleSelectAvatar() {
+ if (this.$refs.studentAvatar) {
+ this.$refs.studentAvatar.editCropper()
+ }
+ },
+ /** 打开拍照弹窗 */
+ 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 并回填学员头像 */
+ 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
+ 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.handleAvatarSuccess(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 = ''
+ },
handleAvatarSuccess(imgUrl) {
if (!imgUrl) {
return
@@ -626,7 +936,37 @@ export default {
this.detail = response.data || {}
//console.log(this.deptArr);
this.$set(this.detail, "orgName", this.formatOrgName(this.detail.orgName));
+ // 详情接口可能未返回头像,兜底使用列表行数据中的头像地址
+ if (!this.detail.avatar && row.avatar) {
+ this.$set(this.detail, "avatar", row.avatar)
+ }
this.detailOpen = true
+ // 所属班级与开班时间来自开班计划的参训名单;宿舍来自住宿分配记录
+ const studentId = row.sId
+ return this.loadPlanClasses().then(() => {
+ return this.loadDetailExtra(studentId)
+ })
+ }).catch(() => {})
+ },
+ /** 详情补充:所属班级、开班时间、分配宿舍位置 */
+ loadDetailExtra(studentId) {
+ const plans = this.getStudentPlans(studentId)
+ this.$set(this.detail, 'className', this.formatClassNames(plans))
+ this.$set(this.detail, 'classStartTime', this.formatClassStartTimes(plans))
+ const enrollmentIds = plans.map(item => item.enrollmentId).filter(id => id !== null && id !== undefined)
+ if (enrollmentIds.length === 0) {
+ this.$set(this.detail, 'dormLocation', '')
+ return Promise.resolve()
+ }
+ this.$set(this.detail, 'dormLocation', '加载中...')
+ return Promise.all(enrollmentIds.map(id => {
+ return getActiveAccommodation(id).then(res => res.data).catch(() => null)
+ })).then(list => {
+ // 详情可能已被切换到其他学员,避免串数据
+ if (String(this.detail.sId) !== String(studentId)) {
+ return
+ }
+ this.$set(this.detail, 'dormLocation', list.map(item => this.formatDorm(item)).filter(text => !!text).join('、'))
})
},
/** 打开关联系统账号对话框 */
@@ -735,6 +1075,63 @@ export default {
background: #f5f7fa;
}
+.student-detail-avatar {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ vertical-align: middle;
+ overflow: hidden;
+ background: #f5f7fa;
+ cursor: pointer;
+}
+
+.avatar-edit {
+ display: flex;
+ align-items: center;
+}
+
+.avatar-edit-actions {
+ display: flex;
+ flex-direction: column;
+ margin-left: 20px;
+
+ .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;
+}
+
+.student-detail-avatar .image-slot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ color: #c0c4cc;
+ font-size: 28px;
+}
+
.student-avatar .image-slot {
display: flex;
align-items: center;
diff --git a/build_ui/src/views/train/teacher/index.vue b/build_ui/src/views/train/teacher/index.vue
index 978694e2c..91f8c0460 100644
--- a/build_ui/src/views/train/teacher/index.vue
+++ b/build_ui/src/views/train/teacher/index.vue
@@ -87,8 +87,9 @@
-
-
+
+
+
-
+
@@ -254,6 +261,23 @@
+
+
+
+
+
+
![拍摄照片]()
+
{{ cameraError }}
+
+
+
+
{
+ 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 并回填教师头像 */
+ 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
+ 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.handleAvatarSuccess(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 = ''
+ },
/** 头像上传成功回调:相对地址写入表单,随教师信息一并提交 */
handleAvatarSuccess(imgUrl) {
if (!imgUrl) {
@@ -653,4 +856,41 @@ export default {
color: #c0c4cc;
font-size: 18px;
}
+
+.avatar-edit {
+ display: flex;
+ align-items: center;
+}
+
+.avatar-edit-actions {
+ display: flex;
+ flex-direction: column;
+ margin-left: 20px;
+
+ .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;
+}