diff --git a/clinical-web/src/utils/password-policy.ts b/clinical-web/src/utils/password-policy.ts new file mode 100644 index 0000000..69670bc --- /dev/null +++ b/clinical-web/src/utils/password-policy.ts @@ -0,0 +1,54 @@ +/** + * 员工账号密码策略。 + * + * 组织架构的「新增员工」与「重置密码」共用同一套规则, + * 避免两处各写一份导致口径分叉:长度不少于 12 位,且同时包含大写和小写字母。 + */ + +/** 密码最小长度 */ +export const PASSWORD_MIN_LENGTH = 12 + +export interface PasswordPolicyChecks { + /** 长度是否达到最小值 */ + minLength: boolean + /** 是否包含大写字母 */ + hasUppercase: boolean + /** 是否包含小写字母 */ + hasLowercase: boolean +} + +export interface PasswordPolicyRule { + key: keyof PasswordPolicyChecks + label: string +} + +/** 规则清单,供表单实时展示校验进度(顺序即提示顺序) */ +export const PASSWORD_POLICY_RULES: PasswordPolicyRule[] = [ + { key: 'minLength', label: `长度不少于 ${PASSWORD_MIN_LENGTH} 位` }, + { key: 'hasUppercase', label: '包含大写字母 A-Z' }, + { key: 'hasLowercase', label: '包含小写字母 a-z' }, +] + +/** 逐条计算密码是否满足策略,返回每个规则的通过情况 */ +export function checkPasswordPolicy(password: string): PasswordPolicyChecks { + return { + minLength: password.length >= PASSWORD_MIN_LENGTH, + hasUppercase: /[A-Z]/.test(password), + hasLowercase: /[a-z]/.test(password), + } +} + +/** 密码是否完全满足策略 */ +export function isPasswordPolicySatisfied(password: string): boolean { + const checks = checkPasswordPolicy(password) + return checks.minLength && checks.hasUppercase && checks.hasLowercase +} + +/** 返回第一条未满足的规则提示,全部满足时返回 null */ +export function firstPasswordPolicyIssue(password: string): string | null { + if (!password) return '请输入密码' + const checks = checkPasswordPolicy(password) + if (!checks.minLength) return `密码长度不少于 ${PASSWORD_MIN_LENGTH} 位` + if (!checks.hasUppercase || !checks.hasLowercase) 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 new file mode 100644 index 0000000..400f5cd --- /dev/null +++ b/clinical-web/src/views/management/users/components/PasswordPolicyHints.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/clinical-web/src/views/management/users/components/UserEditorDialog.vue b/clinical-web/src/views/management/users/components/UserEditorDialog.vue index 4d1009a..87579f8 100644 --- a/clinical-web/src/views/management/users/components/UserEditorDialog.vue +++ b/clinical-web/src/views/management/users/components/UserEditorDialog.vue @@ -2,7 +2,13 @@ import { computed, reactive, watch } from 'vue' import type { CampusRecord, DepartmentRecord, RoleRecord } from '@/api/management/types' +import { + PASSWORD_MIN_LENGTH, + firstPasswordPolicyIssue, + isPasswordPolicySatisfied, +} from '@/utils/password-policy' +import PasswordPolicyHints from './PasswordPolicyHints.vue' import type { UserEditorForm } from '../types' const props = defineProps<{ @@ -51,16 +57,22 @@ function createEmptyForm(): UserEditorForm { } function resetForm() { - Object.assign( - form, - props.initial - ? { - ...props.initial, - roleIds: [...props.initial.roleIds], - roleLabels: [...(props.initial.roleLabels ?? [])], - } - : createEmptyForm(), - ) + const next: UserEditorForm = props.initial + ? { + ...createEmptyForm(), + ...props.initial, + roleIds: [...props.initial.roleIds], + roleLabels: [...(props.initial.roleLabels ?? [])], + } + : createEmptyForm() + + // 先清空全部字段再写入:编辑态会带入 id / employeeId / version / phoneMasked 等键, + // 若只做增量合并,这些键会残留到「新增员工」表单,使新增被误判为编辑态并提交成更新。 + const mutableForm = form as unknown as Record + for (const key of Object.keys(mutableForm)) { + delete mutableForm[key] + } + Object.assign(form, next) error.message = '' } @@ -98,8 +110,8 @@ function submit() { return } - if (!isEditing.value && form.password.length < 8) { - error.message = '初始密码至少需要 8 位' + if (!isEditing.value && !isPasswordPolicySatisfied(form.password)) { + error.message = firstPasswordPolicyIssue(form.password) ?? '初始密码不符合安全要求' return } @@ -201,8 +213,10 @@ watch( v-model="form.password" type="password" autocomplete="new-password" + :minlength="PASSWORD_MIN_LENGTH" placeholder="请输入初始密码" /> +