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 - - - -
② 角色(权限和数据范围由角色统一配置)
- +
+ 角色 +
+ +
+ + 该员工当前绑定 {{ form.roleIds.length }} 个角色,改为互斥单选后保存将只保留所选角色。 + + 角色的菜单、操作与数据权限互斥,一个员工只能绑定一个角色。 +
③ 账号与安全
-