# 图像状态控制功能实现文档
## 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
selectedViewers, gridLayout]
C --> E[bodyPositionList
judged_status, sopInstanceUid]
B --> F[dispatch judgeImageThunk]
F --> G[bodyPositionListSlice]
G --> H[调用 judgeImage API]
H --> I{API 结果}
I -->|成功| J[extraReducers.fulfilled
更新 judged_status]
I -->|失败| K[extraReducers.rejected
设置 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 (
{showRejectButton && (
}
style={{ width: '1.5rem', height: '1.5rem', padding: 0 }}
title="拒绝"
/>
)}
{showRestoreButton && (
}
style={{ width: '1.5rem', height: '1.5rem', padding: 0 }}
title="恢复"
/>
)}
}
style={{ width: '1.5rem', height: '1.5rem', padding: 0 }}
title="另存为"
/>
);
};
```
### 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 状态时禁用按钮
- **实现**: `