年龄和出生日期联动.md 16 KB

年龄和出生日期联动

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 结构:

interface FormState {
  formData: Record<string, any>;
}

关键字段:

  • patient_age: { number: number, unit: 'D' | 'M' | 'Y' } - 患者年龄
  • patient_dob: Dayjs - 患者出生日期

关键 Action:

  • setFormData(payload: Record<string, any>) - 更新整个表单数据对象

2.2 数据流关系

用户修改年龄/出生日期
    ↓
Form onChange 触发
    ↓
组件内部联动逻辑
    ├─→ 计算关联字段值
    └─→ form.setFieldsValue() 更新表单
    ↓
onValuesChange 回调触发
    ↓
dispatch(setFormData(allValues))
    ↓
Redux store.form.formData 更新

3. 防止循环触发的原理

3.1 问题:为什么会出现循环触发?

假设我们这样实现联动:

// ❌ 错误示例 - 会导致无限循环
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 标志位

方案:记录更新源(推荐)

const BasicInfoForm: React.FC<BasicInfoFormProps> = ({ 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 (
    <Form form={form}>
      {/* 表单字段 */}
    </Form>
  );
};

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 年龄 → 出生日期

/**
 * 根据年龄计算出生日期
 * @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 出生日期 → 年龄

/**
 * 根据出生日期计算年龄
 * @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. 引入必要的依赖
import React, { useEffect, useRef } from 'react';
import dayjs, { Dayjs } from 'dayjs';
  1. 添加 useRef 标志位
const BasicInfoForm: React.FC<BasicInfoFormProps> = ({ form, ... }) => {
  // 使用 ref 记录更新源,防止循环触发
  const updateSourceRef = useRef<'age' | 'dob' | null>(null);

  // ... 其他代码
};
  1. 添加计算函数
// 根据年龄计算出生日期
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' };
};
  1. 监听年龄变化
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]);
  1. 监听出生日期变化
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 版本支持 diffsubtract 方法。

9.2 时区处理

出生日期的计算应该考虑时区问题,建议统一使用 UTC 时间。

9.3 性能考虑

  • useEffect 的依赖数组要正确设置
  • 避免不必要的计算和渲染

9.4 用户体验

  • 联动更新应该是静默的,不需要额外提示
  • 更新速度要快,避免用户感觉到延迟

10. 未来改进建议

10.1 增强功能

  1. 智能单位选择

    • 当用户输入年龄数字时,自动建议合适的单位
    • 如输入 365,建议使用"年"而不是"天"
  2. 年龄范围限制

    • 添加合理的年龄范围(如 0-150 岁)
    • 超出范围时给出警告
  3. 精确到时分秒

    • 如果需要更精确的出生时间,考虑支持时分秒

10.2 代码优化

  1. 抽取为自定义 Hook
   const useAgeDobSync = (form: FormInstance) => {
     // 将联动逻辑封装为独立的 Hook
   };
  1. 配置化
    • 将字段名、计算规则等配置化
    • 便于在不同表单中复用

更新记录

日期 修改人 修改内容
2025/10/7 - 创建文档,详细说明年龄和出生日期联动的实现