|
@@ -0,0 +1,473 @@
|
|
|
+# 图像状态控制功能实现文档
|
|
|
+
|
|
|
+## 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 数据流图
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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 序列图
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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 类型
|
|
|
+
|
|
|
+```typescript
|
|
|
+/**
|
|
|
+ * 图像判断状态
|
|
|
+ * - 'Accept': 已接受
|
|
|
+ * - 'Reject': 已拒绝
|
|
|
+ * - '': 未判断
|
|
|
+ */
|
|
|
+type ImageJudgedStatus = 'Accept' | 'Reject' | '';
|
|
|
+```
|
|
|
+
|
|
|
+### 4.2 bodyPositionListSlice 新增字段
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface BodyPositionListState {
|
|
|
+ bodyPositions: BodyPosition[];
|
|
|
+ selectedBodyPosition: BodyPosition | null;
|
|
|
+ loading: boolean; // 新增
|
|
|
+ error: string | null; // 新增
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 4.3 judgeImageThunk 参数
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface JudgeImagePayload {
|
|
|
+ sopInstanceUid: string;
|
|
|
+ accept: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+interface JudgeImageResult {
|
|
|
+ sopInstanceUid: string;
|
|
|
+ judgedStatus: ImageJudgedStatus;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 5. 实现细节
|
|
|
+
|
|
|
+### 5.1 ImageStateControl 组件
|
|
|
+
|
|
|
+**文件**: `src/pages/view/components/ImageStateControl.tsx`
|
|
|
+
|
|
|
+**核心逻辑**:
|
|
|
+```typescript
|
|
|
+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**:
|
|
|
+```typescript
|
|
|
+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**:
|
|
|
+```typescript
|
|
|
+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 测试
|
|
|
+
|
|
|
+```typescript
|
|
|
+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. 实施清单
|
|
|
+
|
|
|
+### 创建新文件
|
|
|
+- [x] `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. 类图
|
|
|
+
|
|
|
+```mermaid
|
|
|
+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 位置
|
|
|
+
|
|
|
+实施完成后,用户可以在单分格模式下方便地拒绝或恢复图像,提升工作效率。
|