kuaifan 20b5daba50 feat(manage): 团队管理支持管理员创建/批量导入员工账号(含部门、职位)
单个创建:邮箱/昵称/初始密码 + 可选首登改密、职位、部门(多选,选子部门自动补选上级,并加入对应部门群)。

批量导入:上传 Excel/CSV → 预览逐行校验 → 确认后导入。职位为模板第4列(选填,逐行解析校验),部门在预览表按行勾选后由底部设置部门到选中写入;导入按行返回结果(全成功关弹窗+成功提示;含失败留弹窗显示失败明细;仅 success>0 才刷新列表)。

后端:User::createByAdmin 选项数组化 + 校验助手 assertValidProfession/assertValidDepartments;importUsers 逐行 department/profession;UsersController createuser/import;UserImport/UserImportTemplate(含职位列)。

测试:tests/Feature/AdminCreateUserTest、tests/Unit/UserImportParseTest。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 01:26:34 +00:00

532 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Modal
v-model="visible"
:title="$L('编辑用户信息')"
:mask-closable="false"
width="560">
<Form :model="formData" v-bind="formOptions" @submit.native.prevent>
<Alert type="warning" style="margin-bottom:18px">
{{ $L(`正在编辑帐号【ID:${userData.userid}, ${userData.nickname}】的信息。`) }}
</Alert>
<FormItem :label="$L('昵称')">
<Input
v-model="formData.nickname"
:maxlength="20"
:placeholder="$L('请输入昵称')"/>
</FormItem>
<FormItem :label="$L('电话')">
<Input
v-model="formData.tel"
:placeholder="$L('请输入电话号码')"/>
</FormItem>
<FormItem :label="$L('职位')">
<Input
v-model="formData.profession"
:maxlength="20"
:placeholder="$L('请输入职位/职称')"/>
</FormItem>
<FormItem :label="$L('邮箱')">
<Input
v-model="formData.email"
:placeholder="$L('请输入邮箱地址')"
:disabled="isLdapUser"/>
<div v-if="isLdapUser" class="form-tip">
{{ $L('LDAP 用户禁止修改邮箱') }}
</div>
</FormItem>
<FormItem :label="$L('新密码')">
<Input
v-model="formData.password"
type="password"
password
:placeholder="$L('留空则不修改密码')"/>
<Checkbox v-if="formData.password" v-model="formData.changepass" style="margin-top:8px">
{{ $L('员工下次登录需修改密码') }}
</Checkbox>
</FormItem>
<FormItem :label="$L('所属部门')">
<Select
v-model="formData.department"
multiple
:multiple-max="10"
:multiple-max-before="onMultipleMaxBefore"
:placeholder="$L('留空为默认部门')">
<Option
v-for="(item, index) in departmentList"
:value="item.id"
:key="index"
:label="item.chains.join(' - ')">
<div :class="`department-level-name level-${item.level - 1}`">{{ item.name }}</div>
</Option>
</Select>
</FormItem>
<FormItem :label="$L('个人简介')">
<Input
v-model="formData.introduction"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 6 }"
:maxlength="500"
:placeholder="$L('请输入个人简介')"/>
</FormItem>
<FormItem :label="$L('个性标签')">
<div class="user-tags-preview">
<template v-if="personalTags.length">
<div
v-for="tag in personalTags"
:key="tag.id"
class="tag-pill"
:class="{'is-recognized': tag.recognized}"
@click="openTagModal">
{{ tag.name }}
<span v-if="tag.recognition_total > 0">{{ tag.recognition_total }}</span>
</div>
</template>
<span v-else class="tags-empty">{{ $L('暂无个性标签') }}</span>
<span v-if="personalTagTotal > personalTags.length" class="tags-total">{{ $L('(*)', personalTagTotal) }}</span>
<Button type="text" size="small" class="manage-button" @click.stop="openTagModal">
<Icon type="md-create"/>
{{ $L('管理') }}
</Button>
</div>
</FormItem>
<template v-if="checkinMode">
<FormItem :label="$L('人脸图片')" class="checkin-field">
<ImgUpload v-model="formData.faceimg" :num="1" :width="512" :height="512" whcut="cover"/>
<div class="form-tip">{{ $L('建议尺寸500x500') }}</div>
</FormItem>
<FormItem :label="$L('MAC地址')" class="checkin-field">
<Row class="checkin-mac-header">
<Col span="11">{{ $L('设备MAC地址') }}</Col>
<Col span="11">{{ $L('备注') }}</Col>
<Col span="2"></Col>
</Row>
<Row
v-for="(item, key) in formData.checkin_macs"
:key="key"
class="checkin-mac-item">
<Col span="11">
<Input
v-model="item.mac"
:maxlength="20"
:placeholder="$L('请输入设备MAC地址')"/>
</Col>
<Col span="11">
<Input v-model="item.remark" :maxlength="100" :placeholder="$L('备注')"/>
</Col>
<Col span="2" class="checkin-mac-del">
<Icon type="md-close" @click="delCheckinMac(key)"/>
</Col>
</Row>
<Button type="default" icon="md-add" @click="addCheckinMac">
{{ $L('添加设备') }}
</Button>
</FormItem>
</template>
</Form>
<UserTagsModal
v-if="userData.userid"
v-model="tagModalVisible"
:userid="userData.userid"
@updated="onTagsUpdated"/>
<div slot="footer" class="adaption">
<Button type="default" @click="visible = false">{{ $L('取消') }}</Button>
<Button type="primary" :loading="loading" @click="handleSave">{{ $L('保存') }}</Button>
</div>
</Modal>
</template>
<script>
import ImgUpload from "../../../components/ImgUpload.vue";
import UserTagsModal from "./UserTagsModal.vue";
import {mapState} from "vuex";
export default {
name: "UserEditModal",
components: {ImgUpload, UserTagsModal},
props: {
value: {
type: Boolean,
default: false
},
userData: {
type: Object,
default: () => ({})
},
checkinMode: {
type: Boolean,
default: false
},
departmentList: {
type: Array,
default: () => []
}
},
data() {
return {
visible: this.value,
loading: false,
formData: {
nickname: '',
tel: '',
profession: '',
email: '',
password: '',
changepass: true,
department: [],
introduction: '',
faceimg: [],
checkin_macs: []
},
extraInfo: {},
tagModalVisible: false,
personalTags: [],
personalTagTotal: 0
};
},
computed: {
...mapState(['formOptions']),
isLdapUser() {
return this.userData.identity && this.userData.identity.includes('ldap');
}
},
watch: {
value(v) {
this.visible = v;
if (v) {
this.initFormData();
}
},
visible(v) {
this.$emit('input', v);
},
'formData.department': {
handler(value, oldValue = []) {
if (!Array.isArray(value) || value.length === 0 || this.departmentList.length === 0) {
return;
}
const previous = Array.isArray(oldValue) ? new Set(oldValue) : new Set();
const selected = new Set(value);
const hasNewSelection = Array.from(selected).some(id => !previous.has(id));
if (!hasNewSelection) {
return;
}
const departmentMap = this.departmentList.reduce((acc, item) => {
acc[item.id] = item;
return acc;
}, {});
const needAdd = new Set();
value.forEach((id) => {
let cursor = departmentMap[id];
while (cursor && cursor.parent_id && cursor.parent_id > 0) {
if (!selected.has(cursor.parent_id)) {
needAdd.add(cursor.parent_id);
}
cursor = departmentMap[cursor.parent_id];
}
});
if (needAdd.size > 0) {
const merged = Array.from(new Set([...value, ...needAdd])).sort((a, b) => a - b);
if (merged.length !== value.length || merged.some((id, index) => id !== value[index])) {
this.$set(this.formData, 'department', merged);
}
}
},
deep: true
}
},
methods: {
initFormData() {
const {nickname_original, tel, profession, email, department, checkin_face, checkin_macs} = this.userData;
this.formData = {
nickname: nickname_original || '',
tel: tel || '',
profession: profession || '',
email: email || '',
password: '',
changepass: true,
department: Array.isArray(department)
? department.map(id => parseInt(id))
: [],
introduction: '',
faceimg: checkin_face ? [{url: checkin_face}] : [],
checkin_macs: Array.isArray(checkin_macs) && checkin_macs.length > 0
? $A.cloneJSON(checkin_macs)
: [{mac: '', remark: ''}]
};
this.extraInfo = {};
this.personalTags = [];
this.personalTagTotal = 0;
this.loadUserExtra();
},
loadUserExtra() {
const userid = this.userData?.userid;
if (!userid) {
return;
}
this.$store.dispatch("getUserExtra", userid)
.then((data) => {
if ($A.isJson(data)) {
this.extraInfo = data;
this.formData.introduction = data.introduction || '';
this.syncPersonalTags();
}
})
.catch(() => {});
},
syncPersonalTags() {
const extra = this.extraInfo || {};
const tags = Array.isArray(extra.personal_tags) ? extra.personal_tags : [];
this.personalTags = tags.slice(0, 10);
this.personalTagTotal = typeof extra.personal_tags_total === 'number'
? extra.personal_tags_total
: this.personalTags.length;
},
openTagModal() {
if (!this.userData.userid) {
return;
}
this.tagModalVisible = true;
},
onTagsUpdated({top, total}) {
this.personalTags = Array.isArray(top) ? top : [];
this.personalTagTotal = typeof total === 'number' ? total : this.personalTags.length;
this.extraInfo = Object.assign({}, this.extraInfo, {
personal_tags: this.personalTags,
personal_tags_total: this.personalTagTotal
});
},
onMultipleMaxBefore(num) {
$A.messageError(`最多选择${num}个部门`);
return false;
},
addCheckinMac() {
this.formData.checkin_macs.push({mac: '', remark: ''});
},
delCheckinMac(index) {
this.formData.checkin_macs.splice(index, 1);
if (this.formData.checkin_macs.length === 0) {
this.addCheckinMac();
}
},
async handleSave() {
this.loading = true;
try {
await this.saveBasicInfo();
await this.saveExtraInfo();
if (this.checkinMode) {
await this.saveCheckinInfo();
}
$A.messageSuccess(this.$L('保存成功'));
this.visible = false;
this.$emit('updated');
} catch (error) {
$A.modalError(error.msg || this.$L('保存失败'));
} finally {
this.loading = false;
}
},
saveExtraInfo() {
const userid = this.userData?.userid;
if (!userid) {
return Promise.resolve();
}
const oldIntroduction = this.extraInfo?.introduction || '';
if (this.formData.introduction === oldIntroduction) {
return Promise.resolve();
}
return this.$store.dispatch('saveUserExtra', {
userid,
data: {
introduction: this.formData.introduction || ''
}
});
},
saveBasicInfo() {
return new Promise((resolve, reject) => {
const data = {
userid: this.userData.userid,
department: this.formData.department,
type: 'department'
};
if (this.formData.nickname !== (this.userData.nickname_original || '')) {
data.nickname = this.formData.nickname;
}
if (this.formData.tel !== (this.userData.tel || '')) {
data.tel = this.formData.tel;
}
if (this.formData.profession !== (this.userData.profession || '')) {
data.profession = this.formData.profession;
}
if (this.formData.email !== this.userData.email) {
data.email = this.formData.email;
}
if (this.formData.password) {
data.password = this.formData.password;
data.changepass = this.formData.changepass ? 1 : 0;
}
this.$store.dispatch("call", {
url: 'users/operation',
data
}).then(resolve).catch(reject);
});
},
saveCheckinInfo() {
const promises = [];
const newFaceUrl = $A.arrayLength(this.formData.faceimg) > 0
? this.formData.faceimg[0].url
: '';
const oldFaceUrl = this.userData.checkin_face || '';
if (newFaceUrl !== oldFaceUrl) {
promises.push(
this.$store.dispatch("call", {
url: 'users/operation',
data: {
userid: this.userData.userid,
type: 'checkin_face',
checkin_face: newFaceUrl
}
})
);
}
const validMacs = this.formData.checkin_macs.filter(item => item.mac && item.mac.trim());
promises.push(
this.$store.dispatch("call", {
url: 'users/operation',
data: {
userid: this.userData.userid,
type: 'checkin_macs',
checkin_macs: validMacs
}
})
);
return Promise.all(promises);
}
}
};
</script>
<style lang="scss">
.checkin-field {
.ivu-form-item-label {
color: #f90;
font-weight: 500;
}
}
</style>
<style lang="scss" scoped>
.checkin-mac-header {
margin-bottom: 8px;
font-weight: 500;
color: #606266;
}
.checkin-mac-item {
margin-bottom: 8px;
.ivu-col {
padding-right: 8px;
&:last-child {
padding-right: 0;
}
}
}
.checkin-mac-del {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #f00;
&:hover {
opacity: 0.8;
}
}
.form-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.user-tags-preview {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-height: 32px;
.tag-pill {
cursor: pointer;
padding: 6px 12px;
border-radius: 12px;
font-size: 13px;
user-select: none;
background-color: #f5f5f5;
color: #606266;
line-height: 14px;
height: 26px;
max-width: 160px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.is-recognized {
color: #67c23a;
}
span {
padding-left: 8px;
position: relative;
&:before {
content: '';
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 2px;
border-radius: 50%;
background-color: currentColor;
}
}
}
.tags-empty {
color: #909399;
}
.tags-total {
color: #909399;
font-size: 12px;
}
.manage-button {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
}
}
</style>