فهرست منبع

feat: 实现切换患者类型时清空已选体位列表功能

- 修改 src/states/patient/viewSelection/index.ts 添加清空selectedViews逻辑
- 新增 docs/实现/注册时体位过滤-需求-实现.md 需求文档
- 新增 cypress/support/mock/handlers/patientRegistration.ts Mock处理器
- 新增 cypress/e2e/patient/register-clear-selected-views.cy.ts E2E测试

close #73
sw 1 هفته پیش
والد
کامیت
e85d14f4bf

+ 409 - 0
cypress/e2e/patient/register-clear-selected-views.cy.ts

@@ -0,0 +1,409 @@
+/**
+ * 测试文件: 患者注册 - 切换患者类型清空已选体位
+ * 功能: 验证切换患者类型时自动清空已选择体位列表
+ * 
+ * 测试场景:
+ * 1. 切换患者类型时清空已选体位列表
+ * 2. 切换协议/体位模式时保持已选体位
+ * 3. 切换身体部位时保持已选体位
+ * 
+ * 相关需求文档: docs/实现/注册时体位过滤-需求-实现.md
+ */
+
+import { mockLoginSuccess } from '../../support/mock/handlers/user';
+import {
+  mockGetMultiplePatientTypes,
+  mockGetBodyPartForHuman,
+  mockGetBodyPartForSpecialType,
+  mockGetViewsForHumanSkull,
+  mockGetProceduresForHumanSkull,
+  mockGetViewsByProcedure,
+} from '../../support/mock/handlers/patientRegistration';
+
+describe('患者注册:切换患者类型清空已选体位列表', () => {
+  beforeEach(() => {
+    // 设置所有必要的 Mock
+    mockLoginSuccess();
+    mockGetMultiplePatientTypes();
+    mockGetBodyPartForHuman();
+    mockGetBodyPartForSpecialType();
+    mockGetViewsForHumanSkull();
+    mockGetProceduresForHumanSkull();
+    mockGetViewsByProcedure();
+
+    // 登录并导航到患者注册页面
+    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();
+  });
+
+  /**
+   * 测试场景 1:切换患者类型时清空已选体位列表
+   * 
+   * Given: 用户已在患者注册页面,已选择患者类型、身体部位并添加了体位
+   * When: 用户切换到不同的患者类型
+   * Then: 已选择体位列表应该被清空
+   */
+  it('应该在切换患者类型时清空已选择的体位列表', () => {
+    /**
+     * Given: 用户在患者注册页面
+     * 步骤1: 页面已加载患者类型列表
+     */
+    cy.wait('@getMultiplePatientTypes');
+
+    /**
+     * When: 选择患者类型 "Human"
+     * 步骤2: 点击患者类型选择器
+     * 步骤3: 选择 "Human" 类型
+     */
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-Human"]').click();
+    cy.wait('@getBodyPartForHuman');
+
+    /**
+     * When: 选择身体部位 "颅骨"
+     * 步骤4: 点击身体部位选择器
+     * 步骤5: 选择 "颅骨"
+     */
+    cy.get('[data-testid="body-part-selector"]').click();
+    cy.get('[data-testid="body-part-option-Human_SKULL"]').click();
+
+    /**
+     * When: 切换到 "体位" 选择模式
+     * 步骤6: 点击 "体位" 标签
+     */
+    cy.get('[data-testid="selection-mode-view"]').click();
+    cy.wait('@getViewsForHumanSkull');
+
+    /**
+     * When: 添加体位到已选列表
+     * 步骤7: 点击第一个体位(颅骨前后位)
+     */
+    cy.get('[data-testid="view-item-View_DX_H_SKULL_AP"]').click();
+
+    /**
+     * Then: 验证已选列表包含 1 个体位
+     * 步骤8: 检查已选列表
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 1);
+
+    cy.get('[data-testid="selected-view-count"]')
+      .should('contain', '1');
+
+    /**
+     * When: 切换患者类型到 "SpecialType"
+     * 步骤9: 打开患者类型选择器
+     * 步骤10: 选择 "SpecialType"
+     */
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-SpecialType"]').click();
+    cy.wait('@getBodyPartForSpecialType');
+
+    /**
+     * Then: 已选择体位列表应该被清空
+     * 步骤11: 验证已选列表为空
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 0);
+
+    cy.get('[data-testid="selected-view-count"]')
+      .should('contain', '0');
+
+    /**
+     * Then: 验证 Redux store 中的状态
+     * 步骤12: 检查 Redux store
+     */
+    cy.window().its('store').invoke('getState')
+      .its('viewSelection')
+      .its('selectedViews')
+      .should('have.length', 0);
+  });
+
+  /**
+   * 测试场景 2:切换协议/体位模式时保持已选体位
+   * 
+   * Given: 用户已选择患者类型、身体部位,并在协议模式下添加了协议
+   * When: 用户切换到体位选择模式
+   * Then: 已选择体位列表应该保持不变
+   */
+  it('应该在切换协议/体位模式时保持已选择的体位列表', () => {
+    /**
+     * Given: 选择患者类型和身体部位
+     */
+    cy.wait('@getMultiplePatientTypes');
+    
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-Human"]').click();
+    cy.wait('@getBodyPartForHuman');
+
+    cy.get('[data-testid="body-part-selector"]').click();
+    cy.get('[data-testid="body-part-option-Human_SKULL"]').click();
+
+    /**
+     * When: 在协议模式下添加协议
+     * 步骤1: 确保在协议模式(默认)
+     * 步骤2: 等待协议列表加载
+     */
+    cy.get('[data-testid="selection-mode-protocol"]').should('have.class', 'active');
+    cy.wait('@getProceduresForHumanSkull');
+
+    /**
+     * When: 点击协议添加体位
+     * 步骤3: 点击协议
+     */
+    cy.get('[data-testid="procedure-item-P_SKULL_AP_LAT"]').click();
+    cy.wait('@getViewsByProcedure');
+
+    /**
+     * Then: 验证已选列表包含体位
+     * 步骤4: 检查已选列表(协议包含2个体位)
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 2);
+
+    /**
+     * When: 切换到体位模式
+     * 步骤5: 点击体位标签
+     */
+    cy.get('[data-testid="selection-mode-view"]').click();
+    cy.wait('@getViewsForHumanSkull');
+
+    /**
+     * Then: 已选列表应该保持不变
+     * 步骤6: 验证已选列表仍然包含 2 个体位
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 2);
+
+    cy.get('[data-testid="selected-view-count"]')
+      .should('contain', '2');
+
+    /**
+     * Then: 验证 Redux store 状态
+     */
+    cy.window().its('store').invoke('getState')
+      .its('viewSelection')
+      .its('selectedViews')
+      .should('have.length', 2);
+  });
+
+  /**
+   * 测试场景 3:切换身体部位时保持已选体位
+   * 
+   * Given: 用户已选择患者类型、身体部位并添加了体位
+   * When: 用户切换到不同的身体部位
+   * Then: 已选择体位列表应该保持不变
+   */
+  it('应该在切换身体部位时保持已选择的体位列表', () => {
+    /**
+     * Given: 选择患者类型 "Human"
+     */
+    cy.wait('@getMultiplePatientTypes');
+    
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-Human"]').click();
+    cy.wait('@getBodyPartForHuman');
+
+    /**
+     * Given: 选择身体部位 "颅骨"
+     */
+    cy.get('[data-testid="body-part-selector"]').click();
+    cy.get('[data-testid="body-part-option-Human_SKULL"]').click();
+
+    /**
+     * Given: 切换到体位模式并添加体位
+     */
+    cy.get('[data-testid="selection-mode-view"]').click();
+    cy.wait('@getViewsForHumanSkull');
+
+    cy.get('[data-testid="view-item-View_DX_H_SKULL_AP"]').click();
+
+    /**
+     * Then: 验证已选列表包含 1 个体位
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 1);
+
+    /**
+     * When: 切换身体部位到 "颈部"
+     * 步骤1: 打开身体部位选择器
+     * 步骤2: 选择 "颈部"
+     */
+    cy.get('[data-testid="body-part-selector"]').click();
+    cy.get('[data-testid="body-part-option-Human_NECK"]').click();
+
+    /**
+     * Then: 已选列表应该保持不变
+     * 步骤3: 验证已选列表仍然包含 1 个体位
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 1);
+
+    cy.get('[data-testid="selected-view-count"]')
+      .should('contain', '1');
+
+    /**
+     * Then: 验证 Redux store 状态
+     */
+    cy.window().its('store').invoke('getState')
+      .its('viewSelection')
+      .its('selectedViews')
+      .should('have.length', 1);
+  });
+
+  /**
+   * 测试场景 4:边界情况 - 已选列表为空时切换患者类型
+   * 
+   * Given: 用户已选择患者类型但未添加任何体位
+   * When: 用户切换到不同的患者类型
+   * Then: 不应该产生错误
+   */
+  it('应该在已选列表为空时安全地切换患者类型', () => {
+    /**
+     * Given: 选择患者类型但不添加体位
+     */
+    cy.wait('@getMultiplePatientTypes');
+    
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-Human"]').click();
+    cy.wait('@getBodyPartForHuman');
+
+    /**
+     * Then: 验证已选列表为空
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 0);
+
+    /**
+     * When: 切换患者类型
+     */
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-SpecialType"]').click();
+    cy.wait('@getBodyPartForSpecialType');
+
+    /**
+     * Then: 应该没有错误,已选列表仍为空
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 0);
+
+    // 验证没有错误提示
+    cy.get('[data-testid="error-message"]').should('not.exist');
+  });
+
+  /**
+   * 测试场景 5:边界情况 - 已选多个体位时切换患者类型
+   * 
+   * Given: 用户已添加多个体位到已选列表
+   * When: 用户切换患者类型
+   * Then: 所有体位都应该被清空
+   */
+  it('应该在切换患者类型时清空所有已选择的体位', () => {
+    /**
+     * Given: 选择患者类型和身体部位
+     */
+    cy.wait('@getMultiplePatientTypes');
+    
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-Human"]').click();
+    cy.wait('@getBodyPartForHuman');
+
+    cy.get('[data-testid="body-part-selector"]').click();
+    cy.get('[data-testid="body-part-option-Human_SKULL"]').click();
+
+    /**
+     * Given: 切换到体位模式并添加多个体位
+     */
+    cy.get('[data-testid="selection-mode-view"]').click();
+    cy.wait('@getViewsForHumanSkull');
+
+    // 添加第一个体位
+    cy.get('[data-testid="view-item-View_DX_H_SKULL_AP"]').click();
+    // 添加第二个体位
+    cy.get('[data-testid="view-item-View_DX_H_SKULL_LAT"]').click();
+
+    /**
+     * Then: 验证已选列表包含 2 个体位
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 2);
+
+    /**
+     * When: 切换患者类型
+     */
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-SpecialType"]').click();
+    cy.wait('@getBodyPartForSpecialType');
+
+    /**
+     * Then: 所有体位都应该被清空
+     */
+    cy.get('[data-testid="selected-views-list"]')
+      .find('[data-testid^="selected-view-"]')
+      .should('have.length', 0);
+
+    cy.get('[data-testid="selected-view-count"]')
+      .should('contain', '0');
+  });
+
+  /**
+   * 测试场景 6:验证控制台日志输出
+   * 
+   * Given: 用户已添加体位
+   * When: 切换患者类型
+   * Then: 应该在控制台输出清空体位的日志
+   */
+  it('应该在切换患者类型时输出正确的控制台日志', () => {
+    /**
+     * 监听控制台日志
+     */
+    cy.window().then((win) => {
+      cy.spy(win.console, 'log').as('consoleLog');
+    });
+
+    /**
+     * Given: 添加体位到已选列表
+     */
+    cy.wait('@getMultiplePatientTypes');
+    
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-Human"]').click();
+    cy.wait('@getBodyPartForHuman');
+
+    cy.get('[data-testid="body-part-selector"]').click();
+    cy.get('[data-testid="body-part-option-Human_SKULL"]').click();
+
+    cy.get('[data-testid="selection-mode-view"]').click();
+    cy.wait('@getViewsForHumanSkull');
+
+    cy.get('[data-testid="view-item-View_DX_H_SKULL_AP"]').click();
+
+    /**
+     * When: 切换患者类型
+     */
+    cy.get('[data-testid="patient-type-selector"]').click();
+    cy.get('[data-testid="patient-type-option-SpecialType"]').click();
+    cy.wait('@getBodyPartForSpecialType');
+
+    /**
+     * Then: 验证控制台输出了清空日志
+     */
+    cy.get('@consoleLog').should('be.calledWith', '患者类型已变更,已选择体位列表已清空');
+  });
+});

+ 341 - 0
cypress/support/mock/handlers/patientRegistration.ts

@@ -0,0 +1,341 @@
+/**
+ * Patient Registration Mock Handlers
+ * 患者注册相关的 mock 处理器
+ */
+
+/**
+ * 获取患者类型列表 - 包含多个类型
+ * 
+ * @description 用于测试切换患者类型的场景
+ * @method GET
+ * @url /dr/api/v1/auth/protocol/patient_type
+ * @access 需要认证
+ * 
+ * @returns {Object[]} data.patient_type_list - 患者类型列表
+ * 
+ * @example
+ * mockGetMultiplePatientTypes();
+ * cy.wait('@getMultiplePatientTypes');
+ */
+export function mockGetMultiplePatientTypes() {
+  cy.intercept('GET', '/dr/api/v1/auth/protocol/patient_type*', {
+    statusCode: 200,
+    body: {
+      code: "0x000000",
+      description: "Success",
+      solution: "",
+      data: {
+        patient_type_list: [
+          {
+            id: "1",
+            patient_type_id: "Human",
+            patient_type_name: "Human",
+            patient_type_local: "Human",
+            patient_type_description: "Human Patient Type",
+            sort: 1,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          },
+          {
+            id: "2",
+            patient_type_id: "SpecialType",
+            patient_type_name: "SpecialType",
+            patient_type_local: "特殊类型",
+            patient_type_description: "Special Patient Type",
+            sort: 2,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          }
+        ]
+      }
+    }
+  }).as('getMultiplePatientTypes');
+}
+
+/**
+ * 获取身体部位列表 - Human患者类型
+ * 
+ * @description 根据患者类型获取身体部位列表
+ * @method GET
+ * @url /dr/api/v1/auth/protocol/body_part
+ * 
+ * @returns {Object[]} data.body_part_list - 身体部位列表
+ * 
+ * @example
+ * mockGetBodyPartForHuman();
+ * cy.wait('@getBodyPartForHuman');
+ */
+export function mockGetBodyPartForHuman() {
+  cy.intercept('GET', '/dr/api/v1/auth/protocol/body_part*patient_type=Human*', {
+    statusCode: 200,
+    body: {
+      code: "0x000000",
+      description: "Success",
+      solution: "",
+      data: {
+        body_part_list: [
+          {
+            id: "1",
+            body_part_id: "Human_SKULL",
+            body_part_name: "颅骨",
+            body_part_local: "颅骨",
+            body_part_description: "Skull",
+            patient_type: "Human",
+            category: "DX",
+            sort: 1,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          },
+          {
+            id: "2",
+            body_part_id: "Human_NECK",
+            body_part_name: "颈部",
+            body_part_local: "颈部",
+            body_part_description: "Neck",
+            patient_type: "Human",
+            category: "DX",
+            sort: 2,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          }
+        ]
+      }
+    }
+  }).as('getBodyPartForHuman');
+}
+
+/**
+ * 获取身体部位列表 - SpecialType患者类型
+ * 
+ * @description 根据患者类型获取身体部位列表
+ * @method GET
+ * @url /dr/api/v1/auth/protocol/body_part
+ * 
+ * @returns {Object[]} data.body_part_list - 身体部位列表
+ * 
+ * @example
+ * mockGetBodyPartForSpecialType();
+ * cy.wait('@getBodyPartForSpecialType');
+ */
+export function mockGetBodyPartForSpecialType() {
+  cy.intercept('GET', '/dr/api/v1/auth/protocol/body_part*patient_type=SpecialType*', {
+    statusCode: 200,
+    body: {
+      code: "0x000000",
+      description: "Success",
+      solution: "",
+      data: {
+        body_part_list: [
+          {
+            id: "3",
+            body_part_id: "Special_HEAD",
+            body_part_name: "头部",
+            body_part_local: "头部",
+            body_part_description: "Head",
+            patient_type: "SpecialType",
+            category: "DX",
+            sort: 1,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          }
+        ]
+      }
+    }
+  }).as('getBodyPartForSpecialType');
+}
+
+/**
+ * 获取体位列表 - Human/颅骨
+ * 
+ * @description 获取指定患者类型和身体部位的体位列表
+ * @method GET
+ * @url /dr/api/v1/auth/protocol/view
+ * 
+ * @returns {Object[]} data.views - 体位列表
+ * 
+ * @example
+ * mockGetViewsForHumanSkull();
+ * cy.wait('@getViewsForHumanSkull');
+ */
+export function mockGetViewsForHumanSkull() {
+  cy.intercept('GET', '/dr/api/v1/auth/protocol/view*patient_type=Human*body_part=Human_SKULL*', {
+    statusCode: 200,
+    body: {
+      code: "0x000000",
+      description: "Success",
+      solution: "",
+      data: {
+        "@type": "type.googleapis.com/dr.protocol.ViewList",
+        count: 2,
+        views: [
+          {
+            internal_id: "View_DX_H_SKULL_AP",
+            view_id: "View_DX_H_SKULL_AP",
+            view_name: "颅骨前后位",
+            view_name_local: "颅骨前后位",
+            view_other_name: "Skull AP",
+            view_description: "颅骨前后位",
+            view_position: "AP",
+            application: "RAD",
+            anatomic_region: "SKULL",
+            patient_type: "Human",
+            body_part_id: "Human_SKULL",
+            view_icon_name: "/Image/Position/Human/skull.ap.table.x.png",
+            modality: "DX",
+            work_station_id: 0,
+            apr_id: "View_DX_H_SKULL_AP",
+            img_proc_id: "View_DX_H_SKULL_AP",
+            sort: 1,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          },
+          {
+            internal_id: "View_DX_H_SKULL_LAT",
+            view_id: "View_DX_H_SKULL_LAT",
+            view_name: "颅骨侧位",
+            view_name_local: "颅骨侧位",
+            view_other_name: "Skull LAT",
+            view_description: "颅骨侧位",
+            view_position: "LAT",
+            application: "RAD",
+            anatomic_region: "SKULL",
+            patient_type: "Human",
+            body_part_id: "Human_SKULL",
+            view_icon_name: "/Image/Position/Human/skull.lat.table.x.png",
+            modality: "DX",
+            work_station_id: 0,
+            apr_id: "View_DX_H_SKULL_LAT",
+            img_proc_id: "View_DX_H_SKULL_LAT",
+            sort: 2,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          }
+        ]
+      }
+    }
+  }).as('getViewsForHumanSkull');
+}
+
+/**
+ * 获取协议列表 - Human/颅骨
+ * 
+ * @description 获取指定患者类型和身体部位的协议列表
+ * @method GET
+ * @url /dr/api/v1/auth/protocol/procedure
+ * 
+ * @returns {Object[]} data.procedures - 协议列表
+ * 
+ * @example
+ * mockGetProceduresForHumanSkull();
+ * cy.wait('@getProceduresForHumanSkull');
+ */
+export function mockGetProceduresForHumanSkull() {
+  cy.intercept('GET', '/dr/api/v1/auth/protocol/procedure*patient_type=Human*body_part=Human_SKULL*', {
+    statusCode: 200,
+    body: {
+      code: "0x000000",
+      description: "Success",
+      solution: "",
+      data: {
+        "@type": "type.googleapis.com/dr.protocol.ProcedureList",
+        count: 1,
+        procedures: [
+          {
+            id: "1",
+            procedure_id: "P_SKULL_AP_LAT",
+            procedure_code: "P_SKULL_AP_LAT",
+            procedure_name: "颅骨前后位+侧位",
+            procedure_name_local: "颅骨前后位+侧位",
+            procedure_other_name: "Skull AP + LAT",
+            procedure_description: "颅骨前后位+侧位",
+            procedure_description_local: "颅骨前后位+侧位",
+            patient_type: "Human",
+            body_part_id: "Human_SKULL",
+            procedure_type: "NORMAL",
+            fast_search: false,
+            protocol_laterality: "U",
+            procedure_category: "Adult",
+            modality: "DX",
+            sort: 1,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          }
+        ]
+      }
+    }
+  }).as('getProceduresForHumanSkull');
+}
+
+/**
+ * 获取协议下的体位列表
+ * 
+ * @description 根据协议ID获取该协议下的体位列表
+ * @method GET
+ * @url /dr/api/v1/auth/protocol/procedure/{procedure_id}/view
+ * 
+ * @returns {Object[]} data.views - 体位列表
+ * 
+ * @example
+ * mockGetViewsByProcedure();
+ * cy.wait('@getViewsByProcedure');
+ */
+export function mockGetViewsByProcedure() {
+  cy.intercept('GET', '/dr/api/v1/auth/protocol/procedure/*/view*', {
+    statusCode: 200,
+    body: {
+      code: "0x000000",
+      description: "Success",
+      solution: "",
+      data: {
+        "@type": "type.googleapis.com/dr.protocol.ViewList",
+        count: 2,
+        views: [
+          {
+            internal_id: "View_DX_H_SKULL_AP",
+            view_id: "View_DX_H_SKULL_AP",
+            view_name: "颅骨前后位",
+            view_name_local: "颅骨前后位",
+            view_other_name: "Skull AP",
+            view_description: "颅骨前后位",
+            view_position: "AP",
+            application: "RAD",
+            anatomic_region: "SKULL",
+            patient_type: "Human",
+            body_part_id: "Human_SKULL",
+            modality: "DX",
+            sort: 1,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          },
+          {
+            internal_id: "View_DX_H_SKULL_LAT",
+            view_id: "View_DX_H_SKULL_LAT",
+            view_name: "颅骨侧位",
+            view_name_local: "颅骨侧位",
+            view_other_name: "Skull LAT",
+            view_description: "颅骨侧位",
+            view_position: "LAT",
+            application: "RAD",
+            anatomic_region: "SKULL",
+            patient_type: "Human",
+            body_part_id: "Human_SKULL",
+            modality: "DX",
+            sort: 2,
+            is_enabled: true,
+            product: "DROS",
+            is_pre_install: true
+          }
+        ]
+      }
+    }
+  }).as('getViewsByProcedure');
+}

+ 460 - 0
docs/实现/注册时体位过滤-需求-实现.md

@@ -0,0 +1,460 @@
+# 注册时体位过滤 - 需求与实现
+
+## 1. 需求描述
+
+### 业务场景
+
+在患者注册流程中,用户需要选择患者类型、身体部位,然后选择体位(View)或协议(Protocol)。已选择的体位会被添加到"已选择体位列表"中。
+
+### 具体需求
+
+**当用户切换患者类型时,需要自动清空已选择的体位列表(selectedViews)**
+
+- ✅ **切换患者类型** → 清空已选择体位列表
+- ❌ **切换协议/体位模式** → 不清空已选择体位列表
+- ❌ **切换身体部位** → 不清空已选择体位列表
+
+### 业务逻辑
+
+不同患者类型对应不同的体位选项,当患者类型改变时,之前选择的体位可能不再适用于新的患者类型,因此需要清空已选择的体位列表,让用户重新选择。
+
+---
+
+## 2. Redux 架构分析
+
+### 2.1 涉及的 Slices
+
+#### `patientTypeSlice.ts` - 患者类型切片
+
+**位置**: `src/states/patientTypeSlice.ts`
+
+**State 结构**:
+
+```typescript
+interface PatientTypeState {
+  items: PatientType[]; // 所有患者类型列表
+  loading: boolean;
+  error: string | null;
+  current: PatientType | null; // 当前选中的患者类型
+}
+```
+
+**关键 Action**:
+
+- `setCurrentPatientType: createAction<PatientType | null>` - 设置当前患者类型
+  - 这是一个独立创建的 action,供其他 slice 监听
+
+#### `bodyPartSlice.ts` - 身体部位切片
+
+**位置**: `src/states/bodyPartSlice.ts`
+
+**State 结构**:
+
+```typescript
+interface BodyPartState {
+  items: BodyPart[]; // 所有身体部位列表
+  loading: boolean;
+  error: string | null;
+  byPatientType: BodyPart[]; // 根据患者类型过滤后的身体部位
+  current: BodyPart | null; // 当前选中的身体部位
+}
+```
+
+**关键 Reducer**:
+
+- `setCurrentBodyPart` - 设置当前身体部位
+
+**监听关系**:
+
+- 监听 `setCurrentPatientType`,根据患者类型过滤身体部位列表
+
+#### `viewSelection/index.ts` - 体位选择切片(核心)
+
+**位置**: `src/states/patient/viewSelection/index.ts`
+
+**State 结构**:
+
+```typescript
+interface ViewSelectionState {
+  selectedViews: ExtendedView[]; // ⭐ 已选择体位列表(本次需求要清空的)
+  availableViews: View[]; // 待选择体位列表
+  protocols: Procedure[]; // 协议列表
+  currentBodyPart: BodyPart | null;
+  currentPatientType: PatientType | null;
+  currentSelectionType: { selected: 'protocol' | 'view' };
+}
+```
+
+**关键 Reducers**:
+
+- `addSelectedView` - 添加单个体位到已选列表
+- `clearSelectedViews` - 清空已选体位列表
+- `removeSelectedView` - 移除单个已选体位
+
+**监听关系**:
+
+- 监听 `setCurrentPatientType` - 更新当前患者类型,清空协议和待选体位
+- 监听 `setCurrentBodyPart` - 更新当前身体部位,清空协议和待选体位
+- 监听 `setSelected` - 更新选择模式(协议/体位)
+
+#### `SelectionTypeSlice.ts` - 选择类型切片
+
+**位置**: `src/states/patient/register/SelectionTypeSlice.ts`
+
+**State 结构**:
+
+```typescript
+interface SelectionState {
+  selected: 'protocol' | 'view'; // 当前选择模式:协议或体位
+}
+```
+
+**关键 Action**:
+
+- `setSelected` - 切换选择模式
+
+---
+
+### 2.2 Actions 说明
+
+#### 独立创建的 Actions(供跨 slice 监听)
+
+```typescript
+// 在 patientTypeSlice.ts 中
+export const setCurrentPatientType = createAction<PatientType | null>(
+  'patientType/setCurrentPatientType'
+);
+```
+
+这个 action 被多个 slice 监听:
+
+- `bodyPartSlice` - 过滤身体部位
+- `viewSelectionSlice` - 清空协议和待选体位
+
+#### Reducer Actions
+
+```typescript
+// 在各自的 slice 中定义
+setCurrentBodyPart; // bodyPartSlice
+setSelected; // SelectionTypeSlice
+addSelectedView; // viewSelectionSlice
+clearSelectedViews; // viewSelectionSlice
+```
+
+---
+
+## 3. 数据流动关系
+
+### 3.1 用户操作流程
+
+```
+用户选择患者类型
+    ↓
+dispatch(setCurrentPatientType(patientType))
+    ↓
+    ├─→ patientTypeSlice
+    │   └─→ 更新 state.current
+    │
+    ├─→ bodyPartSlice (监听)
+    │   └─→ 过滤身体部位: state.byPatientType = filter(items, patientTypeId)
+    │   └─→ 清空当前选择: state.current = null
+    │
+    └─→ viewSelectionSlice (监听)
+        └─→ 更新 state.currentPatientType
+        └─→ 清空 state.protocols = []
+        └─→ 清空 state.availableViews = []
+        └─→ ⭐ 需要添加:清空 state.selectedViews = []
+```
+
+### 3.2 现有监听逻辑(问题所在)
+
+**在 `viewSelectionSlice` 的 extraReducers 中**:
+
+```typescript
+.addCase(setCurrentPatientType, (state, action) => {
+  const currentPatientType = action.payload;
+  if (currentPatientType) {
+    state.currentPatientType = currentPatientType;
+  } else {
+    // 患者类型被清空,清空相关数据
+    state.currentPatientType = null;
+    state.protocols = [];
+    state.availableViews = [];
+    console.log('患者类型已清空,协议和体位列表已重置');
+  }
+  // ⚠️ 问题:这里没有清空 selectedViews
+})
+```
+
+**问题**: 当患者类型改变时,只清空了 `protocols` 和 `availableViews`,但没有清空 `selectedViews`(已选择体位列表)。
+
+---
+
+## 4. 实现方案
+
+### 4.1 修改位置
+
+**文件**: `src/states/patient/viewSelection/index.ts`
+
+**修改的监听器**: `setCurrentPatientType` 的 case 处理
+
+### 4.2 具体代码改动
+
+**修改前**:
+
+```typescript
+.addCase(setCurrentPatientType, (state, action) => {
+  const currentPatientType = action.payload;
+  if (currentPatientType) {
+    state.currentPatientType = currentPatientType;
+  } else {
+    // 患者类型被清空,清空相关数据
+    state.currentPatientType = null;
+    state.protocols = [];
+    state.availableViews = [];
+    console.log('患者类型已清空,协议和体位列表已重置');
+  }
+})
+```
+
+**修改后**:
+
+```typescript
+.addCase(setCurrentPatientType, (state, action) => {
+  const currentPatientType = action.payload;
+  if (currentPatientType) {
+    state.currentPatientType = currentPatientType;
+  } else {
+    // 患者类型被清空,清空相关数据
+    state.currentPatientType = null;
+    state.protocols = [];
+    state.availableViews = [];
+    console.log('患者类型已清空,协议和体位列表已重置');
+  }
+  // ⭐ 新增:无论是选择新患者类型还是清空,都清空已选择体位列表
+  state.selectedViews = [];
+})
+```
+
+### 4.3 修改说明
+
+在 `setCurrentPatientType` 监听器的 `if-else` 代码块**之后**,添加一行:
+
+```typescript
+state.selectedViews = [];
+```
+
+**为什么放在 if-else 之后?**
+
+- 无论是选择新的患者类型(`if` 分支)
+- 还是清空患者类型(`else` 分支)
+- 都需要清空已选择的体位列表
+
+这样可以避免代码重复。
+
+### 4.4 不需要修改的地方
+
+1. **`setCurrentBodyPart` 监听器** - 保持不变
+
+   - 理由:切换身体部位时,不需要清空已选择体位
+
+2. **`setSelected` 监听器** - 保持不变
+
+   - 理由:切换协议/体位模式时,不需要清空已选择体位
+
+3. **其他任何文件** - 都不需要修改
+
+---
+
+## 5. 测试验证
+
+### 5.1 E2E 测试场景(Given-When-Then)
+
+#### 测试场景 1:切换患者类型时清空已选体位列表
+
+**Given**(前置条件):
+
+- 用户已登录系统
+- 用户在患者注册页面
+- 系统已加载患者类型列表
+- 系统已加载身体部位列表
+
+**When**(操作步骤):
+
+1. 选择患者类型 "Human"
+2. 选择身体部位 "颅骨"
+3. 切换到"体位"选择模式
+4. 添加体位 "颅骨前后位" 到已选列表
+5. 验证已选列表包含 1 个体位
+6. 切换患者类型到 "SpecialType"
+
+**Then**(预期结果):
+
+- 已选择体位列表被清空(长度为 0)
+- Redux store 中 `selectedViews` 为空数组
+- 控制台输出:"患者类型已变更,已选择体位列表已清空"
+
+---
+
+#### 测试场景 2:切换协议/体位模式时保持已选体位
+
+**Given**(前置条件):
+
+- 用户已登录系统
+- 用户在患者注册页面
+- 已选择患者类型 "Human"
+- 已选择身体部位 "颅骨"
+- 当前在"协议"选择模式
+
+**When**(操作步骤):
+
+1. 在"协议"模式下添加协议
+2. 验证已选列表包含协议对应的体位
+3. 切换到"体位"选择模式
+
+**Then**(预期结果):
+
+- 已选择体位列表保持不变
+- Redux store 中 `selectedViews` 保持原有内容
+- 体位数量不变
+
+---
+
+#### 测试场景 3:切换身体部位时保持已选体位
+
+**Given**(前置条件):
+
+- 用户已登录系统
+- 用户在患者注册页面
+- 已选择患者类型 "Human"
+- 已选择身体部位 "颅骨"
+
+**When**(操作步骤):
+
+1. 添加体位 "颅骨前后位" 到已选列表
+2. 验证已选列表包含 1 个体位
+3. 切换身体部位到 "颈部"
+
+**Then**(预期结果):
+
+- 已选择体位列表保持不变
+- Redux store 中 `selectedViews` 保持原有内容
+- 体位数量仍为 1
+
+---
+
+### 5.2 单元测试(Redux Slice)
+
+#### 测试 viewSelectionSlice reducer
+
+```typescript
+describe('viewSelectionSlice - setCurrentPatientType', () => {
+  it('should clear selectedViews when patient type changes', () => {
+    const initialState = {
+      selectedViews: [
+        { view_id: 'View_1', guid: 'guid-1' },
+        { view_id: 'View_2', guid: 'guid-2' },
+      ],
+      availableViews: [],
+      protocols: [],
+      currentBodyPart: null,
+      currentPatientType: { patient_type_id: 'Human' },
+      currentSelectionType: { selected: 'protocol' },
+    };
+
+    const newPatientType = {
+      patient_type_id: 'SpecialType',
+      patient_type_name: 'SpecialType',
+    };
+
+    const action = setCurrentPatientType(newPatientType);
+    const newState = viewSelectionReducer(initialState, action);
+
+    expect(newState.selectedViews).toEqual([]);
+    expect(newState.currentPatientType).toEqual(newPatientType);
+  });
+});
+```
+
+### 5.3 验证清单
+
+**功能验证**:
+
+- [ ] 切换患者类型时,`selectedViews` 被清空
+- [ ] 切换协议/体位模式时,`selectedViews` 保持不变
+- [ ] 切换身体部位时,`selectedViews` 保持不变
+- [ ] 控制台日志正确输出状态变化
+
+**UI 验证**:
+
+- [ ] 已选体位列表 UI 在患者类型切换后清空
+- [ ] 体位数量显示正确更新
+- [ ] 无 UI 错误或闪烁
+
+**边界情况**:
+
+- [ ] 已选列表为空时切换患者类型,不报错
+- [ ] 已选列表有多个体位时切换患者类型,全部清空
+- [ ] 快速连续切换患者类型,状态保持一致
+
+---
+
+## 6. 相关文件清单
+
+### 需要修改的文件
+
+- `src/states/patient/viewSelection/index.ts`
+
+### 相关参考文件
+
+- `src/states/patientTypeSlice.ts`
+- `src/states/bodyPartSlice.ts`
+- `src/states/patient/register/SelectionTypeSlice.ts`
+- `src/domain/patient/registerLogic.ts` (使用 selectedViews 的地方)
+
+---
+
+## 7. 注意事项
+
+1. **Redux Toolkit 的 Immer 集成**:
+
+   - 可以直接使用 `state.selectedViews = []` 来修改状态
+   - Redux Toolkit 会自动处理不可变性
+
+2. **Action 监听顺序**:
+
+   - `setCurrentPatientType` 是一个独立创建的 action
+   - 多个 slice 可以同时监听同一个 action
+   - 监听器之间没有执行顺序保证
+
+3. **日志输出**:
+   - 建议在清空 `selectedViews` 后添加日志,方便调试
+   - 例如:`console.log('患者类型已变更,已选择体位列表已清空')`
+
+---
+
+## 8. 扩展思考
+
+### 是否需要提示用户?
+
+当切换患者类型导致已选体位被清空时,是否需要:
+
+- 弹出确认对话框?
+- 显示提示消息?
+- 还是静默清空?
+
+**建议**: 根据业务需要决定。如果用户可能误操作,建议添加确认对话框。
+
+### 其他可能的改进
+
+1. 添加"撤销"功能,允许用户恢复上一次的选择
+2. 在切换患者类型前,保存当前选择到临时状态
+3. 如果用户频繁切换,考虑添加节流或防抖
+
+---
+
+## 更新记录
+
+| 日期      | 修改人 | 修改内容                     |
+| --------- | ------ | ---------------------------- |
+| 2025/10/7 | -      | 创建文档,定义需求和实现方案 |

+ 3 - 0
src/states/patient/viewSelection/index.ts

@@ -143,6 +143,9 @@ const viewSelectionSlice = createSlice({
           state.availableViews = [];
           console.log('患者类型已清空,协议和体位列表已重置');
         }
+        // 无论是选择新患者类型还是清空,都清空已选择体位列表
+        state.selectedViews = [];
+        console.log('患者类型已变更,已选择体位列表已清空');
       })
       .addCase(setCurrentBodyPart, (state, action) => {
         const currentBodyPart = action.payload;