# 选中后显示患者照片功能实现文档 ## 📋 功能概述 在 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 的最佳实践,代码结构清晰,易于维护和扩展。