feat(users): 员工表单对齐原型并优化密码策略与错误文案
- 新增胶囊开关组件,账号状态与强制改密改用开关 - 角色由多选改为互斥单选,编辑态多角色给出收敛提示 - 密码策略增加特殊字符要求,规则提示改为短标签横排 - 移除邮箱字段及相关脱敏、清除逻辑 - 接口错误经 toUserMessage 中文化,透传后端校验原因
This commit is contained in:
@@ -21,8 +21,9 @@ const checks = computed(() => checkPasswordPolicy(props.password))
|
||||
|
||||
<style scoped>
|
||||
.password-hints {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
@@ -34,8 +35,9 @@ const checks = computed(() => checkPasswordPolicy(props.password))
|
||||
|
||||
.password-hints li {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.password-hints__mark {
|
||||
@@ -44,6 +46,6 @@ const checks = computed(() => checkPasswordPolicy(props.password))
|
||||
|
||||
.password-hints li.is-ok,
|
||||
.password-hints li.is-ok .password-hints__mark {
|
||||
color: var(--brand);
|
||||
color: var(--ok);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import type { CampusRecord, DepartmentRecord, RoleRecord } from '@/api/management/types'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import {
|
||||
PASSWORD_MIN_LENGTH,
|
||||
firstPasswordPolicyIssue,
|
||||
@@ -36,6 +37,41 @@ const availableDepartments = computed(() =>
|
||||
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
|
||||
)
|
||||
|
||||
/**
|
||||
* 账号状态用两态胶囊开关(对齐原型),但数据模型里还有后端下发的 LOCKED。
|
||||
* get 只在 ENABLED 时置开;用户不碰开关时 set 不会被调用,LOCKED 原样保留提交,
|
||||
* 避免"编辑一个已锁定员工顺手把它改回启用"。
|
||||
*/
|
||||
const statusEnabled = computed({
|
||||
get: () => form.status === 'enabled',
|
||||
set: (value: boolean) => {
|
||||
form.status = value ? 'enabled' : 'disabled'
|
||||
},
|
||||
})
|
||||
|
||||
/** 表单里该字段是可选的,开关组件要求严格 boolean,这里做一层桥接 */
|
||||
const forcePasswordChange = computed({
|
||||
get: () => form.passwordChangeRequired ?? false,
|
||||
set: (value: boolean) => {
|
||||
form.passwordChangeRequired = value
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 角色互斥单选(对齐原型 `type:'radio'`)。
|
||||
* 接口 `PUT /v1/users/{id}/roles` 收的是 `roleIds` 数组,这里用「数组首元素」桥接,
|
||||
* 单选后数组长度恒为 0 或 1,API 层无需改动。
|
||||
*/
|
||||
const selectedRoleId = computed({
|
||||
get: () => form.roleIds[0] ?? '',
|
||||
set: (value: string) => {
|
||||
form.roleIds = value ? [value] : []
|
||||
},
|
||||
})
|
||||
|
||||
/** 编辑态且原本绑了多个角色:单选保存会收敛成一个,界面上得说清楚,避免静默丢角色 */
|
||||
const hadMultipleRoles = computed(() => isEditing.value && form.roleIds.length > 1)
|
||||
|
||||
function createEmptyForm(): UserEditorForm {
|
||||
return {
|
||||
username: '',
|
||||
@@ -45,14 +81,12 @@ function createEmptyForm(): UserEditorForm {
|
||||
position: '',
|
||||
professionalTitle: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
campusId: props.defaultCampusId ?? props.campuses[0]?.id ?? '',
|
||||
departmentId: props.defaultDepartmentId ?? '',
|
||||
status: 'enabled',
|
||||
roleIds: [],
|
||||
passwordChangeRequired: true,
|
||||
clearPhone: false,
|
||||
clearEmail: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +121,6 @@ function markPhoneEdited() {
|
||||
form.clearPhone = !form.phone
|
||||
}
|
||||
|
||||
function markEmailEdited() {
|
||||
form.emailMasked = false
|
||||
form.clearEmail = !form.email
|
||||
}
|
||||
|
||||
function submit() {
|
||||
error.message = ''
|
||||
|
||||
@@ -140,7 +169,6 @@ function submit() {
|
||||
displayName: form.displayName.trim(),
|
||||
employeeNo: form.employeeNo.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,29 +223,10 @@ watch(
|
||||
<span>系统用户 ID</span>
|
||||
<input :value="isEditing ? (form.employeeId ?? '—') : '保存后自动生成'" disabled />
|
||||
</label>
|
||||
<label v-if="!isEditing">
|
||||
<span>登录账号 <em>*</em></span>
|
||||
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
|
||||
</label>
|
||||
<label v-else>
|
||||
<span>登录账号</span>
|
||||
<input :value="form.username" disabled />
|
||||
</label>
|
||||
<label>
|
||||
<span>姓名 <em>*</em></span>
|
||||
<input v-model="form.displayName" autocomplete="name" placeholder="请输入姓名" />
|
||||
</label>
|
||||
<label v-if="!isEditing">
|
||||
<span>初始密码 <em>*</em></span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:minlength="PASSWORD_MIN_LENGTH"
|
||||
placeholder="请输入初始密码"
|
||||
/>
|
||||
<PasswordPolicyHints :password="form.password" />
|
||||
</label>
|
||||
<label>
|
||||
<span>工号 <em>*</em></span>
|
||||
<input v-model="form.employeeNo" :disabled="isEditing" placeholder="请输入工号" />
|
||||
@@ -241,16 +250,6 @@ watch(
|
||||
@input="markPhoneEdited"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>邮箱</span>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="请输入邮箱"
|
||||
@input="markEmailEdited"
|
||||
/>
|
||||
</label>
|
||||
<div class="form-section">② 角色(权限和数据范围由角色统一配置)</div>
|
||||
<label>
|
||||
<span>院区 <em>*</em></span>
|
||||
@@ -274,17 +273,23 @@ watch(
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="canAssignRoles" class="form-grid__full">
|
||||
<span>角色(可多选)</span>
|
||||
<select v-model="form.roleIds" multiple size="4">
|
||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}({{ role.code }}){{
|
||||
role.status === 'DISABLED' ? '(已停用)' : ''
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<small>多个角色的菜单、操作与数据权限按系统规则合并。</small>
|
||||
</label>
|
||||
<div v-if="canAssignRoles" class="form-grid__full role-field">
|
||||
<span>角色</span>
|
||||
<div class="role-options">
|
||||
<label v-for="role in roles" :key="role.id" class="role-option">
|
||||
<input v-model="selectedRoleId" type="radio" name="user-role" :value="role.id" />
|
||||
<span>
|
||||
{{ role.name }}({{ role.code }}){{
|
||||
role.status === 'DISABLED' ? '(已停用)' : ''
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<small v-if="hadMultipleRoles" class="role-warning">
|
||||
该员工当前绑定 {{ form.roleIds.length }} 个角色,改为互斥单选后保存将只保留所选角色。
|
||||
</small>
|
||||
<small v-else>角色的菜单、操作与数据权限互斥,一个员工只能绑定一个角色。</small>
|
||||
</div>
|
||||
<label v-else class="form-grid__full">
|
||||
<span>已绑定角色</span>
|
||||
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
|
||||
@@ -292,25 +297,42 @@ watch(
|
||||
</label>
|
||||
<div class="form-section">③ 账号与安全</div>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model="form.status">
|
||||
<option value="enabled">启用</option>
|
||||
<option value="disabled">停用</option>
|
||||
<option value="locked">锁定</option>
|
||||
</select>
|
||||
<span>登录账号 <em>*</em></span>
|
||||
<input
|
||||
v-if="!isEditing"
|
||||
v-model="form.username"
|
||||
autocomplete="username"
|
||||
placeholder="如:zhangsan"
|
||||
/>
|
||||
<input v-else :value="form.username" disabled />
|
||||
</label>
|
||||
<label v-if="!isEditing" class="checkbox-field">
|
||||
<input v-model="form.passwordChangeRequired" type="checkbox" />
|
||||
<span>首次登录强制修改密码</span>
|
||||
<label v-if="!isEditing">
|
||||
<span>初始密码 <em>*</em></span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:minlength="PASSWORD_MIN_LENGTH"
|
||||
placeholder="请输入初始密码"
|
||||
/>
|
||||
<PasswordPolicyHints :password="form.password" />
|
||||
</label>
|
||||
<label v-else>
|
||||
<label>
|
||||
<span>首次登录强制修改密码</span>
|
||||
<input :value="form.passwordChangeRequired ? '是' : '否'" disabled />
|
||||
<ToggleSwitch
|
||||
v-model="forcePasswordChange"
|
||||
:disabled="isEditing"
|
||||
label="首次登录强制修改密码"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>账号状态</span>
|
||||
<ToggleSwitch v-model="statusEnabled" label="账号状态" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||
<p class="form-tip">
|
||||
接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存联系方式会明确提交清除操作。
|
||||
接口返回的手机号可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存的联系方式会明确提交清除操作。
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -444,8 +466,51 @@ watch(
|
||||
background: #f5f8f9;
|
||||
}
|
||||
|
||||
.form-grid select[multiple] {
|
||||
min-height: 94px;
|
||||
.role-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.role-field > span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.role-field .role-option {
|
||||
display: flex;
|
||||
/* 必须显式声明:.form-grid label 的 flex-direction:column 同样命中这里的 label,
|
||||
只写 display:flex 的话方向仍是 column,radio 和文字会各占一行 */
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.role-field .role-option > span {
|
||||
color: var(--ink);
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.role-field .role-option input {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.role-field .role-warning {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.form-grid small {
|
||||
@@ -455,18 +520,6 @@ watch(
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px !important;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.checkbox-field input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0;
|
||||
color: var(--err);
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
} from '@/api/management/types'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
import { toUserMessage } from '@/utils/api-response'
|
||||
|
||||
import OrganizationNodeDialog, {
|
||||
type OrganizationNodeSave,
|
||||
@@ -258,7 +259,6 @@ function mapUserRow(
|
||||
position: user.position,
|
||||
professionalTitle: user.professionalTitle,
|
||||
phone: user.phone ?? '',
|
||||
email: user.email ?? '',
|
||||
campus: campusNames.get(user.campusId ?? '') ?? user.campusName ?? '未指定院区',
|
||||
campusId: user.campusId,
|
||||
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
|
||||
@@ -426,8 +426,6 @@ async function editUser(row: UserTableRow) {
|
||||
professionalTitle: user.professionalTitle ?? '',
|
||||
phone: user.phone ?? '',
|
||||
phoneMasked: masked(user.phone),
|
||||
email: user.email ?? '',
|
||||
emailMasked: masked(user.email),
|
||||
campusId: user.campusId ?? row.campusId ?? '',
|
||||
departmentId: user.departmentId ?? row.departmentId ?? '',
|
||||
status: user.status,
|
||||
@@ -435,12 +433,11 @@ async function editUser(row: UserTableRow) {
|
||||
roleLabels: user.roleNames ?? row.roleNames ?? [],
|
||||
passwordChangeRequired: user.passwordChangeRequired ?? false,
|
||||
clearPhone: false,
|
||||
clearEmail: false,
|
||||
version: user.version,
|
||||
}
|
||||
editorVisible.value = true
|
||||
} catch {
|
||||
ElMessage.error('员工详情加载失败,请稍后重试')
|
||||
} catch (error) {
|
||||
ElMessage.error(toUserMessage(error, '员工详情加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,8 +465,8 @@ async function handlePasswordReset(payload: {
|
||||
passwordResetVisible.value = false
|
||||
passwordResetTarget.value = null
|
||||
ElMessage.success(`员工“${target.name}”的密码已重置`)
|
||||
} catch {
|
||||
ElMessage.error('密码重置失败,请稍后重试')
|
||||
} catch (error) {
|
||||
ElMessage.error(toUserMessage(error, '密码重置失败,请稍后重试'))
|
||||
} finally {
|
||||
passwordResetSaving.value = false
|
||||
}
|
||||
@@ -538,7 +535,6 @@ async function saveUser(form: UserEditorForm) {
|
||||
position: form.position,
|
||||
professionalTitle: form.professionalTitle,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
campus: campuses.value.find((campus) => campus.id === form.campusId)?.name ?? '未指定院区',
|
||||
campusId: form.campusId,
|
||||
department:
|
||||
@@ -570,11 +566,6 @@ async function saveUser(form: UserEditorForm) {
|
||||
: form.clearPhone
|
||||
? { clearPhone: true }
|
||||
: { phone: form.phone || undefined }),
|
||||
...(form.emailMasked
|
||||
? {}
|
||||
: form.clearEmail
|
||||
? { clearEmail: true }
|
||||
: { email: form.email || undefined }),
|
||||
campusId: form.campusId,
|
||||
departmentId: form.departmentId || null,
|
||||
status: toBackendUserStatus(form.status),
|
||||
@@ -614,8 +605,10 @@ async function saveUser(form: UserEditorForm) {
|
||||
} else {
|
||||
ElMessage.success(form.id ? '员工资料已更新' : '员工已创建')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error(form.id ? '员工更新失败,请稍后重试' : '员工创建失败,请稍后重试')
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
toUserMessage(error, form.id ? '员工更新失败,请稍后重试' : '员工创建失败,请稍后重试'),
|
||||
)
|
||||
} finally {
|
||||
editorSaving.value = false
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface UserTableRow {
|
||||
position?: string | null
|
||||
professionalTitle?: string | null
|
||||
phone?: string
|
||||
email?: string
|
||||
campus: string
|
||||
campusId?: string | null
|
||||
department: string
|
||||
@@ -34,8 +33,6 @@ export interface UserEditorForm {
|
||||
professionalTitle: string
|
||||
phone: string
|
||||
phoneMasked?: boolean
|
||||
email: string
|
||||
emailMasked?: boolean
|
||||
campusId: string
|
||||
departmentId: string
|
||||
status: UserStatus
|
||||
@@ -43,6 +40,5 @@ export interface UserEditorForm {
|
||||
roleLabels?: string[]
|
||||
passwordChangeRequired?: boolean
|
||||
clearPhone?: boolean
|
||||
clearEmail?: boolean
|
||||
version?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user