diff --git a/clinical-web/src/components/ToggleSwitch.vue b/clinical-web/src/components/ToggleSwitch.vue new file mode 100644 index 0000000..6f4b1f8 --- /dev/null +++ b/clinical-web/src/components/ToggleSwitch.vue @@ -0,0 +1,82 @@ + + + + + + + + + diff --git a/clinical-web/src/utils/api-response.ts b/clinical-web/src/utils/api-response.ts index 135292e..5b80327 100644 --- a/clinical-web/src/utils/api-response.ts +++ b/clinical-web/src/utils/api-response.ts @@ -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 = { + 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' } diff --git a/clinical-web/src/utils/password-policy.ts b/clinical-web/src/utils/password-policy.ts index 69670bc..acb86c4 100644 --- a/clinical-web/src/utils/password-policy.ts +++ b/clinical-web/src/utils/password-policy.ts @@ -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 } diff --git a/clinical-web/src/views/management/users/components/PasswordPolicyHints.vue b/clinical-web/src/views/management/users/components/PasswordPolicyHints.vue index 400f5cd..4ee2bcd 100644 --- a/clinical-web/src/views/management/users/components/PasswordPolicyHints.vue +++ b/clinical-web/src/views/management/users/components/PasswordPolicyHints.vue @@ -21,8 +21,9 @@ const checks = computed(() => checkPasswordPolicy(props.password)) diff --git a/clinical-web/src/views/management/users/components/UserEditorDialog.vue b/clinical-web/src/views/management/users/components/UserEditorDialog.vue index 87579f8..4332849 100644 --- a/clinical-web/src/views/management/users/components/UserEditorDialog.vue +++ b/clinical-web/src/views/management/users/components/UserEditorDialog.vue @@ -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( 系统用户 ID - - 登录账号 * - - - - 登录账号 - - 姓名 * - - 初始密码 * - - - 工号 * @@ -241,16 +250,6 @@ watch( @input="markPhoneEdited" /> - - 邮箱 - - ② 角色(权限和数据范围由角色统一配置) 院区 * @@ -274,17 +273,23 @@ watch( - - 角色(可多选) - - - {{ role.name }}({{ role.code }}){{ - role.status === 'DISABLED' ? '(已停用)' : '' - }} - - - 多个角色的菜单、操作与数据权限按系统规则合并。 - + + 角色 + + + + + {{ role.name }}({{ role.code }}){{ + role.status === 'DISABLED' ? '(已停用)' : '' + }} + + + + + 该员工当前绑定 {{ form.roleIds.length }} 个角色,改为互斥单选后保存将只保留所选角色。 + + 角色的菜单、操作与数据权限互斥,一个员工只能绑定一个角色。 + 已绑定角色 @@ -292,25 +297,42 @@ watch( ③ 账号与安全 - 状态 - - 启用 - 停用 - 锁定 - + 登录账号 * + + - - - 首次登录强制修改密码 + + 初始密码 * + + - + 首次登录强制修改密码 - + + + + 账号状态 + {{ error.message }} - 接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存联系方式会明确提交清除操作。 + 接口返回的手机号可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存的联系方式会明确提交清除操作。 @@ -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); diff --git a/clinical-web/src/views/management/users/index.vue b/clinical-web/src/views/management/users/index.vue index b4de41b..c99e238 100644 --- a/clinical-web/src/views/management/users/index.vue +++ b/clinical-web/src/views/management/users/index.vue @@ -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 } diff --git a/clinical-web/src/views/management/users/types.ts b/clinical-web/src/views/management/users/types.ts index 8a5abd3..02f9d6e 100644 --- a/clinical-web/src/views/management/users/types.ts +++ b/clinical-web/src/views/management/users/types.ts @@ -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 }
{{ error.message }}
- 接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存联系方式会明确提交清除操作。 + 接口返回的手机号可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存的联系方式会明确提交清除操作。