学员管理增加岗位级别

This commit is contained in:
buliping committed 2026-09-29 10:13:47 +08:00
1 parent 86c04320fd
commit 2c14bf2b41
5 files changed
+319 -3

No files matched your search

@@ -5,7 +5,6 @@ import java.util.regex.Pattern;
import javax.validation.Valid;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.train.domain.TrainTeacher;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import com.github.pagehelper.PageInfo;
@@ -0,0 +1,36 @@
package com.ruoyi.train.domain.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 学员手工修改请求。
*
* <p>复用 {@link StudentCreateDTO} 的可编辑字段与校验规则,额外要求携带学员主键 sId。
* 身份证号沿用「只写不读」策略:未提交时保留库内既有密文,不做清空。</p>
*/
public class StudentUpdateDTO extends StudentCreateDTO
{
private static final long serialVersionUID = 1L;
@NotBlank(message = "学员ID不能为空")
@Size(max = 64, message = "学员ID长度不能超过64个字符")
private String sId;
public String getsId()
{
return sId;
}
public void setsId(String sId)
{
this.sId = sId;
}
@Override
public String toString()
{
return "StudentUpdateDTO{sId='" + sId + "', sName='" + getsName()
+ "', idCardProvided=" + (getIdCard() != null) + "}";
}
}
@@ -101,6 +101,7 @@ public class TrainStudentServiceImpl implements ITrainStudentService
student.setsPhone(trimToNull(dto.getsPhone()));
student.setsEmail(trimToNull(dto.getsEmail()));
student.setAvatar(trimToNull(dto.getAvatar()));
student.setDutyLevel(dto.getDutyLevel());
if (normalizedIdCard != null)
{
+49 -2
View File
@@ -175,10 +175,18 @@
<el-input v-model="createForm.sName" placeholder="请输入学员姓名" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="性别" prop="sGender">
<el-radio-group v-model="createForm.sGender">
<!-- <el-radio-group v-model="createForm.sGender">
<el-radio label="男">男</el-radio>
<el-radio label="女">女</el-radio>
</el-radio-group>
</el-radio-group>-->
<el-select v-model="createForm.sGender" placeholder="请选择性别">
<el-option
v-for="dict in dict.type.sys_user_sex"
:key="dict.value"
:label="dict.label"
:value="dict.value"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="出生日期" prop="sBirth">
<el-date-picker
@@ -193,6 +201,16 @@
<!-- <el-input v-model="createForm.orgName" placeholder="请输入所属单位/部门" maxlength="100" />-->
<treeselect v-model="createForm.orgName" :options="deptOptions" :normalizer="normalizer" :show-count="true" placeholder="请选择归属部门" />
</el-form-item>
<el-form-item label="岗位级别" prop="dutyLevel">
<el-select v-model="createForm.dutyLevel" placeholder="请选择岗位级别" clearable style="width: 100%">
<el-option
v-for="dict in dutyLevelOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="联系电话" prop="sPhone">
<el-input v-model="createForm.sPhone" placeholder="请输入联系电话" maxlength="20" />
</el-form-item>
@@ -240,6 +258,10 @@
<el-tag v-else type="info" size="mini">未关联</el-tag>
</el-form-item>
<el-form-item label="所属单位"><span>{{ detail.orgName || '-' }}</span></el-form-item>
<el-form-item label="岗位级别">
<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.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>
@@ -322,6 +344,8 @@ import {addTeacher, updateTeacher} from "@/api/train/teacher";
export default {
name: 'Student',
components: { studentAvatar, Treeselect },
// 岗位级别字典(train_duty_level),注入后可通过 dict.type.train_duty_level 访问
dicts: ['train_duty_level', 'sys_user_sex'],
data() {
const validateIdCard = (rule, value, callback) => {
if (!value || value.trim() === '') {
@@ -382,6 +406,20 @@ export default {
}
}
},
computed: {
/*
* 岗位级别下拉选项:取自字典 train_duty_level。
* 后端 duty_level 为数值型,字典 dictValue 为字符串,
* 此处将纯数字的字典值转为 Number,保证下拉回显时类型一致。
*/
dutyLevelOptions() {
const list = (this.dict && this.dict.type && this.dict.type.train_duty_level) || []
return list.map(item => ({
label: item.label,
value: /^\d+$/.test(String(item.value)) ? Number(item.value) : item.value
}))
}
},
created() {
this.resetCreate()
this.mapDept()
@@ -470,6 +508,7 @@ export default {
sGender: row.sGender || '男',
sBirth: row.sBirth || null,
orgName: this.toDeptValue(row.orgName),
dutyLevel: row.dutyLevel !== undefined && row.dutyLevel !== null ? this.toDutyLevelValue(row.dutyLevel) : null,
sPhone: row.sPhone || null,
sEmail: row.sEmail || null,
idCard: null,
@@ -480,6 +519,13 @@ export default {
this.user = { avatar: this.createForm.avatar || '' }
this.createOpen = true
},
/** 岗位级别值归一化:数字字符串转 Number,保证与下拉选项 value 类型一致 */
toDutyLevelValue(value) {
if (value === null || value === undefined || value === '') {
return null
}
return /^\d+$/.test(String(value)) ? Number(value) : value
},
/** 部门值归一化:数字字符串转 Number,保证与部门树选项 id 类型一致 */
toDeptValue(value) {
if (value === null || value === undefined || value === '') {
@@ -494,6 +540,7 @@ export default {
sGender: '男',
sBirth: null,
orgName: null,
dutyLevel: null,
sPhone: null,
sEmail: null,
idCard: null,
@@ -0,0 +1,233 @@
<template>
<div>
<div class="user-info-head" @click="editCropper()"><img :src="options.img" title="点击上传头像" class="img-circle img-lg" @error="handleAvatarError"></div>
<el-dialog :title="title" :visible.sync="open" width="800px" append-to-body @opened="modalOpened" @close="closeDialog">
<el-row>
<el-col :xs="24" :md="12" :style="{height: '350px'}">
<vue-cropper
v-if="visible"
ref="cropper"
:img="options.img"
:info="true"
:auto-crop="options.autoCrop"
:auto-crop-width="options.autoCropWidth"
:auto-crop-height="options.autoCropHeight"
:fixed-box="options.fixedBox"
:output-type="options.outputType"
@realTime="realTime"
/>
</el-col>
<el-col :xs="24" :md="12" :style="{height: '350px'}">
<div class="avatar-upload-preview">
<img :src="previews.url" :style="previews.img">
</div>
</el-col>
</el-row>
<br>
<el-row>
<el-col :lg="2" :sm="3" :xs="3">
<el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload">
<el-button size="small">
选择
<i class="el-icon-upload el-icon--right" />
</el-button>
</el-upload>
</el-col>
<el-col :lg="{span: 1, offset: 2}" :sm="2" :xs="2">
<el-button icon="el-icon-plus" size="small" @click="changeScale(1)" />
</el-col>
<el-col :lg="{span: 1, offset: 1}" :sm="2" :xs="2">
<el-button icon="el-icon-minus" size="small" @click="changeScale(-1)" />
</el-col>
<el-col :lg="{span: 1, offset: 1}" :sm="2" :xs="2">
<el-button icon="el-icon-refresh-left" size="small" @click="rotateLeft()" />
</el-col>
<el-col :lg="{span: 1, offset: 1}" :sm="2" :xs="2">
<el-button icon="el-icon-refresh-right" size="small" @click="rotateRight()" />
</el-col>
<el-col :lg="{span: 2, offset: 6}" :sm="2" :xs="2">
<el-button type="primary" size="small" @click="uploadImg()">提 交</el-button>
</el-col>
</el-row>
</el-dialog>
</div>
</template>
<script>
import { VueCropper } from 'vue-cropper'
import request from '@/utils/request'
import { debounce } from '@/utils'
// 默认头像
const DEFAULT_AVATAR = require('@/assets/images/profile.jpg')
export default {
components: { VueCropper },
props: {
user: {
type: Object,
default: () => ({})
}
},
data() {
return {
// 是否显示弹出层
open: false,
// 是否显示cropper
visible: false,
// 弹出层标题
title: '修改头像',
options: {
img: DEFAULT_AVATAR, // 裁剪图片的地址
autoCrop: true, // 是否默认生成截图框
autoCropWidth: 200, // 默认生成截图框宽度
autoCropHeight: 200, // 默认生成截图框高度
fixedBox: true, // 固定截图框大小 不允许改变
outputType: 'png' // 默认生成截图为PNG格式
},
previews: {},
resizeHandler: null,
// 当前生效的头像地址(本地上传成功后优先于此值,避免被关闭弹窗重置)
currentAvatar: ''
}
},
watch: {
// 跟随传入学员的头像地址变化
'user.avatar': {
immediate: true,
handler(val) {
this.currentAvatar = val || ''
this.options.img = this.resolveAvatar(val)
}
}
},
methods: {
// 头像地址解析:相对路径补全 baseApi,空值使用默认头像
// withStamp=true 时追加时间戳,强制浏览器重新加载,避免缓存导致图片不刷新
resolveAvatar(avatar, withStamp) {
if (!avatar) {
return DEFAULT_AVATAR
}
if (avatar.indexOf('http') === 0 || avatar.indexOf('data:') === 0) {
return avatar
}
let url = process.env.VUE_APP_BASE_API + avatar
if (withStamp) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 't=' + new Date().getTime()
}
return url
},
handleAvatarError() {
this.options.img = DEFAULT_AVATAR
},
// 编辑头像
editCropper() {
this.open = true
},
// 打开弹出层结束时的回调
modalOpened() {
this.visible = true
if (!this.resizeHandler) {
this.resizeHandler = debounce(() => {
this.refresh()
}, 100)
}
window.addEventListener('resize', this.resizeHandler)
},
// 刷新组件
refresh() {
this.$refs.cropper.refresh()
},
// 覆盖默认的上传行为
requestUpload() {
},
// 向左旋转
rotateLeft() {
this.$refs.cropper.rotateLeft()
},
// 向右旋转
rotateRight() {
this.$refs.cropper.rotateRight()
},
// 图片缩放
changeScale(num) {
num = num || 1
this.$refs.cropper.changeScale(num)
},
// 上传预处理
beforeUpload(file) {
if (file.type.indexOf('image/') === -1) {
this.$modal.msgError('文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。')
} else {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
this.options.img = reader.result
}
}
},
// 上传图片:走通用上传 /common/upload,只存文件不修改 sys_user.avatar
uploadImg() {
this.$refs.cropper.getCropBlob(data => {
const formData = new FormData()
// 使用带文件名的 File,保证后端能识别后缀
const uploadFile = new File([data], 'avatar.png', { type: 'image/png' })
formData.append('file', uploadFile)
request({
url: '/common/upload',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => {
// 入库用相对路径,如 /profile/upload/2026/09/28/xxx.png
const imgUrl = response.fileName || response.url
// 记录当前生效头像,防止弹窗关闭时被旧值覆盖
this.currentAvatar = imgUrl
// 追加时间戳强制刷新,避免浏览器缓存
this.options.img = this.resolveAvatar(imgUrl, true)
// 先抛给父组件更新 user.avatar,再关闭弹窗
this.$emit('success', imgUrl)
this.$modal.msgSuccess('上传成功')
this.open = false
this.visible = false
})
})
},
// 实时预览
realTime(data) {
this.previews = data
},
// 关闭窗口:优先使用本地已上传的头像,其次取父组件传入的 user.avatar
closeDialog() {
this.options.img = this.resolveAvatar(this.currentAvatar || (this.user && this.user.avatar))
this.visible = false
window.removeEventListener('resize', this.resizeHandler)
}
}
}
</script>
<style scoped lang="scss">
.user-info-head {
position: relative;
display: inline-block;
height: 120px;
}
.user-info-head:hover:after {
content: '+';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #eee;
background: rgba(0, 0, 0, 0.5);
font-size: 24px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: pointer;
line-height: 110px;
border-radius: 50%;
}
</style>