Bläddra i källkod

docs(exam): 添加退出检查逻辑文档和E2E测试用例

- 新增文档:docs/实现/退出检查的逻辑.md
  - 详细说明退出检查的完整流程
  - 包含三种曝光状态的处理逻辑
  - 提供10个测试场景说明

- 新增测试:cypress/e2e/exam/exit-exam-logic.cy.ts
  - 实现10个E2E测试场景
  - 覆盖未曝光、半曝光、全曝光状态
  - 测试退出反馈弹窗交互逻辑
sw 1 månad sedan
förälder
incheckning
92e6395b3c
2 ändrade filer med 1427 tillägg och 0 borttagningar
  1. 697 0
      cypress/e2e/exam/exit-exam-logic.cy.ts
  2. 730 0
      docs/实现/退出检查的逻辑.md

+ 697 - 0
cypress/e2e/exam/exit-exam-logic.cy.ts

@@ -0,0 +1,697 @@
+/**
+ * 测试文件: 检查页面 - 退出检查逻辑
+ * 功能: 验证从检查页面退出时的各种场景和逻辑处理
+ * 
+ * 测试场景:
+ * 1. 未曝光状态直接退出
+ * 2. 半曝光状态 - 继续检查
+ * 3. 半曝光状态 - 保存并完成
+ * 4. 半曝光状态 - 直接中止
+ * 5. 全曝光状态退出
+ * 6. 发生器采集中阻止退出到process
+ * 7. 从检查正常退出到process(非采集)
+ * 8. 边界场景:快速连续点击退出
+ * 
+ * 相关需求文档: docs/实现/退出检查的逻辑.md
+ */
+
+import LoginPage from '../../support/pageObjects/LoginPage';
+import MainPage from '../../support/pageObjects/MainPage';
+import WorklistPage from '../../support/pageObjects/WorklistPage';
+
+describe('检查页面:退出检查逻辑', () => {
+  const loginPage = new LoginPage();
+  const mainPage = new MainPage();
+  const worklistPage = new WorklistPage();
+
+  /**
+   * 通用的登录和进入检查页面的前置步骤
+   */
+  beforeEach(() => {
+    // 登录系统
+    loginPage.visit();
+    loginPage.login('admin', '123456');
+    cy.contains('登录成功').should('be.visible', { timeout: 10000 });
+
+    // 导航到工作列表
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    // 双击第一行进入检查页面
+    worklistPage.findTableAndDoubleClickFirstRow();
+    cy.wait(1000);
+
+    // 验证已进入检查页面
+    cy.get('[data-testid="exam-page"]').should('exist');
+  });
+
+  /**
+   * 测试场景 1:未曝光状态直接退出
+   * 
+   * Given: 用户已登录系统并从工作列表进入检查页面
+   *        未进行任何曝光操作
+   * When: 用户点击导航按钮切换到工作列表
+   * Then: 应该直接退出检查页面,不显示退出反馈弹窗
+   */
+  it('场景1:未曝光状态应该直接退出检查', () => {
+    /**
+     * Given: 验证当前在检查页面
+     */
+    cy.get('[data-testid="exam-page"]').should('exist');
+
+    /**
+     * Given: 验证曝光状态为 "Not Exposed"
+     */
+    cy.window().its('store').invoke('getState')
+      .its('bodyPositionList')
+      .its('exposureStatus')
+      .should('equal', 'Not Exposed');
+
+    /**
+     * When: 点击导航按钮切换到工作列表
+     */
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    /**
+     * Then: 验证成功退出到工作列表页面
+     */
+    cy.url().should('include', '/worklist');
+
+    /**
+     * Then: 验证未显示退出反馈弹窗
+     */
+    cy.get('[role="dialog"]').should('not.exist');
+
+    /**
+     * Then: 验证Redux store中业务流程已切换
+     */
+    cy.window().its('store').invoke('getState')
+      .its('BusinessFlow')
+      .its('currentKey')
+      .should('equal', 'worklist');
+  });
+
+  /**
+   * 测试场景 2:半曝光状态 - 继续检查
+   * 
+   * Given: 用户已登录系统并进入检查页面
+   *        已完成部分体位的曝光
+   * When: 用户点击退出,显示反馈弹窗
+   *       用户点击"继续检查"按钮
+   * Then: 弹窗关闭,停留在检查页面
+   */
+  it('场景2:半曝光状态点击"继续检查"应该停留在检查页面', () => {
+    /**
+     * Given: 模拟半曝光状态
+     * 通过Redux store直接设置状态
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'bodyPositionList/setExposureStatus',
+        payload: 'Half Exposed'
+      });
+    });
+
+    /**
+     * Given: 验证曝光状态为 "Half Exposed"
+     */
+    cy.window().its('store').invoke('getState')
+      .its('bodyPositionList')
+      .its('exposureStatus')
+      .should('equal', 'Half Exposed');
+
+    /**
+     * When: 点击导航按钮尝试退出
+     */
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    /**
+     * Then: 验证显示退出反馈弹窗
+     */
+    cy.get('[role="dialog"]').should('be.visible');
+
+    /**
+     * Then: 验证弹窗标题
+     */
+    cy.get('[role="dialog"]').should('contain', '检查未完成');
+
+    /**
+     * Then: 验证弹窗提示信息
+     */
+    cy.get('[role="dialog"]').should('contain', '当前检查的曝光步骤尚未完成');
+
+    /**
+     * Then: 验证Redux store中弹窗状态
+     */
+    cy.window().its('store').invoke('getState')
+      .its('largeScreen')
+      .its('isFeedbackOpen')
+      .should('be.true');
+
+    /**
+     * When: 点击"继续检查"按钮
+     */
+    cy.get('[role="dialog"]').contains('button', '继续检查').click();
+
+    /**
+     * Then: 验证弹窗关闭
+     */
+    cy.get('[role="dialog"]').should('not.exist');
+
+    /**
+     * Then: 验证仍停留在检查页面
+     */
+    cy.get('[data-testid="exam-page"]').should('exist');
+    cy.url().should('include', '/exam');
+
+    /**
+     * Then: 验证Redux store中弹窗已关闭
+     */
+    cy.window().its('store').invoke('getState')
+      .its('largeScreen')
+      .its('isFeedbackOpen')
+      .should('be.false');
+
+    /**
+     * Then: 验证业务流程仍为exam
+     */
+    cy.window().its('store').invoke('getState')
+      .its('BusinessFlow')
+      .its('currentKey')
+      .should('equal', 'exam');
+  });
+
+  /**
+   * 测试场景 3:半曝光状态 - 保存并完成
+   * 
+   * Given: 用户已登录系统并进入检查页面
+   *        已完成部分体位的曝光
+   * When: 用户点击退出,显示反馈弹窗
+   *       用户点击"保存并完成"按钮
+   * Then: 调用API通知后端(study_status='Completed')
+   *       成功跳转到工作列表页面
+   */
+  it('场景3:半曝光状态点击"保存并完成"应该调用API并退出', () => {
+    /**
+     * Given: Mock API接口
+     */
+    cy.intercept('POST', '/auth/task/inspection/leave', {
+      statusCode: 200,
+      body: {
+        code: '0x000000',
+        description: 'Success',
+        solution: '',
+        data: {}
+      }
+    }).as('suspendOrCompleteStudy');
+
+    /**
+     * Given: 模拟半曝光状态并设置StudyID
+     */
+    cy.window().its('store').then((store) => {
+      const state = store.getState();
+      
+      // 设置曝光状态
+      store.dispatch({
+        type: 'bodyPositionList/setExposureStatus',
+        payload: 'Half Exposed'
+      });
+
+      // 确保有StudyID
+      if (state.bodyPositionList.selectedBodyPosition?.work?.StudyID) {
+        cy.wrap(state.bodyPositionList.selectedBodyPosition.work.StudyID).as('currentStudyId');
+      }
+    });
+
+    /**
+     * When: 点击导航按钮尝试退出
+     */
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    /**
+     * Then: 验证显示退出反馈弹窗
+     */
+    cy.get('[role="dialog"]').should('be.visible');
+
+    /**
+     * When: 点击"保存并完成"按钮
+     */
+    cy.get('[role="dialog"]').contains('button', '保存并完成').click();
+
+    /**
+     * Then: 验证调用了API
+     */
+    cy.wait('@suspendOrCompleteStudy').then((interception) => {
+      // 验证请求参数
+      expect(interception.request.body).to.have.property('study_status', 'Completed');
+      expect(interception.request.body).to.have.property('study_id');
+    });
+
+    /**
+     * Then: 验证弹窗关闭
+     */
+    cy.get('[role="dialog"]').should('not.exist');
+
+    /**
+     * Then: 验证成功跳转到工作列表页面
+     */
+    cy.url().should('include', '/worklist');
+
+    /**
+     * Then: 验证业务流程已切换
+     */
+    cy.window().its('store').invoke('getState')
+      .its('BusinessFlow')
+      .its('currentKey')
+      .should('equal', 'worklist');
+  });
+
+  /**
+   * 测试场景 4:半曝光状态 - 直接中止
+   * 
+   * Given: 用户已登录系统并进入检查页面
+   *        已完成部分体位的曝光
+   * When: 用户点击退出,显示反馈弹窗
+   *       用户点击"直接中止"按钮(危险按钮)
+   * Then: 调用API通知后端(study_status='InProgress')
+   *       成功跳转到工作列表页面
+   */
+  it('场景4:半曝光状态点击"直接中止"应该调用API并退出', () => {
+    /**
+     * Given: Mock API接口
+     */
+    cy.intercept('POST', '/auth/task/inspection/leave', {
+      statusCode: 200,
+      body: {
+        code: '0x000000',
+        description: 'Success',
+        solution: '',
+        data: {}
+      }
+    }).as('suspendOrCompleteStudy');
+
+    /**
+     * Given: 模拟半曝光状态
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'bodyPositionList/setExposureStatus',
+        payload: 'Half Exposed'
+      });
+    });
+
+    /**
+     * When: 点击导航按钮尝试退出
+     */
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    /**
+     * Then: 验证显示退出反馈弹窗
+     */
+    cy.get('[role="dialog"]').should('be.visible');
+
+    /**
+     * Then: 验证"直接中止"按钮为危险样式
+     */
+    cy.get('[role="dialog"]')
+      .contains('button', '直接中止')
+      .should('have.class', 'ant-btn-dangerous');
+
+    /**
+     * When: 点击"直接中止"按钮
+     */
+    cy.get('[role="dialog"]').contains('button', '直接中止').click();
+
+    /**
+     * Then: 验证调用了API
+     */
+    cy.wait('@suspendOrCompleteStudy').then((interception) => {
+      // 验证请求参数
+      expect(interception.request.body).to.have.property('study_status', 'InProgress');
+      expect(interception.request.body).to.have.property('study_id');
+    });
+
+    /**
+     * Then: 验证弹窗关闭
+     */
+    cy.get('[role="dialog"]').should('not.exist');
+
+    /**
+     * Then: 验证成功跳转到工作列表页面
+     */
+    cy.url().should('include', '/worklist');
+  });
+
+  /**
+   * 测试场景 5:全曝光状态退出
+   * 
+   * Given: 用户已登录系统并进入检查页面
+   *        已完成所有体位的曝光
+   * When: 用户点击导航按钮切换到工作列表
+   * Then: 应该直接退出,不显示退出反馈弹窗
+   */
+  it('场景5:全曝光状态应该直接退出检查', () => {
+    /**
+     * Given: 模拟全曝光状态
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'bodyPositionList/setExposureStatus',
+        payload: 'Fully Exposed'
+      });
+    });
+
+    /**
+     * Given: 验证曝光状态为 "Fully Exposed"
+     */
+    cy.window().its('store').invoke('getState')
+      .its('bodyPositionList')
+      .its('exposureStatus')
+      .should('equal', 'Fully Exposed');
+
+    /**
+     * When: 点击导航按钮切换到工作列表
+     */
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    /**
+     * Then: 验证未显示退出反馈弹窗
+     */
+    cy.get('[role="dialog"]').should('not.exist');
+
+    /**
+     * Then: 验证成功退出到工作列表页面
+     */
+    cy.url().should('include', '/worklist');
+
+    /**
+     * Then: 验证业务流程已切换
+     */
+    cy.window().its('store').invoke('getState')
+      .its('BusinessFlow')
+      .its('currentKey')
+      .should('equal', 'worklist');
+  });
+
+  /**
+   * 测试场景 6:发生器采集中阻止退出到process
+   * 
+   * Given: 用户在检查页面
+   *        发生器正在采集(acquisitionState = 1)
+   * When: 用户尝试跳转到图像处理页面(process)
+   * Then: 跳转被阻止,停留在检查页面
+   */
+  it('场景6:发生器采集中应该阻止退出到图像处理', () => {
+    /**
+     * Given: 模拟发生器正在采集
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'generatorMonitor/setAcquisitionState',
+        payload: 1
+      });
+    });
+
+    /**
+     * Given: 验证发生器状态
+     */
+    cy.window().its('store').invoke('getState')
+      .its('generatorMonitor')
+      .its('acquisitionState')
+      .should('equal', 1);
+
+    /**
+     * When: 尝试跳转到图像处理页面
+     * 注意:这里需要根据实际的导航方式调整
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'BusinessFlow/setBusinessFlow',
+        payload: 'process'
+      });
+    });
+
+    /**
+     * Then: 验证仍停留在检查页面
+     */
+    cy.wait(500); // 等待可能的状态变化
+    cy.get('[data-testid="exam-page"]').should('exist');
+
+    /**
+     * Then: 验证业务流程仍为exam
+     */
+    cy.window().its('store').invoke('getState')
+      .its('BusinessFlow')
+      .its('currentKey')
+      .should('equal', 'exam');
+  });
+
+  /**
+   * 测试场景 7:从检查正常退出到process(非采集)
+   * 
+   * Given: 用户在检查页面
+   *        发生器空闲(acquisitionState != 1)
+   *        有已曝光的图像
+   * When: 用户点击进入图像处理页面
+   * Then: 成功跳转到process页面
+   *       执行了清理操作(unprepare)
+   */
+  it('场景7:发生器空闲时应该能正常退出到图像处理', () => {
+    /**
+     * Given: 确保发生器不在采集状态
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'generatorMonitor/setAcquisitionState',
+        payload: 0
+      });
+    });
+
+    /**
+     * Given: 验证发生器状态
+     */
+    cy.window().its('store').invoke('getState')
+      .its('generatorMonitor')
+      .its('acquisitionState')
+      .should('not.equal', 1);
+
+    /**
+     * Given: 监听控制台日志(验证unprepare调用)
+     */
+    cy.window().then((win) => {
+      cy.spy(win.console, 'log').as('consoleLog');
+    });
+
+    /**
+     * When: 点击进入图像处理页面
+     * 注意:这里需要根据实际的导航方式调整
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'BusinessFlow/setBusinessFlow',
+        payload: 'process'
+      });
+    });
+
+    /**
+     * Then: 验证成功跳转到process页面
+     */
+    cy.wait(1000);
+    cy.url().should('include', '/process');
+
+    /**
+     * Then: 验证业务流程已切换
+     */
+    cy.window().its('store').invoke('getState')
+      .its('BusinessFlow')
+      .its('currentKey')
+      .should('equal', 'process');
+
+    /**
+     * Then: 验证控制台输出了相应日志
+     */
+    cy.get('@consoleLog').should('be.calledWith', '从检查退出到处理');
+  });
+
+  /**
+   * 测试场景 8:边界场景 - 快速连续点击退出
+   * 
+   * Given: 用户在检查页面(半曝光状态)
+   * When: 快速连续点击退出按钮(3次)
+   * Then: 只显示一个退出反馈弹窗
+   *       弹窗功能正常
+   */
+  it('场景8:快速连续点击退出应该只显示一个弹窗', () => {
+    /**
+     * Given: 模拟半曝光状态
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'bodyPositionList/setExposureStatus',
+        payload: 'Half Exposed'
+      });
+    });
+
+    /**
+     * When: 快速连续点击退出按钮3次
+     */
+    for (let i = 0; i < 3; i++) {
+      mainPage.clickPatientManagementButton();
+      mainPage.clickWorklistButton();
+      cy.wait(50); // 很短的等待时间,模拟快速点击
+    }
+
+    /**
+     * Then: 验证只显示一个弹窗
+     */
+    cy.get('[role="dialog"]').should('have.length', 1);
+
+    /**
+     * Then: 验证弹窗可见
+     */
+    cy.get('[role="dialog"]').should('be.visible');
+
+    /**
+     * Then: 验证弹窗功能正常(点击"继续检查")
+     */
+    cy.get('[role="dialog"]').contains('button', '继续检查').click();
+    cy.get('[role="dialog"]').should('not.exist');
+
+    /**
+     * Then: 验证仍在检查页面
+     */
+    cy.get('[data-testid="exam-page"]').should('exist');
+  });
+
+  /**
+   * 测试场景 9:边界场景 - StudyID为空的错误处理
+   * 
+   * Given: 用户在检查页面但StudyID为空
+   * When: 用户尝试退出并选择保存
+   * Then: 应该有适当的错误处理
+   */
+  it('场景9:StudyID为空时应该有错误处理', () => {
+    /**
+     * Given: 清空StudyID
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'bodyPositionList/setExposureStatus',
+        payload: 'Half Exposed'
+      });
+
+      // 模拟StudyID为空的情况
+      store.dispatch({
+        type: 'bodyPositionList/clearSelectedBodyPosition'
+      });
+    });
+
+    /**
+     * Given: 监听控制台错误日志
+     */
+    cy.window().then((win) => {
+      cy.spy(win.console, 'error').as('consoleError');
+    });
+
+    /**
+     * When: 尝试退出
+     */
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    /**
+     * Then: 可能显示弹窗或直接显示错误
+     * 根据实际实现调整验证逻辑
+     */
+    cy.get('[role="dialog"]').then(($dialog) => {
+      if ($dialog.length > 0) {
+        // 如果显示了弹窗,尝试点击保存
+        cy.get('[role="dialog"]').contains('button', '保存并完成').click();
+        
+        // 验证控制台输出了错误日志
+        cy.get('@consoleError').should('be.called');
+      }
+    });
+  });
+
+  /**
+   * 测试场景 10:验证退出反馈弹窗UI元素
+   * 
+   * Given: 用户在检查页面(半曝光状态)
+   * When: 触发退出反馈弹窗
+   * Then: 验证弹窗包含所有必要的UI元素
+   */
+  it('场景10:退出反馈弹窗应该包含所有必要的UI元素', () => {
+    /**
+     * Given: 模拟半曝光状态
+     */
+    cy.window().its('store').then((store) => {
+      store.dispatch({
+        type: 'bodyPositionList/setExposureStatus',
+        payload: 'Half Exposed'
+      });
+    });
+
+    /**
+     * When: 触发退出
+     */
+    mainPage.clickPatientManagementButton();
+    mainPage.clickWorklistButton();
+
+    /**
+     * Then: 验证弹窗存在
+     */
+    cy.get('[role="dialog"]').should('be.visible');
+
+    /**
+     * Then: 验证弹窗标题
+     */
+    cy.get('[role="dialog"]').should('contain', '检查未完成');
+
+    /**
+     * Then: 验证警告图标
+     */
+    cy.get('[role="dialog"]').find('.anticon-exclamation-circle').should('exist');
+
+    /**
+     * Then: 验证提示消息
+     */
+    cy.get('[role="dialog"]').should('contain', '当前检查的曝光步骤尚未完成');
+    cy.get('[role="dialog"]').should('contain', '中止后,本次检查的进度将不会保存');
+
+    /**
+     * Then: 验证包含三个按钮
+     */
+    cy.get('[role="dialog"]').find('button').should('have.length', 3);
+
+    /**
+     * Then: 验证"继续检查"按钮
+     */
+    cy.get('[role="dialog"]').contains('button', '继续检查').should('exist');
+
+    /**
+     * Then: 验证"保存并完成"按钮(主按钮)
+     */
+    cy.get('[role="dialog"]')
+      .contains('button', '保存并完成')
+      .should('have.class', 'ant-btn-primary');
+
+    /**
+     * Then: 验证"直接中止"按钮(危险按钮)
+     */
+    cy.get('[role="dialog"]')
+      .contains('button', '直接中止')
+      .should('have.class', 'ant-btn-primary')
+      .should('have.class', 'ant-btn-dangerous');
+
+    /**
+     * 清理:关闭弹窗
+     */
+    cy.get('[role="dialog"]').contains('button', '继续检查').click();
+  });
+});

+ 730 - 0
docs/实现/退出检查的逻辑.md

@@ -0,0 +1,730 @@
+# 退出检查的逻辑
+
+## 1. 概述
+
+本文档详细说明了从检查页面(exam)退出的完整逻辑,包括曝光状态检查、用户反馈、后端通知等流程。
+
+退出检查是一个关键的业务流程,需要根据当前的曝光状态做出不同的处理,以确保检查数据的完整性和用户操作的安全性。
+
+---
+
+## 2. 核心逻辑流程
+
+### 2.1 触发退出的入口
+
+当用户尝试从检查页面离开时(即当前业务流程是 `exam`,目标是其他流程),会触发退出逻辑。
+
+**关键判断函数:**
+
+```typescript
+function isExitingExam(currentAction: string, currentKey: string): boolean {
+  return currentAction !== 'exam' && currentKey === 'exam';
+}
+```
+
+**位置:** `src/states/businessFlowMiddlewareLogic.ts`
+
+### 2.2 退出前的曝光状态检查
+
+系统会检查当前的曝光状态(`exposureStatus`),根据不同状态采取不同的处理策略。
+
+---
+
+## 3. 曝光状态检查
+
+### 3.1 三种曝光状态
+
+| 状态   | 英文          | 说明                       | 处理方式           |
+| ------ | ------------- | -------------------------- | ------------------ |
+| 未曝光 | Not Exposed   | 所有体位都未曝光           | 直接允许退出       |
+| 半曝光 | Half Exposed  | 部分体位已曝光,部分未曝光 | 显示退出反馈弹窗   |
+| 全曝光 | Fully Exposed | 所有体位都已曝光           | 通知后端,允许退出 |
+
+### 3.2 半曝光状态的处理(核心)
+
+**当曝光状态为 "Half Exposed" 时:**
+
+1. **显示退出反馈弹窗** `ExamExitFeedback`
+2. **阻止立即退出** - 不执行业务流程切换
+3. **提供3个选项供用户选择:**
+
+#### 选项1:继续检查
+
+- **按钮文本**: "继续检查"
+- **用户意图**: 留在检查页面,继续完成未曝光的体位
+- **处理逻辑**:
+  ```typescript
+  dispatch(setFeedbackOpen(false)); // 关闭弹窗
+  dispatch(setBusinessFlow('exitExamSuspended')); // 挂起检查
+  ```
+
+#### 选项2:保存并完成
+
+- **按钮文本**: "保存并完成"
+- **按钮样式**: `type="primary"`
+- **用户意图**: 保存已曝光的体位,标记检查为完成状态
+- **处理逻辑**:
+  ```typescript
+  dispatch(setFeedbackOpen(false)); // 关闭弹窗
+  dispatch(setBusinessFlow('exitExamCompleted')); // 完成检查
+  ```
+
+#### 选项3:直接中止
+
+- **按钮文本**: "直接中止"
+- **按钮样式**: `type="primary" danger`
+- **用户意图**: 不保存,直接中止检查
+- **处理逻辑**:
+  ```typescript
+  dispatch(setFeedbackOpen(false)); // 仅关闭弹窗
+  // 注意:这里只关闭弹窗,不改变业务流程
+  ```
+
+---
+
+## 4. 退出处理的两种模式
+
+### 4.1 完成检查(exitExamCompleted)
+
+**业务流程标识**: `'exitExamCompleted'`
+
+**处理逻辑**:
+
+```typescript
+if (action.payload === 'exitExamCompleted') {
+  // 1. 获取当前检查的 StudyID
+  const studyId = state.bodyPositionList.selectedBodyPosition.work.StudyID;
+
+  // 2. 调用API通知后端 - 标记为已完成
+  await suspendOrCompleteStudy(studyId, 'Completed');
+
+  // 3. 跳转到目标业务流程
+  return next({ ...action, payload: continueBusinessFlow });
+}
+```
+
+**API参数**:
+
+- `study_id`: 当前检查的ID
+- `study_status`: `'Completed'` - 标记为已完成
+
+### 4.2 挂起检查(exitExamSuspended)
+
+**业务流程标识**: `'exitExamSuspended'`
+
+**处理逻辑**:
+
+```typescript
+if (action.payload === 'exitExamSuspended') {
+  // 1. 获取当前检查的 StudyID
+  const studyId = state.bodyPositionList.selectedBodyPosition.work.StudyID;
+
+  // 2. 调用API通知后端 - 标记为进行中
+  await suspendOrCompleteStudy(studyId, 'InProgress');
+
+  // 3. 跳转到目标业务流程
+  return next({ ...action, payload: continueBusinessFlow });
+}
+```
+
+**API参数**:
+
+- `study_id`: 当前检查的ID
+- `study_status`: `'InProgress'` - 标记为进行中
+
+### 4.3 目标流程的保存
+
+**全局变量**: `continueBusinessFlow`
+
+**作用**: 保存用户最初想要跳转的目标业务流程
+
+**示例场景**:
+
+1. 用户在检查页面,点击"工作列表"按钮 → `continueBusinessFlow = 'worklist'`
+2. 系统检测到半曝光状态,显示弹窗 → 阻止跳转
+3. 用户选择"保存并完成" → 调用API后,跳转到 `worklist`
+
+---
+
+## 5. 特殊情况处理
+
+### 5.1 从检查进入图像处理(process)
+
+**特殊性**: 曝光操作可能导致自动进入图像处理页面
+
+**判断函数**:
+
+```typescript
+function isFromExamToView(currentAction: string, currentKey: string): boolean {
+  return currentAction === 'process' && currentKey === 'exam';
+}
+```
+
+**处理逻辑**:
+
+```typescript
+if (isFromExamToView(action.payload, currentKey)) {
+  // 检查发生器状态
+  if (store.getState().generatorMonitor.acquisitionState === 1) {
+    // 发生器正在采集 - 阻止退出
+    console.log('发生器正在采集,不能退出');
+    return next(action);
+  } else {
+    // 非曝光导致的退出 - 执行清理
+    console.log('从检查退出到处理');
+    unprepare(); // 清理检查相关资源
+  }
+}
+```
+
+**关键状态**: `generatorMonitor.acquisitionState`
+
+- `1`: 正在采集 - 阻止退出
+- 其他值: 空闲状态 - 允许退出
+
+---
+
+## 6. 核心代码位置
+
+### 6.1 主要文件
+
+| 文件路径                                         | 作用                   | 关键内容                          |
+| ------------------------------------------------ | ---------------------- | --------------------------------- |
+| `src/states/businessFlowMiddlewareLogic.ts`      | 业务流程中间件(核心) | 所有退出判断和处理逻辑            |
+| `src/pages/exam/components/ExamExitFeedback.tsx` | 退出反馈UI组件         | "检查未完成"弹窗                  |
+| `src/pages/exam/LargeScreen.tsx`                 | 大屏幕组件             | 集成退出反馈弹窗                  |
+| `src/states/exam/largeScreenSlice.ts`            | 大屏幕状态管理         | 管理反馈弹窗显示状态              |
+| `src/states/BusinessFlowSlice.ts`                | 业务流程状态管理       | 管理业务流程切换                  |
+| `src/API/patient/workActions.ts`                 | API接口                | `suspendOrCompleteStudy()` 函数   |
+| `src/domain/exam/prepare.ts`                     | 检查准备逻辑           | `prepare()` 和 `unprepare()` 函数 |
+
+### 6.2 代码片段位置
+
+#### 退出判断逻辑
+
+**文件**: `src/states/businessFlowMiddlewareLogic.ts`
+**行数**: 约 200-250 行
+
+```typescript
+if (
+  isExitingExam(action.payload, currentKey) &&
+  action.payload !== 'exitExamCompleted' &&
+  action.payload !== 'exitExamSuspended' &&
+  action.payload !== 'process'
+) {
+  const exposureStatus = store.getState().bodyPositionList.exposureStatus;
+
+  if (exposureStatus === 'Half Exposed') {
+    store.dispatch(setFeedbackOpen(true));
+    continueBusinessFlow = action.payload;
+    return; // 阻止退出
+  }
+}
+```
+
+#### 退出反馈弹窗
+
+**文件**: `src/pages/exam/LargeScreen.tsx`
+**行数**: 约 20-30 行
+
+```typescript
+<ExamExitFeedback
+  open={isFeedbackOpen}
+  onContinue={() => {
+    dispatch(setFeedbackOpen(false));
+    dispatch(setBusinessFlow('exitExamSuspended'));
+  }}
+  onSave={() => {
+    dispatch(setFeedbackOpen(false));
+    dispatch(setBusinessFlow('exitExamCompleted'));
+  }}
+  onAbort={() => dispatch(setFeedbackOpen(false))}
+/>
+```
+
+---
+
+## 7. 退出流程图
+
+### 7.1 完整流程图
+
+```
+用户点击离开exam
+    ↓
+检查是否从exam退出?
+    ↓ (是)
+判断目标页面
+    ├─ process (图像处理)
+    │   ↓
+    │   检查发生器状态
+    │   ├─ acquisitionState === 1 → 阻止退出
+    │   └─ 其他 → unprepare() → 允许退出
+    │
+    └─ 其他页面 (worklist, register, etc.)
+        ↓
+        检查曝光状态
+        ├─ Not Exposed (未曝光)
+        │   ↓
+        │   直接允许退出
+        │
+        ├─ Half Exposed (半曝光)
+        │   ↓
+        │   显示退出反馈弹窗
+        │   ↓
+        │   用户选择
+        │   ├─ 继续检查 → 关闭弹窗,停留在exam
+        │   ├─ 保存并完成 → API调用(Completed) → 跳转到目标页面
+        │   └─ 直接中止 → API调用(InProgress) → 跳转到目标页面
+        │
+        └─ Fully Exposed (全曝光)
+            ↓
+            通知后端 → 允许退出
+```
+
+### 7.2 状态转换图
+
+```
+[Exam Page]
+    |
+    | 用户触发退出
+    ↓
+[检查曝光状态]
+    |
+    ├─→ [Not Exposed] ──────────→ [直接退出]
+    |
+    ├─→ [Half Exposed] ──→ [显示弹窗]
+    |                         |
+    |                         ├─→ [继续检查] → [停留在Exam]
+    |                         ├─→ [保存并完成] → [exitExamCompleted]
+    |                         └─→ [直接中止] → [exitExamSuspended]
+    |
+    └─→ [Fully Exposed] ─────────→ [通知后端] → [退出]
+```
+
+---
+
+## 8. 关键状态变量
+
+### 8.1 Redux State
+
+| State路径                                            | 类型    | 说明                                                       |
+| ---------------------------------------------------- | ------- | ---------------------------------------------------------- |
+| `largeScreen.isFeedbackOpen`                         | boolean | 退出反馈弹窗是否显示                                       |
+| `bodyPositionList.exposureStatus`                    | string  | 曝光状态:'Not Exposed' / 'Half Exposed' / 'Fully Exposed' |
+| `bodyPositionList.selectedBodyPosition.work.StudyID` | string  | 当前检查的Study ID                                         |
+| `generatorMonitor.acquisitionState`                  | number  | 发生器采集状态:1=采集中                                   |
+| `BusinessFlow.currentKey`                            | string  | 当前业务流程                                               |
+
+### 8.2 模块级变量
+
+| 变量名                 | 类型   | 作用                             | 位置                             |
+| ---------------------- | ------ | -------------------------------- | -------------------------------- |
+| `continueBusinessFlow` | string | 保存退出检查后要去的目标业务流程 | `businessFlowMiddlewareLogic.ts` |
+
+---
+
+## 9. API接口说明
+
+### 9.1 suspendOrCompleteStudy
+
+**函数签名**:
+
+```typescript
+const suspendOrCompleteStudy = async (
+  studyId: string,
+  studyStatus: 'InProgress' | 'Completed'
+): Promise<{ code: string; description: string; solution: string; data: {} }>
+```
+
+**API端点**:
+
+```http
+POST /auth/task/inspection/leave
+```
+
+**请求参数**:
+
+```json
+{
+  "study_id": "string",
+  "study_status": "InProgress" | "Completed"
+}
+```
+
+**响应示例**:
+
+```json
+{
+  "code": "0x000000",
+  "description": "Success",
+  "solution": "",
+  "data": {}
+}
+```
+
+**调用场景**:
+
+1. **挂起检查**: `studyStatus = 'InProgress'`
+   - 用户选择"继续检查"后
+   - 用户选择"直接中止"后
+2. **完成检查**: `studyStatus = 'Completed'`
+   - 用户选择"保存并完成"后
+
+---
+
+## 10. 测试场景
+
+### 10.1 场景1:未曝光状态直接退出
+
+**前置条件**:
+
+- 用户已登录系统
+- 从工作列表进入检查页面
+- 未进行任何曝光操作
+
+**测试步骤**:
+
+1. 验证当前在检查页面(`data-testid="exam-page"`存在)
+2. 检查曝光状态为 "Not Exposed"
+3. 点击导航按钮切换到工作列表
+4. 验证成功退出到工作列表页面
+5. 验证未显示退出反馈弹窗
+
+**预期结果**:
+
+- ✅ 直接退出检查页面
+- ✅ 未显示退出反馈弹窗
+- ✅ 成功进入工作列表页面
+
+---
+
+### 10.2 场景2:半曝光状态 - 继续检查
+
+**前置条件**:
+
+- 用户已登录系统
+- 从工作列表进入检查页面
+- 已完成部分体位的曝光(至少1个体位已曝光,至少1个体位未曝光)
+
+**测试步骤**:
+
+1. 验证当前在检查页面
+2. 检查曝光状态为 "Half Exposed"
+3. 点击导航按钮切换到工作列表
+4. 验证显示退出反馈弹窗
+5. 验证弹窗标题为"检查未完成"
+6. 验证弹窗内容包含提示信息
+7. 点击"继续检查"按钮
+8. 验证弹窗关闭
+9. 验证仍停留在检查页面
+
+**预期结果**:
+
+- ✅ 显示退出反馈弹窗
+- ✅ 点击"继续检查"后弹窗关闭
+- ✅ 停留在检查页面,不跳转
+- ✅ Redux store中 `isFeedbackOpen = false`
+- ✅ 业务流程仍为 `exam`
+
+---
+
+### 10.3 场景3:半曝光状态 - 保存并完成
+
+**前置条件**:
+
+- 用户已登录系统
+- 从工作列表进入检查页面
+- 已完成部分体位的曝光
+
+**测试步骤**:
+
+1. 验证当前在检查页面
+2. 记录当前检查的 StudyID
+3. 检查曝光状态为 "Half Exposed"
+4. Mock API `/auth/task/inspection/leave`
+5. 点击导航按钮切换到工作列表
+6. 验证显示退出反馈弹窗
+7. 点击"保存并完成"按钮
+8. 验证弹窗关闭
+9. 验证调用了API,参数为:
+   - `study_id`: 当前StudyID
+   - `study_status`: "Completed"
+10. 验证成功跳转到工作列表页面
+
+**预期结果**:
+
+- ✅ 显示退出反馈弹窗
+- ✅ 点击"保存并完成"后调用API
+- ✅ API参数正确(study_status='Completed')
+- ✅ 成功跳转到目标页面
+- ✅ 弹窗关闭
+
+---
+
+### 10.4 场景4:半曝光状态 - 直接中止
+
+**前置条件**:
+
+- 用户已登录系统
+- 从工作列表进入检查页面
+- 已完成部分体位的曝光
+
+**测试步骤**:
+
+1. 验证当前在检查页面
+2. 记录当前检查的 StudyID
+3. 检查曝光状态为 "Half Exposed"
+4. Mock API `/auth/task/inspection/leave`
+5. 点击导航按钮切换到工作列表
+6. 验证显示退出反馈弹窗
+7. 点击"直接中止"按钮(危险按钮)
+8. 验证弹窗关闭
+9. 验证调用了API,参数为:
+   - `study_id`: 当前StudyID
+   - `study_status`: "InProgress"
+10. 验证成功跳转到工作列表页面
+
+**预期结果**:
+
+- ✅ 显示退出反馈弹窗
+- ✅ "直接中止"按钮为危险样式(红色)
+- ✅ 点击后调用API
+- ✅ API参数正确(study_status='InProgress')
+- ✅ 成功跳转到目标页面
+
+---
+
+### 10.5 场景5:全曝光状态退出
+
+**前置条件**:
+
+- 用户已登录系统
+- 从工作列表进入检查页面
+- 已完成所有体位的曝光
+
+**测试步骤**:
+
+1. 验证当前在检查页面
+2. 检查曝光状态为 "Fully Exposed"
+3. 点击导航按钮切换到工作列表
+4. 验证成功退出到工作列表页面
+5. 验证未显示退出反馈弹窗
+6. 验证后端收到通知(可选)
+
+**预期结果**:
+
+- ✅ 直接退出检查页面
+- ✅ 未显示退出反馈弹窗
+- ✅ 成功进入工作列表页面
+- ✅ 后端收到完成通知(如果有)
+
+---
+
+### 10.6 场景6:发生器采集中阻止退出到process
+
+**前置条件**:
+
+- 用户已登录系统
+- 在检查页面
+- 模拟发生器正在采集(`acquisitionState = 1`)
+
+**测试步骤**:
+
+1. 验证当前在检查页面
+2. 设置发生器状态为采集中
+3. 尝试跳转到图像处理页面(process)
+4. 验证跳转被阻止
+5. 验证仍停留在检查页面
+
+**预期结果**:
+
+- ✅ 发生器采集中时,阻止退出到process
+- ✅ 停留在检查页面
+- ✅ 控制台输出相应日志
+
+---
+
+### 10.7 场景7:从检查正常退出到process(非采集)
+
+**前置条件**:
+
+- 用户已登录系统
+- 在检查页面
+- 发生器空闲(`acquisitionState != 1`)
+- 有已曝光的图像
+
+**测试步骤**:
+
+1. 验证当前在检查页面
+2. 确认发生器不在采集状态
+3. 点击进入图像处理页面
+4. 验证成功跳转到process页面
+5. 验证调用了 `unprepare()` 清理函数
+
+**预期结果**:
+
+- ✅ 成功退出到process页面
+- ✅ 执行了清理操作(unprepare)
+- ✅ 控制台输出相应日志
+
+---
+
+### 10.8 边界场景:快速连续点击退出
+
+**前置条件**:
+
+- 用户已登录系统
+- 在检查页面(半曝光状态)
+
+**测试步骤**:
+
+1. 快速连续点击退出按钮(3次)
+2. 验证只显示一个退出反馈弹窗
+3. 验证弹窗功能正常
+
+**预期结果**:
+
+- ✅ 只显示一个弹窗
+- ✅ 不会出现多个弹窗叠加
+- ✅ 弹窗功能正常
+
+---
+
+### 10.9 边界场景:弹窗显示时切换曝光状态
+
+**前置条件**:
+
+- 用户已登录系统
+- 在检查页面(半曝光状态)
+- 退出反馈弹窗已显示
+
+**测试步骤**:
+
+1. 显示退出反馈弹窗
+2. 模拟后台完成剩余曝光(状态变为全曝光)
+3. 验证弹窗行为
+
+**预期结果**:
+
+- 弹窗仍然显示(或根据业务需求自动关闭)
+- 状态保持一致
+
+---
+
+## 11. 相关Redux Actions和Reducers
+
+### 11.1 Actions
+
+| Action            | 类型   | Payload | 说明                 |
+| ----------------- | ------ | ------- | -------------------- |
+| `setBusinessFlow` | Action | string  | 切换业务流程         |
+| `setFeedbackOpen` | Action | boolean | 控制退出反馈弹窗显示 |
+
+### 11.2 Selectors
+
+推荐创建以下Selectors方便测试和使用:
+
+```typescript
+// 选择曝光状态
+const selectExposureStatus = (state: RootState) =>
+  state.bodyPositionList.exposureStatus;
+
+// 选择当前StudyID
+const selectCurrentStudyId = (state: RootState) =>
+  state.bodyPositionList.selectedBodyPosition?.work?.StudyID;
+
+// 选择反馈弹窗状态
+const selectIsFeedbackOpen = (state: RootState) =>
+  state.largeScreen.isFeedbackOpen;
+
+// 选择发生器状态
+const selectAcquisitionState = (state: RootState) =>
+  state.generatorMonitor.acquisitionState;
+```
+
+---
+
+## 12. 注意事项和最佳实践
+
+### 12.1 错误处理
+
+1. **API调用失败**:
+
+   - 应该有重试机制
+   - 向用户显示错误信息
+   - 记录错误日志
+
+2. **StudyID为空**:
+   - 在调用API前验证StudyID存在
+   - 如果为空,记录错误并阻止退出
+
+### 12.2 用户体验
+
+1. **按钮禁用状态**:
+
+   - API调用期间,按钮应该禁用
+   - 显示Loading状态
+
+2. **清晰的提示信息**:
+
+   - 弹窗文本应该清楚说明当前状态
+   - 按钮文本应该明确表达操作结果
+
+3. **操作确认**:
+   - "直接中止"是危险操作,使用危险样式提醒用户
+
+### 12.3 性能优化
+
+1. **防抖处理**:
+
+   - 退出按钮点击应该有防抖,避免重复触发
+
+2. **状态检查优化**:
+   - 曝光状态应该实时更新
+   - 避免过时的状态导致错误判断
+
+---
+
+## 13. 未来改进建议
+
+### 13.1 功能增强
+
+1. **自动保存**:
+
+   - 定期自动保存检查进度
+   - 减少数据丢失风险
+
+2. **退出历史**:
+
+   - 记录用户的退出操作
+   - 用于审计和分析
+
+3. **智能提示**:
+   - 根据历史操作,智能建议用户操作
+
+### 13.2 代码优化
+
+1. **状态机模式**:
+
+   - 使用状态机管理退出流程
+   - 使逻辑更清晰
+
+2. **抽象公共逻辑**:
+   - 将退出逻辑抽象为独立函数
+   - 提高可测试性和可维护性
+
+---
+
+## 14. 相关文档
+
+- [急诊流程.md](./急诊流程.md) - 急诊模式下的特殊处理
+- [体位全曝光-worklist-双击-进入检查.md](./体位全曝光-worklist-双击-进入检查.md) - 全曝光状态的判断逻辑
+- [注册时体位过滤-需求-实现.md](./注册时体位过滤-需求-实现.md) - 体位选择相关逻辑
+
+---
+
+## 更新记录
+
+| 日期      | 修改人 | 修改内容                         |
+| --------- | ------ | -------------------------------- |
+| 2025/10/7 | -      | 创建文档,详细说明退出检查的逻辑 |