# 年龄和出生日期联动 ## 1. 需求描述 ### 业务场景 在患者注册流程中,用户需要填写患者的年龄和出生日期。这两个字段存在逻辑关联,当用户修改其中一个字段时,另一个字段应该自动计算并更新。 ### 具体需求 **双向联动关系:** 1. **年龄 → 出生日期** - 当用户修改年龄时,根据当前日期自动计算出生日期 - 例如:年龄改为 "25Y" → 出生日期自动设置为 25 年前的日期 2. **出生日期 → 年龄** - 当用户修改出生日期时,根据当前日期自动计算年龄 - 例如:出生日期改为 "2000-01-01" → 自动计算为 "24Y"(假设当前是2024年) ### 业务规则 - 年龄单位支持:天(D)、月(M)、年(Y) - 年龄优先级:年 > 月 > 天 - 计算规则: - 如果年龄 >= 1年,使用年作为单位 - 如果年龄 >= 1月但 < 1年,使用月作为单位 - 如果年龄 < 1月,使用天作为单位 --- ## 2. Redux 架构分析 ### 2.1 涉及的 Slices #### `formSlice` - 表单数据切片 **位置**: `src/states/patient/register/formSlice.ts` **State 结构**: ```typescript interface FormState { formData: Record; } ``` **关键字段**: - `patient_age`: `{ number: number, unit: 'D' | 'M' | 'Y' }` - 患者年龄 - `patient_dob`: `Dayjs` - 患者出生日期 **关键 Action**: - `setFormData(payload: Record)` - 更新整个表单数据对象 ### 2.2 数据流关系 ``` 用户修改年龄/出生日期 ↓ Form onChange 触发 ↓ 组件内部联动逻辑 ├─→ 计算关联字段值 └─→ form.setFieldsValue() 更新表单 ↓ onValuesChange 回调触发 ↓ dispatch(setFormData(allValues)) ↓ Redux store.form.formData 更新 ``` --- ## 3. 防止循环触发的原理 ### 3.1 问题:为什么会出现循环触发? 假设我们这样实现联动: ```typescript // ❌ 错误示例 - 会导致无限循环 const patient_age = Form.useWatch('patient_age', form); const patient_dob = Form.useWatch('patient_dob', form); useEffect(() => { // 当年龄变化时,更新出生日期 if (patient_age) { const newDob = calculateDobFromAge(patient_age); form.setFieldsValue({ patient_dob: newDob }); } }, [patient_age]); useEffect(() => { // 当出生日期变化时,更新年龄 if (patient_dob) { const newAge = calculateAgeFromDob(patient_dob); form.setFieldsValue({ patient_age: newAge }); } }, [patient_dob]); ``` **循环过程:** 1. 用户修改年龄 → `patient_age` 变化 2. 第一个 useEffect 触发 → 计算并设置 `patient_dob` 3. `patient_dob` 变化 → 触发第二个 useEffect 4. 第二个 useEffect 计算并设置 `patient_age` 5. `patient_age` 变化 → 又触发第一个 useEffect 6. 无限循环... 💥 ### 3.2 useRef 的特性 `useRef` 有两个关键特性适合解决这个问题: 1. **跨渲染持久化**:ref 的值在组件重新渲染时保持不变 2. **修改不触发重渲染**:修改 ref.current 不会导致组件重新渲染,也不会触发 useEffect 这使得 ref 非常适合作为"标志位"来存储状态信息。 ### 3.3 解决方案:使用 useRef 标志位 #### 方案:记录更新源(推荐) ```typescript const BasicInfoForm: React.FC = ({ form, ... }) => { // 使用 ref 记录是谁触发的更新 const updateSourceRef = useRef<'age' | 'dob' | null>(null); const patient_age = Form.useWatch('patient_age', form); const patient_dob = Form.useWatch('patient_dob', form); // 监听年龄变化 useEffect(() => { // 如果是出生日期联动更新的年龄,跳过 if (updateSourceRef.current === 'dob') { updateSourceRef.current = null; // 重置标志 return; } if (patient_age) { // 标记本次更新来自年龄变化 updateSourceRef.current = 'age'; const newDob = calculateDobFromAge(patient_age); form.setFieldsValue({ patient_dob: newDob }); } }, [patient_age]); // 监听出生日期变化 useEffect(() => { // 如果是年龄联动更新的出生日期,跳过 if (updateSourceRef.current === 'age') { updateSourceRef.current = null; // 重置标志 return; } if (patient_dob) { // 标记本次更新来自出生日期变化 updateSourceRef.current = 'dob'; const newAge = calculateAgeFromDob(patient_dob); form.setFieldsValue({ patient_age: newAge }); } }, [patient_dob]); return (
{/* 表单字段 */}
); }; ``` ### 3.4 执行流程分析 **场景 1:用户修改年龄** ``` 1. 用户输入年龄 25Y 2. patient_age 变化 → 触发年龄 useEffect 3. updateSourceRef.current === null → 检查通过,继续执行 4. updateSourceRef.current = 'age' → 标记更新源为年龄 5. form.setFieldsValue({ patient_dob: ... }) → 设置出生日期 6. patient_dob 变化 → 触发出生日期 useEffect 7. updateSourceRef.current === 'age' → 检查不通过,直接 return 8. updateSourceRef.current = null → 重置标志 9. ✅ 完成,没有循环 ``` **场景 2:用户修改出生日期** ``` 1. 用户选择出生日期 2000-01-01 2. patient_dob 变化 → 触发出生日期 useEffect 3. updateSourceRef.current === null → 检查通过,继续执行 4. updateSourceRef.current = 'dob' → 标记更新源为出生日期 5. form.setFieldsValue({ patient_age: ... }) → 设置年龄 6. patient_age 变化 → 触发年龄 useEffect 7. updateSourceRef.current === 'dob' → 检查不通过,直接 return 8. updateSourceRef.current = null → 重置标志 9. ✅ 完成,没有循环 ``` ### 3.5 原理总结 **为什么 useRef 能解决问题?** 1. **状态记忆**:ref 可以记住"上一次是谁触发的更新" 2. **不触发渲染**:修改 ref 不会导致组件重新渲染,不会触发 useEffect 3. **同步执行**:ref 的读写是同步的,在同一个渲染周期内立即生效 **关键点:** - 当 A 字段的 useEffect 更新 B 字段时,先设置标志 `updateSourceRef.current = 'A'` - 当 B 字段的 useEffect 被触发时,检查标志,如果发现是 A 触发的,就跳过执行 - 跳过后立即重置标志 `updateSourceRef.current = null`,为下次用户操作做准备 --- ## 4. 核心计算逻辑 ### 4.1 年龄 → 出生日期 ```typescript /** * 根据年龄计算出生日期 * @param age - 年龄对象 { number: number, unit: 'D' | 'M' | 'Y' } * @returns Dayjs对象表示的出生日期 */ function calculateDobFromAge(age: { number: number; unit: 'D' | 'M' | 'Y'; }): Dayjs { const now = dayjs(); switch (age.unit) { case 'D': return now.subtract(age.number, 'day'); case 'M': return now.subtract(age.number, 'month'); case 'Y': return now.subtract(age.number, 'year'); default: return now; } } ``` **示例:** - 输入:`{ number: 25, unit: 'Y' }`(25岁) - 当前日期:2024-10-07 - 输出:1999-10-07 ### 4.2 出生日期 → 年龄 ```typescript /** * 根据出生日期计算年龄 * @param dob - Dayjs对象表示的出生日期 * @returns 年龄对象 { number: number, unit: 'D' | 'M' | 'Y' } */ function calculateAgeFromDob(dob: Dayjs): { number: number; unit: 'D' | 'M' | 'Y'; } { const now = dayjs(); // 计算年份差 const years = now.diff(dob, 'year'); if (years >= 1) { return { number: years, unit: 'Y' }; } // 计算月份差 const months = now.diff(dob, 'month'); if (months >= 1) { return { number: months, unit: 'M' }; } // 计算天数差 const days = now.diff(dob, 'day'); return { number: Math.max(0, days), unit: 'D' }; } ``` **示例:** - 输入:2000-01-01 - 当前日期:2024-10-07 - 输出:`{ number: 24, unit: 'Y' }`(24岁) ### 4.3 边界情况处理 1. **负数年龄**:出生日期在未来 - 处理:将天数设置为 0,避免负数 2. **空值处理**: - 如果年龄为空或 undefined,不进行计算 - 如果出生日期为空或 null,不进行计算 3. **初始化时的联动**: - 表单初始化时,如果只有一个字段有默认值,应该触发联动计算另一个字段 - 如果两个字段都有默认值,以出生日期为准 --- ## 5. 实现方案 ### 5.1 修改位置 **文件**: `src/pages/patient/components/register.form.tsx` ### 5.2 具体实现步骤 1. **引入必要的依赖** ```typescript import React, { useEffect, useRef } from 'react'; import dayjs, { Dayjs } from 'dayjs'; ``` 2. **添加 useRef 标志位** ```typescript const BasicInfoForm: React.FC = ({ form, ... }) => { // 使用 ref 记录更新源,防止循环触发 const updateSourceRef = useRef<'age' | 'dob' | null>(null); // ... 其他代码 }; ``` 3. **添加计算函数** ```typescript // 根据年龄计算出生日期 const calculateDobFromAge = (age: { number: number; unit: 'D' | 'M' | 'Y'; }): Dayjs => { const now = dayjs(); switch (age.unit) { case 'D': return now.subtract(age.number, 'day'); case 'M': return now.subtract(age.number, 'month'); case 'Y': return now.subtract(age.number, 'year'); default: return now; } }; // 根据出生日期计算年龄 const calculateAgeFromDob = ( dob: Dayjs ): { number: number; unit: 'D' | 'M' | 'Y' } => { const now = dayjs(); const years = now.diff(dob, 'year'); if (years >= 1) { return { number: years, unit: 'Y' }; } const months = now.diff(dob, 'month'); if (months >= 1) { return { number: months, unit: 'M' }; } const days = now.diff(dob, 'day'); return { number: Math.max(0, days), unit: 'D' }; }; ``` 4. **监听年龄变化** ```typescript const patient_age = Form.useWatch('patient_age', form); useEffect(() => { // 如果是出生日期联动更新的年龄,跳过 if (updateSourceRef.current === 'dob') { updateSourceRef.current = null; return; } // 如果年龄有效,计算出生日期 if (patient_age && patient_age.number >= 0) { updateSourceRef.current = 'age'; const newDob = calculateDobFromAge(patient_age); form.setFieldsValue({ patient_dob: newDob }); console.log('年龄变化,更新出生日期:', newDob.format('YYYY-MM-DD')); } }, [patient_age, form]); ``` 5. **监听出生日期变化** ```typescript const patient_dob = Form.useWatch('patient_dob', form); useEffect(() => { // 如果是年龄联动更新的出生日期,跳过 if (updateSourceRef.current === 'age') { updateSourceRef.current = null; return; } // 如果出生日期有效,计算年龄 if (patient_dob && dayjs.isDayjs(patient_dob)) { updateSourceRef.current = 'dob'; const newAge = calculateAgeFromDob(patient_dob); form.setFieldsValue({ patient_age: newAge }); console.log('出生日期变化,更新年龄:', newAge); } }, [patient_dob, form]); ``` --- ## 6. E2E 测试场景 ### 测试文件位置 `cypress/e2e/patient/age-dob-sync.cy.ts` ### 6.1 场景1:修改年龄自动更新出生日期 **Given**(前置条件): - 用户已登录系统 - 用户在患者注册页面 - 表单已初始化,年龄为默认值 **When**(操作步骤): 1. 修改年龄数字为 25 2. 选择年龄单位为 "年" **Then**(预期结果): - 出生日期自动更新为 25 年前的日期 - 出生日期字段显示正确的日期格式 - Redux store 中 `patient_dob` 已更新 - 控制台输出日志:"年龄变化,更新出生日期: YYYY-MM-DD" --- ### 6.2 场景2:修改出生日期自动更新年龄 **Given**(前置条件): - 用户已登录系统 - 用户在患者注册页面 - 表单已初始化 **When**(操作步骤): 1. 点击出生日期选择器 2. 选择日期为 2000-01-01 **Then**(预期结果): - 年龄自动更新为正确的年数 - 年龄单位自动设置为 "Y" - Redux store 中 `patient_age` 已更新 - 控制台输出日志:"出生日期变化,更新年龄: {number: X, unit: 'Y'}" --- ### 6.3 场景3:快速连续修改不产生循环 **Given**(前置条件): - 用户已登录系统 - 用户在患者注册页面 **When**(操作步骤): 1. 快速修改年龄为 25 2. 立即修改出生日期 3. 再次修改年龄为 30 **Then**(预期结果): - 每次修改都正确联动 - 不出现无限循环 - 不出现控制台错误 - 最终状态一致 --- ### 6.4 场景4:不同年龄单位的联动 **Given**(前置条件): - 用户已登录系统 - 用户在患者注册页面 **When**(操作步骤): 1. 设置年龄为 6,单位为 "月" 2. 验证出生日期 3. 修改年龄为 15,单位为 "天" 4. 验证出生日期 **Then**(预期结果): - 每次修改后出生日期都正确计算 - 月份和天数的计算都准确 - 联动正常工作 --- ### 6.5 场景5:选择未来日期后修改为过去日期 **Given**(前置条件): - 用户已登录系统 - 用户在患者注册页面 **When**(操作步骤): 1. 选择出生日期为未来的日期(如明年) 2. 验证年龄显示为 0 3. 修改出生日期为过去的日期(如 2000-01-01) 4. 验证年龄正确计算 **Then**(预期结果): - 未来日期时年龄为 0 - 修改为过去日期后年龄正确计算 - 不出现负数年龄 --- ### 6.6 场景6:初始化时的联动 **Given**(前置条件): - 用户已登录系统 - 用户在患者注册页面 **When**(操作步骤): 1. 表单加载完成 2. 检查年龄和出生日期的初始值 **Then**(预期结果): - 如果有默认值,联动正确执行 - 初始状态一致 --- ### 6.7 场景7:表单提交时数据正确 **Given**(前置条件): - 用户已登录系统 - 用户在患者注册页面 - 已修改年龄触发联动 **When**(操作步骤): 1. 填写其他必填项 2. 点击注册按钮 **Then**(预期结果): - Redux store 中的数据包含正确的年龄和出生日期 - 联动更新的数据被正确保存 - 提交的数据格式正确 --- ## 7. 测试验证清单 ### 功能验证 - [ ] 修改年龄时,出生日期自动更新 - [ ] 修改出生日期时,年龄自动更新 - [ ] 不同年龄单位(天、月、年)的计算都正确 - [ ] 不会出现无限循环 - [ ] 快速连续修改不会导致错误 ### UI 验证 - [ ] 联动更新后,UI 显示正确 - [ ] 无闪烁或延迟 - [ ] 表单校验正常工作 ### 边界情况 - [ ] 未来日期的处理(年龄为0) - [ ] 空值的处理 - [ ] 极大年龄的处理(如 999 岁) - [ ] 极小年龄的处理(如 0 天) ### Redux 状态 - [ ] Redux store 正确更新 - [ ] 不会产生多余的 action 分发 - [ ] 状态一致性 --- ## 8. 相关文件清单 ### 需要修改的文件 - `src/pages/patient/components/register.form.tsx` - 添加联动逻辑 ### 需要创建的文件 - `cypress/e2e/patient/age-dob-sync.cy.ts` - E2E 测试 ### 相关参考文件 - `src/states/patient/register/formSlice.ts` - 表单状态管理 - `src/domain/patient/registerLogic.ts` - 注册逻辑(使用表单数据) - `src/validation/patient/registerSchema.ts` - 表单验证规则 --- ## 9. 注意事项 ### 9.1 日期库版本 确保使用的 `dayjs` 版本支持 `diff` 和 `subtract` 方法。 ### 9.2 时区处理 出生日期的计算应该考虑时区问题,建议统一使用 UTC 时间。 ### 9.3 性能考虑 - useEffect 的依赖数组要正确设置 - 避免不必要的计算和渲染 ### 9.4 用户体验 - 联动更新应该是静默的,不需要额外提示 - 更新速度要快,避免用户感觉到延迟 --- ## 10. 未来改进建议 ### 10.1 增强功能 1. **智能单位选择**: - 当用户输入年龄数字时,自动建议合适的单位 - 如输入 365,建议使用"年"而不是"天" 2. **年龄范围限制**: - 添加合理的年龄范围(如 0-150 岁) - 超出范围时给出警告 3. **精确到时分秒**: - 如果需要更精确的出生时间,考虑支持时分秒 ### 10.2 代码优化 1. **抽取为自定义 Hook**: ```typescript const useAgeDobSync = (form: FormInstance) => { // 将联动逻辑封装为独立的 Hook }; ``` 2. **配置化**: - 将字段名、计算规则等配置化 - 便于在不同表单中复用 --- ## 更新记录 | 日期 | 修改人 | 修改内容 | | --------- | ------ | ------------------------------------------ | | 2025/10/7 | - | 创建文档,详细说明年龄和出生日期联动的实现 |