学员、教师支持浏览器拍照、上传图片两种方式更新头像;学员信息关联所属班级、开班时间、宿舍等信息;

This commit is contained in:
buliping committed 2026-10-09 14:41:17 +08:00
1 parent c26ddbb262
commit 5749c333e5
2 files changed
+646 -9

No files matched your search

+403 -6
View File
@@ -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;
+243 -3
View File
@@ -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>