图像状态控制功能.md 13 KB

图像状态控制功能实现文档

1. 功能概述

在图像处理界面(view 模式)的 OperationPanel 底部,实现图像状态控制功能,包括:

  • 拒绝按钮:将当前图像标记为拒绝状态
  • 恢复按钮:将已拒绝的图像恢复为接受状态
  • 另存为按钮:保存图像副本(UI 占位,暂不实现功能)

业务规则

  1. 拒绝和恢复按钮互斥显示,基于当前选中图像的 judged_status
  2. 仅在单分格模式(1x1)下显示拒绝/恢复按钮
  3. 多分格模式下,拒绝/恢复按钮不可见
  4. 另存为按钮始终可见但禁用

2. 架构设计

2.1 参与者

UI 组件层

  • OperationPanel (src/pages/view/components/OperationPanel.tsx)

    • 职责:父容器组件,Layout 结构
  • ImageStateControl (src/pages/view/components/ImageStateControl.tsx) - 新建

    • 职责:图像状态控制组件,渲染按钮并处理用户操作

状态管理层(Redux)

  • viewerContainerSlice (src/states/view/viewerContainerSlice.ts)

    • 职责:管理分格布局、选中的 viewer
    • 提供:selectGridLayout, selectSelectedViewers
  • bodyPositionListSlice (src/states/exam/bodyPositionListSlice.ts) - 需修改

    • 职责:管理体位列表、图像数据、判断状态
    • 新增:judgeImageThunk 异步操作
    • 新增:loading/error 状态字段

API 层

  • judgeImage (src/API/exam/judgeImage.ts)
    • 已存在的接受/拒绝图像 API

3. 数据流设计

3.1 数据流图

flowchart TD
    A[用户点击拒绝/恢复按钮] --> B[ImageStateControl 组件]
    B --> C{获取状态}
    C --> D[viewerContainerSlice<br/>selectedViewers, gridLayout]
    C --> E[bodyPositionList<br/>judged_status, sopInstanceUid]
    
    B --> F[dispatch judgeImageThunk]
    F --> G[bodyPositionListSlice]
    G --> H[调用 judgeImage API]
    H --> I{API 结果}
    
    I -->|成功| J[extraReducers.fulfilled<br/>更新 judged_status]
    I -->|失败| K[extraReducers.rejected<br/>设置 error]
    
    J --> L[Redux 通知状态变更]
    K --> L
    L --> B
    B --> M[重新渲染按钮]

3.2 序列图

sequenceDiagram
    participant User as 用户
    participant UI as ImageStateControl
    participant VCS as viewerContainerSlice
    participant BPL as bodyPositionListSlice
    participant API as judgeImage API

    Note over UI: 组件初始化
    UI->>VCS: selectSelectedViewers()
    VCS-->>UI: [imageUrl]
    UI->>VCS: selectGridLayout()
    VCS-->>UI: '1x1'
    UI->>BPL: 读取 bodyPositions
    BPL-->>UI: {judged_status: '', sopInstanceUid}
    
    Note over UI: 决定按钮可见性
    alt gridLayout === '1x1'
        alt judged_status === 'Reject'
            UI->>UI: 渲染"恢复"按钮
        else
            UI->>UI: 渲染"拒绝"按钮
        end
    else
        UI->>UI: 不渲染拒绝/恢复按钮
    end
    
    Note over User: 用户点击"拒绝"按钮
    User->>UI: onClick
    UI->>BPL: dispatch judgeImageThunk({sopInstanceUid, accept: false})
    BPL->>BPL: pending → loading = true
    BPL->>API: judgeImage(sopInstanceUid, false)
    API-->>BPL: 成功
    BPL->>BPL: fulfilled → 更新 judged_status = 'Reject'
    BPL->>BPL: loading = false
    BPL-->>UI: 状态变更通知
    UI->>User: 显示成功提示
    UI->>UI: 重新渲染,显示"恢复"按钮

4. 数据结构设计

4.1 judged_status 类型

/**
 * 图像判断状态
 * - 'Accept': 已接受
 * - 'Reject': 已拒绝
 * - '': 未判断
 */
type ImageJudgedStatus = 'Accept' | 'Reject' | '';

4.2 bodyPositionListSlice 新增字段

interface BodyPositionListState {
  bodyPositions: BodyPosition[];
  selectedBodyPosition: BodyPosition | null;
  loading: boolean;  // 新增
  error: string | null;  // 新增
}

4.3 judgeImageThunk 参数

interface JudgeImagePayload {
  sopInstanceUid: string;
  accept: boolean;
}

interface JudgeImageResult {
  sopInstanceUid: string;
  judgedStatus: ImageJudgedStatus;
}

5. 实现细节

5.1 ImageStateControl 组件

文件: src/pages/view/components/ImageStateControl.tsx

核心逻辑:

const ImageStateControl = () => {
  const dispatch = useDispatch();
  
  // 获取状态
  const selectedViewers = useSelector(selectSelectedViewers);
  const gridLayout = useSelector(selectGridLayout);
  const bodyPositionList = useSelector((state: RootState) => 
    state.bodyPositionList.bodyPositions
  );
  const loading = useSelector((state: RootState) => 
    state.bodyPositionList.loading
  );

  // 查找当前选中的图像
  const selectedImage = useMemo(() => {
    if (selectedViewers.length === 0) return null;
    const imageUrl = selectedViewers[0];
    return bodyPositionList.find(
      bp => getDcmImageUrl(bp.sop_instance_uid) === imageUrl
    );
  }, [selectedViewers, bodyPositionList]);

  // 判断按钮可见性
  const isSingleGrid = gridLayout === '1x1';
  const judgedStatus = selectedImage?.dview?.judged_status || '';
  const showRejectButton = isSingleGrid && judgedStatus !== 'Reject';
  const showRestoreButton = isSingleGrid && judgedStatus === 'Reject';

  // 按钮处理函数
  const handleReject = async () => {
    if (!selectedImage) return;
    try {
      await dispatch(judgeImageThunk({
        sopInstanceUid: selectedImage.sop_instance_uid,
        accept: false
      })).unwrap();
      message.success('图像已拒绝');
    } catch (err) {
      message.error('拒绝图像失败');
    }
  };

  const handleRestore = async () => {
    if (!selectedImage) return;
    try {
      await dispatch(judgeImageThunk({
        sopInstanceUid: selectedImage.sop_instance_uid,
        accept: true
      })).unwrap();
      message.success('图像已恢复');
    } catch (err) {
      message.error('恢复图像失败');
    }
  };

  return (
    <Flex gap="small" align="center">
      {showRejectButton && (
        <Button
          onClick={handleReject}
          loading={loading}
          icon={<Icon module="module-process" name="reject" userId="base" theme="default" size="2x" state="normal" />}
          style={{ width: '1.5rem', height: '1.5rem', padding: 0 }}
          title="拒绝"
        />
      )}
      {showRestoreButton && (
        <Button
          onClick={handleRestore}
          loading={loading}
          icon={<Icon module="module-process" name="restore" userId="base" theme="default" size="2x" state="normal" />}
          style={{ width: '1.5rem', height: '1.5rem', padding: 0 }}
          title="恢复"
        />
      )}
      <Button
        disabled
        icon={<Icon module="module-process" name="saveas" userId="base" theme="default" size="2x" state="normal" />}
        style={{ width: '1.5rem', height: '1.5rem', padding: 0 }}
        title="另存为"
      />
    </Flex>
  );
};

5.2 bodyPositionListSlice 修改

文件: src/states/exam/bodyPositionListSlice.ts

新增 thunk:

import { judgeImage } from '@/API/exam/judgeImage';

export const judgeImageThunk = createAsyncThunk(
  'bodyPositionList/judgeImage',
  async (payload: { sopInstanceUid: string; accept: boolean }) => {
    await judgeImage(payload.sopInstanceUid, payload.accept);
    return {
      sopInstanceUid: payload.sopInstanceUid,
      judgedStatus: payload.accept ? 'Accept' : 'Reject'
    };
  }
);

extraReducers:

extraReducers: (builder) => {
  builder
    .addCase(judgeImageThunk.pending, (state) => {
      state.loading = true;
      state.error = null;
    })
    .addCase(judgeImageThunk.fulfilled, (state, action) => {
      state.loading = false;
      const position = state.bodyPositions.find(
        bp => bp.sop_instance_uid === action.payload.sopInstanceUid
      );
      if (position?.dview) {
        position.dview.judged_status = action.payload.judgedStatus;
      }
    })
    .addCase(judgeImageThunk.rejected, (state, action) => {
      state.loading = false;
      state.error = action.error.message || 'Failed to judge image';
    });
}

6. 测试方案

6.1 单元测试场景

ImageStateControl 组件

  • ✅ 单分格 + 未判断状态 → 显示拒绝按钮
  • ✅ 单分格 + Accept 状态 → 显示拒绝按钮
  • ✅ 单分格 + Reject 状态 → 显示恢复按钮
  • ✅ 多分格 → 拒绝和恢复按钮都不可见
  • ✅ 另存为按钮始终可见但禁用
  • ✅ 点击拒绝 → 调用 judgeImageThunk (accept=false)
  • ✅ 点击恢复 → 调用 judgeImageThunk (accept=true)
  • ✅ loading 状态 → 按钮显示加载中

bodyPositionListSlice

  • ✅ judgeImageThunk.pending → loading = true
  • ✅ judgeImageThunk.fulfilled → 更新 judged_status
  • ✅ judgeImageThunk.rejected → error 设置

6.2 集成测试场景

场景 1: 拒绝图像完整流程

  1. 初始状态:单分格,judgedStatus = ''
  2. 点击拒绝按钮
  3. API 调用成功
  4. judgedStatus 更新为 'Reject'
  5. 按钮切换为"恢复"

场景 2: 恢复图像完整流程

  1. 初始状态:单分格,judgedStatus = 'Reject'
  2. 点击恢复按钮
  3. API 调用成功
  4. judgedStatus 更新为 'Accept'
  5. 按钮切换为"拒绝"

场景 3: 多分格场景

  1. 设置 gridLayout = '2x2'
  2. 拒绝/恢复按钮不可见
  3. 仅显示另存为按钮

场景 4: API 失败处理

  1. 模拟 API 调用失败
  2. 显示错误提示
  3. judgedStatus 保持不变

6.3 E2E 测试

describe('图像状态控制 E2E', () => {
  it('完整拒绝和恢复流程', () => {
    cy.navigateToView();
    cy.get('[data-testid="layout-1x1"]').click();
    cy.get('[data-testid="viewer-0"]').click();
    cy.get('[title="拒绝"]').click();
    cy.contains('图像已拒绝').should('be.visible');
    cy.get('[title="恢复"]').should('be.visible');
    cy.get('[title="恢复"]').click();
    cy.contains('图像已恢复').should('be.visible');
    cy.get('[title="拒绝"]').should('be.visible');
  });
});

7. 边界情况处理

7.1 多图像选中

  • 处理: 取第一个选中的图像 selectedViewers[0]
  • 行为: 按钮状态基于第一个图像的 judged_status

7.2 并发操作

  • 处理: loading 状态时禁用按钮
  • 实现: <Button loading={loading}>

7.3 网络错误

  • 处理: catch 错误,显示提示
  • 行为: judged_status 保持不变

7.4 无选中图像

  • 处理: selectedImage 为 null
  • 行为: 不渲染拒绝/恢复按钮

7.5 分格切换

  • 处理: 响应式计算 showRejectButton/showRestoreButton
  • 行为: 自动显示/隐藏按钮

8. 图标文件

需要提供以下图标文件(放在 src/assets/imgs/module-process/base/default/2x/ 目录下):

  1. reject_normal.png - 拒绝按钮图标
  2. restore_normal.png - 恢复按钮图标
  3. saveas_normal.png - 另存为按钮图标

9. 实施清单

创建新文件

  • docs/实现/图像状态控制功能.md - 实现文档
  • src/pages/view/components/ImageStateControl.tsx - 组件

修改现有文件

  • src/pages/view/components/OperationPanel.tsx - 替换占位符
  • src/states/exam/bodyPositionListSlice.ts - 添加 judgeImageThunk

图标资源

  • 提供 reject_normal.png
  • 提供 restore_normal.png
  • 提供 saveas_normal.png

10. 类图

classDiagram
    class OperationPanel {
        +renderPanel() React.ReactNode
    }
    
    class ImageStateControl {
        -selectedImage: BodyPosition | null
        -judgedStatus: ImageJudgedStatus
        -isSingleGrid: boolean
        -loading: boolean
        -showRejectButton: boolean
        -showRestoreButton: boolean
        +handleReject() Promise~void~
        +handleRestore() Promise~void~
        +render() React.ReactNode
    }
    
    class viewerContainerSlice {
        +gridLayout: GridLayout
        +selectedViewers: string[]
        +selectGridLayout() GridLayout
        +selectSelectedViewers() string[]
    }
    
    class bodyPositionListSlice {
        +bodyPositions: BodyPosition[]
        +loading: boolean
        +error: string | null
        +judgeImageThunk(payload) AsyncThunk
    }
    
    class judgeImage_API {
        +judgeImage(instanceUid, accept) Promise
    }
    
    class dview {
        +PrimarySopUID: string
        +judged_status: ImageJudgedStatus
        +expose_status: string
    }
    
    OperationPanel --> ImageStateControl : 包含
    ImageStateControl --> viewerContainerSlice : 读取状态
    ImageStateControl --> bodyPositionListSlice : 调用 thunk
    bodyPositionListSlice --> judgeImage_API : 调用
    bodyPositionListSlice --> dview : 更新

11. 总结

本功能实现了图像处理界面的状态控制,主要特点:

  1. 简洁架构: 不创建额外的 slice,直接在 bodyPositionListSlice 中处理
  2. 响应式 UI: 基于 Redux 状态自动更新按钮显示
  3. 用户友好: 提供 loading 状态和错误提示
  4. 可扩展: 预留了"另存为"功能的 UI 位置

实施完成后,用户可以在单分格模式下方便地拒绝或恢复图像,提升工作效率。