Jelajahi Sumber

feat: 实现年龄和出生日期双向联动功能 close #75

- 添加年龄和出生日期联动逻辑到注册表单
- 使用 useRef 防止循环触发
- 支持天/月/年三种单位自动计算
- 添加完整的 E2E 测试用例
- 创建详细的实现文档

文件变更:
- src/pages/patient/components/register.form.tsx
- cypress/e2e/patient/age-dob-sync.cy.ts
- docs/实现/年龄和出生日期联动.md
sw 1 Minggu lalu
induk
melakukan
6cfc225854

+ 354 - 0
cypress/e2e/patient/age-dob-sync.cy.ts

@@ -0,0 +1,354 @@
+import dayjs from 'dayjs';
+import { mockLoginSuccess } from '../../support/mock/handlers/user';
+
+describe('年龄和出生日期联动功能测试', () => {
+  beforeEach(() => {
+    // 设置登录 Mock
+    mockLoginSuccess();
+
+    // 登录并进入注册页面
+    cy.visit('/');
+    cy.get('[data-testid="username"]').type('admin');
+    cy.get('[data-testid="password"]').type('123456');
+    cy.get('[data-testid="login-button"]').click();
+    cy.wait('@loginSuccess');
+
+    // 导航到患者管理 -> 注册页面
+    cy.get('[data-testid="patient-management"]').click();
+    cy.get('[data-testid="register"]').click();
+    cy.wait(1000); // 等待页面加载
+  });
+
+  describe('场景1:修改年龄自动更新出生日期', () => {
+    it('应该在修改年龄为25年时,自动更新出生日期为25年前', () => {
+      // 获取年龄输入框和出生日期选择器
+      const ageInput = cy.get('[data-testid="patient_age-number"]').should('exist');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]').should('exist');
+      const dobPicker = cy.get('[data-testid="patient_dob"]').should('exist');
+
+      // 修改年龄为25
+      ageInput.clear().type('25');
+      
+      // 确认单位为"年"
+      ageUnitSelect.click();
+      cy.contains('.ant-select-item-option-content', '年').click();
+      
+      // 等待联动更新
+      cy.wait(500);
+
+      // 验证出生日期已更新(应该是25年前的日期)
+      const expectedDate = dayjs().subtract(25, 'year');
+      dobPicker.should(($input) => {
+        const value = $input.val() as string;
+        const selectedDate = dayjs(value);
+        // 允许1天的误差
+        expect(Math.abs(selectedDate.diff(expectedDate, 'day'))).to.be.lessThan(2);
+      });
+
+      // 验证Redux store
+      cy.window().its('store').invoke('getState').its('form').its('formData').its('patient_dob').should('exist');
+    });
+
+    it('应该在修改年龄为6月时,自动更新出生日期为6个月前', () => {
+      const ageInput = cy.get('[data-testid="patient_age-number"]').should('exist');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]').should('exist');
+      const dobPicker = cy.get('[data-testid="patient_dob"]').should('exist');
+
+      // 修改年龄为6月
+      ageInput.clear().type('6');
+      ageUnitSelect.click();
+      cy.contains('.ant-select-item-option-content', '月').click();
+      
+      cy.wait(500);
+
+      // 验证出生日期(应该是6个月前)
+      const expectedDate = dayjs().subtract(6, 'month');
+      dobPicker.should(($input) => {
+        const value = $input.val() as string;
+        const selectedDate = dayjs(value);
+        expect(Math.abs(selectedDate.diff(expectedDate, 'day'))).to.be.lessThan(2);
+      });
+    });
+
+    it('应该在修改年龄为15天时,自动更新出生日期为15天前', () => {
+      const ageInput = cy.get('[data-testid="patient_age-number"]').should('exist');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]').should('exist');
+      const dobPicker = cy.get('[data-testid="patient_dob"]').should('exist');
+
+      // 修改年龄为15天
+      ageInput.clear().type('15');
+      ageUnitSelect.click();
+      cy.contains('.ant-select-item-option-content', '天').click();
+      
+      cy.wait(500);
+
+      // 验证出生日期(应该是15天前)
+      const expectedDate = dayjs().subtract(15, 'day');
+      dobPicker.should(($input) => {
+        const value = $input.val() as string;
+        const selectedDate = dayjs(value);
+        expect(Math.abs(selectedDate.diff(expectedDate, 'day'))).to.be.lessThan(2);
+      });
+    });
+  });
+
+  describe('场景2:修改出生日期自动更新年龄', () => {
+    it('应该在选择出生日期为2000-01-01时,自动计算并更新年龄', () => {
+      const dobPicker = cy.get('[data-testid="patient_dob"]').should('exist');
+      const ageInput = cy.get('[data-testid="patient_age-number"]').should('exist');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]').should('exist');
+
+      // 点击出生日期选择器
+      dobPicker.click();
+      
+      // 选择年份2000
+      cy.get('.ant-picker-year-btn').click();
+      cy.contains('.ant-picker-cell', '2000').click();
+      
+      // 选择月份1月
+      cy.get('.ant-picker-month-btn').click();
+      cy.contains('.ant-picker-cell', '一月').click();
+      
+      // 选择日期1日
+      cy.contains('.ant-picker-cell', '1').first().click();
+      
+      cy.wait(500);
+
+      // 验证年龄自动更新
+      const expectedYears = dayjs().diff(dayjs('2000-01-01'), 'year');
+      ageInput.should('have.value', expectedYears.toString());
+      
+      // 验证单位为"年"
+      ageUnitSelect.should(($select) => {
+        const text = $select.text();
+        expect(text).to.contain('年');
+      });
+    });
+
+    it('应该在选择6个月前的日期时,年龄单位自动设置为"月"', () => {
+      const dobPicker = cy.get('[data-testid="patient_dob"]').should('exist');
+      const ageInput = cy.get('[data-testid="patient_age-number"]').should('exist');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]').should('exist');
+
+      // 选择6个月前的日期
+      const targetDate = dayjs().subtract(6, 'month');
+      
+      dobPicker.click();
+      
+      // 选择年份
+      cy.get('.ant-picker-year-btn').click();
+      cy.contains('.ant-picker-cell', targetDate.year().toString()).click();
+      
+      // 选择月份
+      cy.get('.ant-picker-month-btn').click();
+      const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
+      cy.contains('.ant-picker-cell', monthNames[targetDate.month()]).click();
+      
+      // 选择日期
+      cy.contains('.ant-picker-cell', targetDate.date().toString()).click();
+      
+      cy.wait(500);
+
+      // 验证年龄约为6个月
+      ageInput.should(($input) => {
+        const value = parseInt($input.val() as string);
+        expect(value).to.be.closeTo(6, 1);
+      });
+      
+      // 验证单位为"月"
+      ageUnitSelect.should(($select) => {
+        const text = $select.text();
+        expect(text).to.contain('月');
+      });
+    });
+
+    it('应该在选择未来日期时,年龄显示为0天', () => {
+      const dobPicker = cy.get('[data-testid="patient_dob"]').should('exist');
+      const ageInput = cy.get('[data-testid="patient_age-number"]').should('exist');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]').should('exist');
+
+      // 选择明年的日期
+      const futureDate = dayjs().add(1, 'year');
+      
+      dobPicker.click();
+      cy.get('.ant-picker-year-btn').click();
+      cy.contains('.ant-picker-cell', futureDate.year().toString()).click();
+      cy.get('.ant-picker-month-btn').click();
+      const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
+      cy.contains('.ant-picker-cell', monthNames[futureDate.month()]).click();
+      cy.contains('.ant-picker-cell', futureDate.date().toString()).click();
+      
+      cy.wait(500);
+
+      // 验证年龄为0
+      ageInput.should('have.value', '0');
+      
+      // 验证单位为"天"
+      ageUnitSelect.should(($select) => {
+        const text = $select.text();
+        expect(text).to.contain('天');
+      });
+    });
+  });
+
+  describe('场景3:快速连续修改不产生循环', () => {
+    it('应该能够连续修改年龄和出生日期而不出错', () => {
+      const ageInput = cy.get('[data-testid="patient_age-number"]').should('exist');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]').should('exist');
+      const dobPicker = cy.get('[data-testid="patient_dob"]').should('exist');
+
+      // 第一次:修改年龄为25
+      ageInput.clear().type('25');
+      ageUnitSelect.click();
+      cy.contains('.ant-select-item-option-content', '年').click();
+      cy.wait(300);
+
+      // 第二次:修改出生日期
+      dobPicker.click();
+      cy.get('.ant-picker-year-btn').click();
+      cy.contains('.ant-picker-cell', '2000').click();
+      cy.get('.ant-picker-month-btn').click();
+      cy.contains('.ant-picker-cell', '一月').click();
+      cy.contains('.ant-picker-cell', '1').first().click();
+      cy.wait(300);
+
+      // 第三次:再次修改年龄为30
+      ageInput.clear().type('30');
+      cy.wait(300);
+
+      // 验证最终状态一致
+      ageInput.should('have.value', '30');
+      
+      // 不应该有控制台错误
+      cy.window().then((win) => {
+        expect(win.console.error).not.to.be.called;
+      });
+    });
+  });
+
+  describe('场景4:表单提交时数据正确', () => {
+    it('应该在提交表单时包含正确的年龄和出生日期数据', () => {
+      // 填写必填项
+      cy.get('input[name="accession_number"]').type('ACC001');
+      cy.get('input[name="patient_id"]').type('P001');
+      cy.get('input[name="patient_name"]').type('测试患者');
+
+      // 修改年龄为25岁
+      const ageInput = cy.get('[data-testid="patient_age-number"]');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]');
+      
+      ageInput.clear().type('25');
+      ageUnitSelect.click();
+      cy.contains('.ant-select-item-option-content', '年').click();
+      
+      cy.wait(500);
+
+      // 验证Redux store中的数据
+      cy.window().its('store').invoke('getState').its('form').its('formData').should((formData) => {
+        // 验证年龄数据
+        expect(formData.patient_age).to.exist;
+        expect(formData.patient_age.number).to.equal(25);
+        expect(formData.patient_age.unit).to.equal('Y');
+        
+        // 验证出生日期数据
+        expect(formData.patient_dob).to.exist;
+      });
+    });
+  });
+
+  describe('场景5:边界情况处理', () => {
+    it('应该正确处理年龄为0的情况', () => {
+      const ageInput = cy.get('[data-testid="patient_age-number"]');
+      const dobPicker = cy.get('[data-testid="patient_dob"]');
+
+      // 设置年龄为0
+      ageInput.clear().type('0');
+      cy.wait(500);
+
+      // 出生日期应该是今天
+      dobPicker.should(($input) => {
+        const value = $input.val() as string;
+        const selectedDate = dayjs(value);
+        const today = dayjs();
+        expect(Math.abs(selectedDate.diff(today, 'day'))).to.be.lessThan(1);
+      });
+    });
+
+    it('应该正确处理极大年龄(如100岁)', () => {
+      const ageInput = cy.get('[data-testid="patient_age-number"]');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]');
+      const dobPicker = cy.get('[data-testid="patient_dob"]');
+
+      // 设置年龄为100岁
+      ageInput.clear().type('100');
+      ageUnitSelect.click();
+      cy.contains('.ant-select-item-option-content', '年').click();
+      cy.wait(500);
+
+      // 出生日期应该是100年前
+      const expectedDate = dayjs().subtract(100, 'year');
+      dobPicker.should(($input) => {
+        const value = $input.val() as string;
+        const selectedDate = dayjs(value);
+        expect(Math.abs(selectedDate.diff(expectedDate, 'day'))).to.be.lessThan(2);
+      });
+    });
+
+    it('应该正确处理1岁以下年龄的单位切换', () => {
+      const ageInput = cy.get('[data-testid="patient_age-number"]');
+      const ageUnitSelect = cy.get('[data-testid="patient_age-unit"]');
+
+      // 设置年龄为11月
+      ageInput.clear().type('11');
+      ageUnitSelect.click();
+      cy.contains('.ant-select-item-option-content', '月').click();
+      cy.wait(500);
+
+      // 修改出生日期,触发反向计算
+      const dobPicker = cy.get('[data-testid="patient_dob"]');
+      const targetDate = dayjs().subtract(11, 'month');
+      
+      dobPicker.click();
+      cy.get('.ant-picker-year-btn').click();
+      cy.contains('.ant-picker-cell', targetDate.year().toString()).click();
+      cy.get('.ant-picker-month-btn').click();
+      const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
+      cy.contains('.ant-picker-cell', monthNames[targetDate.month()]).click();
+      cy.contains('.ant-picker-cell', targetDate.date().toString()).click();
+      
+      cy.wait(500);
+
+      // 验证年龄约为11个月
+      ageInput.should(($input) => {
+        const value = parseInt($input.val() as string);
+        expect(value).to.be.closeTo(11, 1);
+      });
+      
+      // 验证单位为"月"
+      ageUnitSelect.should(($select) => {
+        const text = $select.text();
+        expect(text).to.contain('月');
+      });
+    });
+  });
+
+  describe('场景6:初始化时的联动', () => {
+    it('应该在表单加载时保持初始值的一致性', () => {
+      const ageInput = cy.get('[data-testid="patient_age-number"]');
+      const dobPicker = cy.get('[data-testid="patient_dob"]');
+
+      // 验证初始值都存在
+      ageInput.should('have.value');
+      dobPicker.should('have.value');
+
+      // 验证Redux store中的数据
+      cy.window().its('store').invoke('getState').its('form').its('formData').should((formData) => {
+        if (formData.patient_age && formData.patient_dob) {
+          // 如果两个字段都有值,验证它们的一致性
+          expect(formData.patient_age).to.exist;
+          expect(formData.patient_dob).to.exist;
+        }
+      });
+    });
+  });
+});

+ 690 - 0
docs/实现/年龄和出生日期联动.md

@@ -0,0 +1,690 @@
+# 年龄和出生日期联动
+
+## 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<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 问题:为什么会出现循环触发?
+
+假设我们这样实现联动:
+
+```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<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 年龄 → 出生日期
+
+```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<BasicInfoFormProps> = ({ 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 | -      | 创建文档,详细说明年龄和出生日期联动的实现 |

+ 79 - 5
src/pages/patient/components/register.form.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
 import {
   Form,
   Input,
@@ -11,7 +11,7 @@ import {
 import { useIntl, FormattedMessage } from 'react-intl';
 import { registerFormFields } from '@/validation/patient/registerSchema';
 import NumberWithUnit from '@/components/NumberWithUnit';
-import dayjs from 'dayjs';
+import dayjs, { Dayjs } from 'dayjs';
 import { useSelector } from 'react-redux';
 import { RootState } from '@/states/store';
 
@@ -77,15 +77,89 @@ const BasicInfoForm: React.FC<BasicInfoFormProps> = ({
   form,
   onValuesChange,
 }) => {
+  // 使用 ref 记录更新源,防止循环触发
+  const updateSourceRef = useRef<'age' | 'dob' | null>(null);
+
   const patient_age = Form.useWatch('patient_age', form);
-  React.useEffect(() => {
-    console.log('patient_age 变化了:', patient_age); // 每次 NumberWithUnit onChange 都会触发
-  }, [patient_age]);
+  const patient_dob = Form.useWatch('patient_dob', form);
+
   const productName = useSelector(
     (state: RootState) => state.product.productName
   );
 
   const intl = useIntl();
+
+  // 根据年龄计算出生日期
+  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' };
+  };
+
+  // 监听年龄变化
+  useEffect(() => {
+    // 如果是出生日期联动更新的年龄,跳过
+    if (updateSourceRef.current === 'dob') {
+      updateSourceRef.current = null;
+      return;
+    }
+
+    // 如果年龄有效,计算出生日期
+    if (patient_age && patient_age.number >= 0 && form) {
+      updateSourceRef.current = 'age';
+      const newDob = calculateDobFromAge(patient_age);
+      form.setFieldsValue({ patient_dob: newDob });
+      console.log('年龄变化,更新出生日期:', newDob.format('YYYY-MM-DD'));
+    }
+  }, [patient_age, form]);
+
+  // 监听出生日期变化
+  useEffect(() => {
+    // 如果是年龄联动更新的出生日期,跳过
+    if (updateSourceRef.current === 'age') {
+      updateSourceRef.current = null;
+      return;
+    }
+
+    // 如果出生日期有效,计算年龄
+    if (patient_dob && dayjs.isDayjs(patient_dob) && form) {
+      updateSourceRef.current = 'dob';
+      const newAge = calculateAgeFromDob(patient_dob);
+      form.setFieldsValue({ patient_age: newAge });
+      console.log('出生日期变化,更新年龄:', newAge);
+    }
+  }, [patient_dob, form]);
   return (
     <Form
       form={form}