# 选中后显示患者照片功能实现文档
## 📋 功能概述
在 worklist 或 historylist 中,当表格行被点击后,在页面右上角浮动显示被点击患者的照片。照片组件支持拖拽调整位置,显示 3 秒后自动关闭,同时提供手动关闭按钮。
## 🎯 涉及的参与者
### 1. 数据层(Domain)
- **`src/domain/work.ts`** - Task 接口
- 添加 `portrait_status?: string` - 照片保存状态("Saved" | "NotSaved" | "")
- 添加 `portrait_file?: string` - 照片文件名
### 2. API 层
- **`src/API/patient/portraitActions.ts`** - 照片相关 API
- `getPortraitUrl(filename: string)` - 构建照片完整 URL
- `hasPortrait(portraitStatus?: string)` - 检查照片是否可用
### 3. 状态管理层(Redux)
- **`src/states/patient/worklist/slices/selectedPatientSlice.ts`** - 选中患者状态管理
- 监听 `workSelectionSlice` 的选中变化
- 监听 `historySelectionSlice` 的选中变化
- 提供清除选中状态的 action
- **`src/states/store.ts`** - 注册新的 reducer
### 4. 组件层(UI)
- **`src/pages/patient/components/PatientPortraitFloat.tsx`** - 浮动照片组件
- 支持拖拽调整位置
- 3 秒自动关闭
- 手动关闭按钮
- 加载状态和错误处理
- **`src/pages/patient/worklist.tsx`** - Worklist 页面(集成照片组件)
- **`src/pages/patient/HistoryList.tsx`** - HistoryList 页面(集成照片组件)
---
## 📊 系统架构图
### 类图
```mermaid
classDiagram
class Task {
+string StudyID
+string PatientName
+string? portrait_status
+string? portrait_file
}
class SelectedPatientState {
+string? patientId
+string? patientName
+string? portraitStatus
+string? portraitFile
}
class PatientPortraitFloat {
-boolean isVisible
-boolean imageLoading
-boolean imageError
-string imageUrl
-Position position
-boolean isDragging
+handleClose()
+handleMouseDown()
}
class portraitActions {
+getPortraitUrl(filename: string)
+hasPortrait(status?: string)
}
Task --> SelectedPatientState : provides data
SelectedPatientState --> PatientPortraitFloat : state
PatientPortraitFloat --> portraitActions : uses
```
---
## 🔄 交互流程
### 序列图 - 用户点击表格行显示照片
```mermaid
sequenceDiagram
participant User as 用户
participant UI as WorkList/HistoryList
participant Handler as handleRowClick
participant WorkSel as workSelectionSlice
participant PatientSel as selectedPatientSlice
participant Float as PatientPortraitFloat
participant API as Portrait API
participant Backend as 后端服务器
User->>UI: 点击表格行
UI->>Handler: 触发 handleRowClick(record)
Handler->>WorkSel: dispatch(setSelectedIds([id]))
Note over WorkSel,PatientSel: 通过 extraReducers 监听
WorkSel->>PatientSel: 自动触发状态更新
PatientSel->>PatientSel: 保存 patientId
PatientSel->>Float: state 变化触发重渲染
Float->>Float: 从 entities 获取完整 Task 数据
alt portrait_status === "Saved"
Float->>API: getPortraitUrl(portrait_file)
API-->>Float: 返回完整 URL
Float->>Backend: GET /api/v1/pub/capture/{filename}
Backend-->>Float: 返回图像文件
Float->>Float: 设置 imageUrl,显示加载状态
Float->>User: 显示患者照片
Note over Float: 启动 3 秒定时器
Float->>Float: setTimeout(handleClose, 3000)
alt 用户点击关闭按钮
User->>Float: 点击关闭按钮
Float->>Float: handleClose()
Float->>User: 隐藏组件
else 3 秒后自动关闭
Float->>Float: 定时器触发 handleClose()
Float->>User: 隐藏组件
end
else portrait_status !== "Saved"
Float->>User: 不显示(或显示占位符)
end
```
---
## 📈 数据流图
```mermaid
graph TD
A[后端 API] -->|返回 Study 数据
含 portrait_status & portrait_file| B[fetchWorkThunk]
B -->|存储数据| C[Redux Store]
C -->|workEntities| D1[WorklistTable]
C -->|historyEntities| D2[HistorylistTable]
D1 -->|用户点击行| E1[handleRowClick]
D2 -->|用户点击行| E2[handleRowClick]
E1 -->|dispatch setSelectedIds| F1[workSelectionSlice]
E2 -->|dispatch setSelectedIds| F2[historySelectionSlice]
F1 -->|extraReducers 监听| G[selectedPatientSlice]
F2 -->|extraReducers 监听| G
G -->|更新 patientId| H[PatientPortraitFloat 组件]
H -->|根据 patientId| I{从 entities
查找 Task}
I -->|找到| J{检查
portrait_status}
I -->|未找到| K[不显示]
J -->|Saved| L[构建图片 URL]
J -->|NotSaved/空| K
L -->|/api/v1/pub/capture/filename| M[加载并显示照片]
M -->|3 秒后| N[自动关闭]
M -->|用户点击| O[手动关闭]
```
---
## 🔄 状态同步机制流程图
```mermaid
graph LR
A[用户操作] --> B{在哪个页面?}
B -->|Worklist| C[workSelectionSlice
setSelectedIds]
B -->|HistoryList| D[historySelectionSlice
setSelectedIds]
C --> E[selectedPatientSlice
监听 action]
D --> E
E --> F{选中 ID 数量}
F -->|0| G[清空状态]
F -->|>0| H[保存 patientId]
H --> I[PatientPortraitFloat
读取 state]
I --> J[从对应的
entities 获取数据]
J --> K{有照片?}
K -->|是| L[显示照片
启动定时器]
K -->|否| M[不显示]
L --> N[3 秒后自动关闭]
```
---
## 🗂️ 关键数据结构
### Task 接口(扩展)
```typescript
export interface Task {
// ... 现有字段
/**---患者照片相关字段--- */
portrait_status?: string; // "Saved" | "NotSaved" | ""
portrait_file?: string; // 照片文件名,如 "xxx.dcm"
}
```
### SelectedPatientState
```typescript
interface SelectedPatientState {
patientId: string | null; // 选中患者的 Study ID
patientName: string | null; // 患者姓名(保留字段)
portraitStatus: string | null; // 照片状态(保留字段)
portraitFile: string | null; // 照片文件名(保留字段)
}
```
### API 响应示例
根据 API 文档 `13.5 获取图像(pub)`:
```
GET /api/v1/pub/capture/{filename}
```
其中 `filename` 来自 `Task.portrait_file` 字段。
---
## 🚀 执行流程
### 起点:用户点击表格行
1. **用户操作**
- 用户在 worklist 或 historylist 中点击某一行
2. **触发事件**
- `handleRowClick(record: Task)` 被调用
3. **更新选中状态**
- dispatch `workSelectionSlice.actions.setSelectedIds([record.StudyID])`
- 或 dispatch `historySelectionSlice.actions.setSelectedIds([record.StudyID])`
4. **状态自动同步**
- `selectedPatientSlice` 通过 `extraReducers` 监听到变化
- 自动更新 `patientId`
5. **组件响应**
- `PatientPortraitFloat` 组件监听 `selectedPatient` state
- 通过 `useMemo` 从 `workEntities` 或 `historyEntities` 获取完整的 Task 数据
6. **条件渲染**
- 如果 `portrait_status === "Saved"`,显示照片
- 否则,不显示
7. **加载照片**
- 调用 `getPortraitUrl(portrait_file)` 构建完整 URL
- 使用 `
` 标签加载照片
- 显示加载状态(Spin)
8. **自动关闭**
- 启动 3 秒定时器
- 定时器到期后调用 `handleClose()`
9. **手动关闭**
- 用户点击关闭按钮
- 立即调用 `handleClose()`
---
## 🧪 测试方案
### 功能测试场景
#### 1. 基础显示功能
| 测试场景 | 操作步骤 | 预期结果 |
|---------|---------|---------|
| 有照片的患者 | 1. 点击有照片的患者行
2. 观察右上角 | 应显示浮动照片组件,显示患者照片 |
| 无照片的患者 | 1. 点击无照片的患者行
2. 观察右上角 | 不应显示浮动组件 |
| 照片加载中 | 1. 点击有照片的患者行
2. 立即观察 | 应显示 loading 状态(转圈图标) |
| 照片加载失败 | 1. 模拟网络错误
2. 点击患者行 | 应显示错误提示图标和文字 |
#### 2. 自动关闭功能
| 测试场景 | 操作步骤 | 预期结果 |
|---------|---------|---------|
| 3 秒自动关闭 | 1. 点击有照片的患者行
2. 等待 3 秒 | 照片应自动消失 |
| 切换患者重置定时器 | 1. 点击患者 A
2. 2 秒后点击患者 B | 患者 B 的照片显示,定时器重新计时 3 秒 |
#### 3. 手动关闭功能
| 测试场景 | 操作步骤 | 预期结果 |
|---------|---------|---------|
| 点击关闭按钮 | 1. 点击有照片的患者行
2. 点击关闭按钮(X) | 照片立即消失 |
| 关闭后定时器清除 | 1. 点击有照片的患者行
2. 1 秒后手动关闭
3. 等待 3 秒 | 照片不应再次出现 |
#### 4. 拖拽功能
| 测试场景 | 操作步骤 | 预期结果 |
|---------|---------|---------|
| 拖拽移动位置 | 1. 显示照片
2. 鼠标按住拖拽 | 照片应跟随鼠标移动 |
| 拖拽到边界 | 1. 显示照片
2. 尝试拖出窗口范围 | 照片应被限制在窗口内 |
| 拖拽时鼠标样式 | 1. 显示照片
2. 鼠标悬停 | 鼠标应显示 grab 光标 |
| 拖拽中鼠标样式 | 1. 显示照片
2. 拖拽中 | 鼠标应显示 grabbing 光标 |
#### 5. 跨页面功能
| 测试场景 | 操作步骤 | 预期结果 |
|---------|---------|---------|
| Worklist 中选中 | 1. 在 worklist 点击患者行 | 显示照片 |
| HistoryList 中选中 | 1. 在 historylist 点击患者行 | 显示照片 |
| 跨页面切换 | 1. 在 worklist 选中患者 A
2. 切换到 historylist | 照片应消失或保持显示患者 A |
#### 6. 多选场景
| 测试场景 | 操作步骤 | 预期结果 |
|---------|---------|---------|
| 快速切换选中 | 1. 快速连续点击多个患者行 | 只显示最后一个患者的照片 |
| 取消选中 | 1. 点击患者行
2. 再次点击同一行取消选中 | 照片应消失 |
### 测试数据准备
```typescript
// 1. 有照片的测试数据
const taskWithPortrait: Task = {
StudyID: "TEST001",
PatientName: "张三",
portrait_status: "Saved",
portrait_file: "test-portrait-001.dcm",
// ... 其他必需字段
};
// 2. 无照片的测试数据(状态为 NotSaved)
const taskWithoutPortrait1: Task = {
StudyID: "TEST002",
PatientName: "李四",
portrait_status: "NotSaved",
portrait_file: "",
// ... 其他必需字段
};
// 3. 无照片的测试数据(字段为空)
const taskWithoutPortrait2: Task = {
StudyID: "TEST003",
PatientName: "王五",
portrait_status: "",
portrait_file: "",
// ... 其他必需字段
};
// 4. 照片文件名不存在(测试错误处理)
const taskWithInvalidPortrait: Task = {
StudyID: "TEST004",
PatientName: "赵六",
portrait_status: "Saved",
portrait_file: "non-existent-file.dcm",
// ... 其他必需字段
};
```
---
## 🐛 潜在问题和边界情况分析
### 1. 数据异常处理
#### 问题 1.1:`portrait_file` 为空或格式错误
**影响**:可能导致请求错误的 URL
**解决方案**:
```typescript
// 在 getPortraitUrl 中添加验证
export const getPortraitUrl = (filename: string): string => {
if (!filename || filename.trim() === '') {
return ''; // 返回空字符串,组件不显示
}
return `${IP_PORT}/api/v1/pub/capture/${filename}`;
};
```
#### 问题 1.2:后端返回的数据缺少照片字段
**影响**:TypeScript 可选字段可能为 undefined
**解决方案**:
- 使用可选字段(`?`)定义
- 在组件中使用 `hasPortrait()` 函数统一检查
- 使用 `||` 提供默认值
### 2. 网络问题
#### 问题 2.1:图片加载超时
**影响**:用户一直看到 loading 状态
**解决方案**:
```typescript
// 添加超时处理
const timeoutRef = useRef(null);
useEffect(() => {
if (imageUrl) {
setImageLoading(true);
// 设置 10 秒超时
timeoutRef.current = setTimeout(() => {
setImageLoading(false);
setImageError(true);
}, 10000);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [imageUrl]);
```
#### 问题 2.2:频繁切换导致多次请求
**影响**:浪费带宽,可能导致显示错乱
**解决方案**:
- 图片请求由浏览器缓存处理
- 可以添加请求取消机制(使用 AbortController)
### 3. 性能问题
#### 问题 3.1:大图片加载慢
**影响**:影响用户体验
**解决方案**:
- 后端提供缩略图 API
- 使用渐进式加载
- 添加图片懒加载
#### 问题 3.2:内存泄漏
**影响**:长时间使用导致页面卡顿
**解决方案**:
```typescript
// 清理定时器和事件监听
useEffect(() => {
return () => {
if (autoCloseTimerRef.current) {
clearTimeout(autoCloseTimerRef.current);
}
};
}, []);
// 清理拖拽事件
useEffect(() => {
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
```
### 4. 用户体验问题
#### 问题 4.1:照片遮挡重要内容
**影响**:用户无法操作被遮挡的区域
**解决方案**:
- 支持拖拽调整位置(已实现)
- 3 秒自动关闭(已实现)
- 提供手动关闭按钮(已实现)
#### 问题 4.2:快速切换导致闪烁
**影响**:视觉体验差
**解决方案**:
```typescript
// 添加防抖延迟
const debouncedPatientId = useDebounce(selectedPatientId, 200);
```
### 5. 兼容性问题
#### 问题 5.1:老数据没有照片字段
**影响**:可能显示 undefined
**解决方案**:
- 使用可选字段(`?`)
- 提供默认值处理
- 优雅降级,不影响现有功能
#### 问题 5.2:不同浏览器拖拽行为差异
**影响**:拖拽可能不流畅
**解决方案**:
- 使用标准的 `mousedown/mousemove/mouseup` 事件
- 添加 `e.preventDefault()` 防止默认行为
- 测试主流浏览器
### 6. 状态同步问题
#### 问题 6.1:从 worklist 切换到 historylist 时状态残留
**影响**:可能显示错误的照片
**解决方案**:
- 在 `selectedPatientSlice` 中正确处理两个页面的选中事件
- 确保组件从正确的 entities 获取数据
- 页面卸载时清理状态(如果需要)
#### 问题 6.2:选中状态与 entities 数据不同步
**影响**:找不到对应的 Task 数据
**解决方案**:
```typescript
// 使用 useMemo 确保及时更新
const selectedPatient = React.useMemo(() => {
if (!selectedPatientId) return null;
const fromWork = workEntities.find(item => item.StudyID === selectedPatientId);
if (fromWork) return fromWork;
const fromHistory = historyEntities.find(item => item.StudyID === selectedPatientId);
return fromHistory || null;
}, [selectedPatientId, workEntities, historyEntities]);
```
---
## 📝 实现要点总结
### 1. 状态管理设计
**设计思路**:使用 Redux 的 `extraReducers` 监听机制实现自动状态同步
**优点**:
- 解耦:不需要在每个 handleRowClick 中手动更新照片状态
- 单向数据流:符合 Redux 设计原则
- 易维护:新增页面时只需添加监听规则
### 2. 组件设计
**设计思路**:独立的浮动组件,通过 Redux state 驱动显示
**优点**:
- 可复用:可在任何页面使用
- 独立性:不影响现有布局
- 灵活性:支持拖拽和自定义位置
### 3. API 设计
**设计思路**:简单的工具函数,封装 URL 构建逻辑
**优点**:
- 简单:不需要复杂的请求逻辑
- 可测试:纯函数易于测试
- 可维护:API 变更只需修改一处
### 4. 交互设计
**核心功能**:
1. ✅ 拖拽调整位置
2. ✅ 3 秒自动关闭
3. ✅ 手动关闭按钮
4. ✅ 加载状态提示
5. ✅ 错误处理
---
## 🎉 总结
本功能成功实现了以下目标:
1. ✅ **数据模型扩展**:在 Task 接口中添加照片相关字段
2. ✅ **状态管理**:创建独立的 selectedPatientSlice 监听选中变化
3. ✅ **UI 组件**:实现功能完整的浮动照片组件
4. ✅ **页面集成**:在 worklist 和 historylist 中集成组件
5. ✅ **用户体验**:支持拖拽、自动关闭、手动关闭
6. ✅ **错误处理**:完善的加载状态和错误提示
该实现遵循了 React + Redux 的最佳实践,代码结构清晰,易于维护和扩展。