学员、教师支持浏览器拍照、上传图片两种方式更新头像;学员信息关联所属班级、开班时间、宿舍等信息;
This commit is contained in:
1 parent
c26ddbb262
commit
5749c333e5
2 files changed
+646
-9
No files matched your search
@@ -87,13 +87,18 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="所属单位" align="center" prop="orgName" min-width="140" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="联系电话" align="center" prop="sPhone" width="130" />
|
||||
<!-- <el-table-column label="联系电话" align="center" prop="sPhone" width="130" />
|
||||
<el-table-column label="邮箱" align="center" prop="sEmail" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="身份证号" align="center" prop="idCardMasked" min-width="190" />
|
||||
<el-table-column label="来源" align="center" prop="sourceType" width="100">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ sourceTypeLabel(scope.row.sourceType) }}</span>
|
||||
</template>
|
||||
</el-table-column>-->
|
||||
<el-table-column label="所属班级" align="center" min-width="140" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.className || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" prop="status" width="80">
|
||||
<template slot-scope="scope">
|
||||
@@ -170,7 +175,13 @@
|
||||
<el-dialog :title="createTitle" :visible.sync="createOpen" width="560px" append-to-body @closed="resetCreate">
|
||||
<el-form ref="createForm" :model="createForm" :rules="rules" label-width="90px">
|
||||
<el-form-item label="学员头像" prop="avatar">
|
||||
<studentAvatar :user="{ avatar: createForm.avatar }" @success="handleAvatarSuccess" />
|
||||
<div class="avatar-edit">
|
||||
<studentAvatar ref="studentAvatar" :user="{ avatar: createForm.avatar }" @success="handleAvatarSuccess" />
|
||||
<div class="avatar-edit-actions">
|
||||
<el-button size="mini" type="primary" icon="el-icon-picture" @click="handleSelectAvatar">选择图片</el-button>
|
||||
<el-button size="mini" icon="el-icon-camera" :loading="cameraStarting" @click="openCamera">拍照</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="学员姓名" prop="sName">
|
||||
<el-input v-model="createForm.sName" placeholder="请输入学员姓名" maxlength="50" show-word-limit />
|
||||
@@ -240,17 +251,37 @@
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 拍照:调用浏览器摄像头拍摄学员头像 -->
|
||||
<el-dialog title="拍照" :visible.sync="cameraOpen" width="640px" append-to-body @opened="handleCameraOpened" @close="closeCamera">
|
||||
<div class="camera-wrap">
|
||||
<video v-show="!cameraPhoto" ref="cameraVideo" class="camera-video" autoplay playsinline muted />
|
||||
<canvas ref="cameraCanvas" style="display: none;" />
|
||||
<img v-if="cameraPhoto" :src="cameraPhoto" class="camera-photo" alt="拍摄照片" />
|
||||
<div v-if="cameraError" class="camera-error">{{ cameraError }}</div>
|
||||
</div>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button size="mini" icon="el-icon-refresh" :disabled="!!cameraPhoto" @click="switchCamera">切换摄像头</el-button>
|
||||
<el-button v-if="!cameraPhoto" type="primary" size="mini" icon="el-icon-camera-solid" @click="takePhoto">拍 摄</el-button>
|
||||
<el-button v-if="cameraPhoto" size="mini" icon="el-icon-refresh-left" @click="retakePhoto">重 拍</el-button>
|
||||
<el-button v-if="cameraPhoto" type="primary" size="mini" :loading="cameraUploading" @click="submitCameraPhoto">使用该照片</el-button>
|
||||
<el-button size="mini" @click="cameraOpen = false">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="学员主档详情" :visible.sync="detailOpen" width="560px" append-to-body>
|
||||
<el-form :model="detail" label-width="110px" class="student-detail">
|
||||
<el-form-item label="学员头像">
|
||||
<!-- 与新增/修改窗口一致的圆形头像预览,仅展示不可上传,点击可放大预览 -->
|
||||
<el-image
|
||||
v-if="detail.avatar"
|
||||
:src="avatarUrl(detail.avatar)"
|
||||
:preview-src-list="[avatarUrl(detail.avatar)]"
|
||||
fit="cover"
|
||||
class="student-avatar"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
class="student-detail-avatar"
|
||||
>
|
||||
<div slot="error" class="image-slot">
|
||||
<i class="el-icon-picture-outline" />
|
||||
</div>
|
||||
</el-image>
|
||||
</el-form-item>
|
||||
<el-form-item label="学员姓名"><span>{{ detail.sName || '-' }}</span></el-form-item>
|
||||
<el-form-item label="系统编号"><span>{{ detail.sId || '-' }}</span></el-form-item>
|
||||
@@ -263,6 +294,9 @@
|
||||
<dict-tag v-if="detail.dutyLevel !== null && detail.dutyLevel !== undefined" :options="dict.type.train_duty_level" :value="detail.dutyLevel" />
|
||||
<span v-else>-</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属班级"><span>{{ detail.className || '-' }}</span></el-form-item>
|
||||
<el-form-item label="开班时间"><span>{{ detail.classStartTime || '-' }}</span></el-form-item>
|
||||
<el-form-item label="分配宿舍位置"><span>{{ detail.dormLocation || '-' }}</span></el-form-item>
|
||||
<el-form-item label="联系电话"><span>{{ detail.sPhone || '-' }}</span></el-form-item>
|
||||
<el-form-item label="邮箱"><span>{{ detail.sEmail || '-' }}</span></el-form-item>
|
||||
<el-form-item label="身份证号"><span>{{ detail.idCardMasked || '-' }}</span></el-form-item>
|
||||
@@ -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;
|
||||
|
||||
@@ -87,8 +87,9 @@
|
||||
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="teacherList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table v-loading="loading" :data="teacherList">
|
||||
<!-- @selection-change="handleSelectionChange"-->
|
||||
<!-- <el-table-column type="selection" width="55" align="center" />-->
|
||||
<el-table-column label="头像" align="center" width="80">
|
||||
<template slot-scope="scope">
|
||||
<el-image
|
||||
@@ -190,7 +191,13 @@
|
||||
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="教师头像" prop="avatar">
|
||||
<studentAvatar :user="user" @success="handleAvatarSuccess" />
|
||||
<div class="avatar-edit">
|
||||
<studentAvatar ref="studentAvatar" :user="user" @success="handleAvatarSuccess" />
|
||||
<div class="avatar-edit-actions">
|
||||
<el-button size="mini" type="primary" icon="el-icon-picture" @click="handleSelectAvatar">选择图片</el-button>
|
||||
<el-button size="mini" icon="el-icon-camera" :loading="cameraStarting" @click="openCamera">拍照</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="教师姓名" prop="tName">
|
||||
<el-input v-model="form.tName" placeholder="请输入教师姓名" maxlength="50" show-word-limit />
|
||||
@@ -254,6 +261,23 @@
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 拍照:调用浏览器摄像头拍摄教师头像 -->
|
||||
<el-dialog title="拍照" :visible.sync="cameraOpen" width="640px" append-to-body @opened="handleCameraOpened" @close="closeCamera">
|
||||
<div class="camera-wrap">
|
||||
<video v-show="!cameraPhoto" ref="cameraVideo" class="camera-video" autoplay playsinline muted />
|
||||
<canvas ref="cameraCanvas" style="display: none;" />
|
||||
<img v-if="cameraPhoto" :src="cameraPhoto" class="camera-photo" alt="拍摄照片" />
|
||||
<div v-if="cameraError" class="camera-error">{{ cameraError }}</div>
|
||||
</div>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button size="mini" icon="el-icon-refresh" :disabled="!!cameraPhoto" @click="switchCamera">切换摄像头</el-button>
|
||||
<el-button v-if="!cameraPhoto" type="primary" size="mini" icon="el-icon-camera-solid" @click="takePhoto">拍 摄</el-button>
|
||||
<el-button v-if="cameraPhoto" size="mini" icon="el-icon-refresh-left" @click="retakePhoto">重 拍</el-button>
|
||||
<el-button v-if="cameraPhoto" type="primary" size="mini" :loading="cameraUploading" @click="submitCameraPhoto">使用该照片</el-button>
|
||||
<el-button size="mini" @click="cameraOpen = false">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 关联系统账号对话框 -->
|
||||
<el-dialog title="关联系统账号 (教师业务档案)" :visible.sync="bindingOpen" width="700px" append-to-body>
|
||||
<el-alert
|
||||
@@ -324,6 +348,7 @@ import "@riophae/vue-treeselect/dist/vue-treeselect.css";
|
||||
import {listDept} from "@/api/system/dept";
|
||||
// 复用学员头像上传组件(通用:props.user 传入 {avatar},上传成功后抛出 success 事件)
|
||||
import studentAvatar from '@/views/train/student/studentAvatar'
|
||||
import request from '@/utils/request'
|
||||
export default {
|
||||
name: 'Teacher',
|
||||
components: { Treeselect, studentAvatar },
|
||||
@@ -368,6 +393,20 @@ export default {
|
||||
deptArr: {},
|
||||
// 头像上传组件用的教师对象(avatar 为入库的相对地址)
|
||||
user: { avatar: '' },
|
||||
// 拍照弹窗
|
||||
cameraOpen: false,
|
||||
// 当前摄像头视频流
|
||||
cameraStream: null,
|
||||
// 摄像头是否正在启动
|
||||
cameraStarting: false,
|
||||
// 拍摄后的照片 DataURL
|
||||
cameraPhoto: '',
|
||||
// 摄像头前置/后置
|
||||
cameraFacing: 'user',
|
||||
// 摄像头不可用时的提示
|
||||
cameraError: '',
|
||||
// 拍摄照片上传中
|
||||
cameraUploading: false,
|
||||
form: {},
|
||||
// 表单校验
|
||||
rules: {
|
||||
@@ -475,6 +514,170 @@ export default {
|
||||
this.user = { avatar: '' }
|
||||
this.resetForm('form')
|
||||
},
|
||||
/*
|
||||
* “选择图片”:与点击头像图片效果一致,打开头像裁剪/上传弹窗
|
||||
* (弹窗内的“选择”按钮可挑选本地图片,提交后写入 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) {
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user