feat(users): 员工表单对齐原型并优化密码策略与错误文案

- 新增胶囊开关组件,账号状态与强制改密改用开关
- 角色由多选改为互斥单选,编辑态多角色给出收敛提示
- 密码策略增加特殊字符要求,规则提示改为短标签横排
- 移除邮箱字段及相关脱敏、清除逻辑
- 接口错误经 toUserMessage 中文化,透传后端校验原因
This commit is contained in:
yelan
2026-09-30 10:33:24 +08:00
parent 3ad95cc795
commit 3628c5ed86
7 changed files with 313 additions and 104 deletions
@@ -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
}