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
@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* 胶囊开关(对齐原型 `.switch`:36×20 胶囊 + 圆形滑块,开启为 --ok)。
* 用 button + role="switch" 而不是 checkbox,避免各浏览器对 checkbox 的默认外观差异。
*/
const props = defineProps<{
modelValue: boolean
disabled?: boolean
/** 供读屏使用的名称;视觉标签由调用方提供 */
label?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function toggle() {
if (props.disabled) {
return
}
emit('update:modelValue', !props.modelValue)
}
</script>
<template>
<button
type="button"
class="switch"
:class="{ 'is-on': modelValue }"
role="switch"
:aria-checked="modelValue"
:aria-label="label"
:disabled="disabled"
@click="toggle"
>
<span class="switch__thumb" />
</button>
</template>
<style scoped>
.switch {
position: relative;
flex-shrink: 0;
width: 36px;
height: 20px;
padding: 0;
background: #cbd8de;
border: none;
border-radius: 20px;
transition: background 0.2s;
cursor: pointer;
}
.switch.is-on {
background: var(--ok);
}
.switch:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.switch:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.switch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: left 0.2s;
}
.switch.is-on .switch__thumb {
left: 18px;
}
</style>
+72
View File
@@ -1,3 +1,5 @@
import axios from 'axios'
import type { ApiResponse } from '@/types/common'
export class ApiResponseError extends Error {
@@ -12,6 +14,76 @@ export class ApiResponseError extends Error {
}
}
/** 后端字段校验文案里的字段名 → 界面用词 */
const BACKEND_FIELD_LABELS: Record<string, string> = {
password: '密码',
newPassword: '新密码',
username: '登录账号',
displayName: '姓名',
employeeNo: '工号',
position: '岗位',
professionalTitle: '职称',
phone: '手机号',
reason: '重置原因',
campusId: '院区',
roleIds: '角色',
}
/**
* 后端的字段校验是 Spring Validation,message 形如
* `password: size must be between 8 and 128` / `displayName: must not be blank`(英文技术文案)。
* 直接甩给用户不友好,这里只对**已知格式**做中文化;其它文案一律原样返回,不猜。
*/
function localizeBackendMessage(message: string): string {
const text = message.trim()
const sizeRange = /^(\w+):\s*size must be between (\d+) and (\d+)$/.exec(text)
if (sizeRange) {
const [, field, min, max] = sizeRange
return `${BACKEND_FIELD_LABELS[field] ?? field}长度需在 ${min}–${max} 位之间`
}
const notBlank = /^(\w+):\s*must not be blank$/.exec(text)
if (notBlank) {
return `请填写${BACKEND_FIELD_LABELS[notBlank[1]] ?? notBlank[1]}`
}
return text
}
/**
* 把接口错误转成可以直接展示给用户的文案。
*
* 两处来源都要覆盖:
* - 业务错误码(HTTP 200 + code≠0)由 `unwrapApiResponse` 抛成 `ApiResponseError`;
* - 参数校验失败是 **HTTP 400**,axios 抛 `AxiosError`,文案在 `response.data.message` 里。
* 兜底才用调用方给的 fallback —— 不要把后端给的原因吞掉换成"操作失败,请稍后重试"。
*/
export function toUserMessage(error: unknown, fallback: string): string {
if (error instanceof ApiResponseError) {
return error.message ? localizeBackendMessage(error.message) : fallback
}
if (axios.isAxiosError(error)) {
const backendMessage = (error.response?.data as { message?: unknown } | undefined)?.message
if (typeof backendMessage === 'string' && backendMessage) {
return localizeBackendMessage(backendMessage)
}
if (!error.response) {
return '网络异常,请检查网络后重试'
}
return `${fallback}(HTTP ${error.response.status})`
}
if (error instanceof Error && error.message) {
return error.message
}
return fallback
}
export function isSuccessCode(code: number | string) {
return code === 0 || code === '0'
}
+17 -6
View File
@@ -2,12 +2,18 @@
* 员工账号密码策略。
*
* 组织架构的「新增员工」与「重置密码」共用同一套规则,
* 避免两处各写一份导致口径分叉:长度不少于 12 位,且同时包含大写和小写字母。
* 避免两处各写一份导致口径分叉:长度不少于 12 位,且同时包含大写字母、小写字母、特殊字符。
*/
/** 密码最小长度 */
export const PASSWORD_MIN_LENGTH = 12
/**
* 特殊字符:常见 ASCII 可见符号。
* 用显式符号集而不是 `[^A-Za-z0-9]`,避免把中文、emoji、空白也算成「特殊字符」。
*/
const SPECIAL_CHAR_PATTERN = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~`]/
export interface PasswordPolicyChecks {
/** 长度是否达到最小值 */
minLength: boolean
@@ -15,6 +21,8 @@ export interface PasswordPolicyChecks {
hasUppercase: boolean
/** 是否包含小写字母 */
hasLowercase: boolean
/** 是否包含特殊字符 */
hasSpecialChar: boolean
}
export interface PasswordPolicyRule {
@@ -22,11 +30,12 @@ export interface PasswordPolicyRule {
label: string
}
/** 规则清单,供表单实时展示校验进度(顺序即提示顺序) */
/** 规则清单,供表单实时展示校验进度(顺序即提示顺序;文案取短版,保证半列宽也能横排一行) */
export const PASSWORD_POLICY_RULES: PasswordPolicyRule[] = [
{ key: 'minLength', label: `长度不少于 ${PASSWORD_MIN_LENGTH} 位` },
{ key: 'hasUppercase', label: '包含大写字母 A-Z' },
{ key: 'hasLowercase', label: '包含小写字母 a-z' },
{ key: 'minLength', label: `≥ ${PASSWORD_MIN_LENGTH} 位` },
{ key: 'hasUppercase', label: '含大写' },
{ key: 'hasLowercase', label: '含小写' },
{ key: 'hasSpecialChar', label: '含符号' },
]
/** 逐条计算密码是否满足策略,返回每个规则的通过情况 */
@@ -35,13 +44,14 @@ export function checkPasswordPolicy(password: string): PasswordPolicyChecks {
minLength: password.length >= PASSWORD_MIN_LENGTH,
hasUppercase: /[A-Z]/.test(password),
hasLowercase: /[a-z]/.test(password),
hasSpecialChar: SPECIAL_CHAR_PATTERN.test(password),
}
}
/** 密码是否完全满足策略 */
export function isPasswordPolicySatisfied(password: string): boolean {
const checks = checkPasswordPolicy(password)
return checks.minLength && checks.hasUppercase && checks.hasLowercase
return checks.minLength && checks.hasUppercase && checks.hasLowercase && checks.hasSpecialChar
}
/** 返回第一条未满足的规则提示,全部满足时返回 null */
@@ -50,5 +60,6 @@ export function firstPasswordPolicyIssue(password: string): string | null {
const checks = checkPasswordPolicy(password)
if (!checks.minLength) return `密码长度不少于 ${PASSWORD_MIN_LENGTH} 位`
if (!checks.hasUppercase || !checks.hasLowercase) return '密码必须同时包含大写和小写字母'
if (!checks.hasSpecialChar) return '密码必须包含特殊字符'
return null
}
@@ -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
}