选中后显示患者照片功能.md 16 KB

选中后显示患者照片功能实现文档

📋 功能概述

在 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 页面(集成照片组件)

📊 系统架构图

类图

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

🔄 交互流程

序列图 - 用户点击表格行显示照片

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

📈 数据流图

graph TD
    A[后端 API] -->|返回 Study 数据<br/>含 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<br/>查找 Task}
    
    I -->|找到| J{检查<br/>portrait_status}
    I -->|未找到| K[不显示]
    
    J -->|Saved| L[构建图片 URL]
    J -->|NotSaved/空| K
    
    L -->|/api/v1/pub/capture/filename| M[加载并显示照片]
    M -->|3 秒后| N[自动关闭]
    M -->|用户点击| O[手动关闭]

🔄 状态同步机制流程图

graph LR
    A[用户操作] --> B{在哪个页面?}
    
    B -->|Worklist| C[workSelectionSlice<br/>setSelectedIds]
    B -->|HistoryList| D[historySelectionSlice<br/>setSelectedIds]
    
    C --> E[selectedPatientSlice<br/>监听 action]
    D --> E
    
    E --> F{选中 ID 数量}
    
    F -->|0| G[清空状态]
    F -->|>0| H[保存 patientId]
    
    H --> I[PatientPortraitFloat<br/>读取 state]
    
    I --> J[从对应的<br/>entities 获取数据]
    
    J --> K{有照片?}
    
    K -->|是| L[显示照片<br/>启动定时器]
    K -->|否| M[不显示]
    
    L --> N[3 秒后自动关闭]

🗂️ 关键数据结构

Task 接口(扩展)

export interface Task {
  // ... 现有字段
  
  /**---患者照片相关字段--- */
  portrait_status?: string;  // "Saved" | "NotSaved" | ""
  portrait_file?: string;    // 照片文件名,如 "xxx.dcm"
}

SelectedPatientState

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
    • 通过 useMemoworkEntitieshistoryEntities 获取完整的 Task 数据
  6. 条件渲染

    • 如果 portrait_status === "Saved",显示照片
    • 否则,不显示
  7. 加载照片

    • 调用 getPortraitUrl(portrait_file) 构建完整 URL
    • 使用 <img> 标签加载照片
    • 显示加载状态(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. 再次点击同一行取消选中
照片应消失

测试数据准备

// 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

解决方案

// 在 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 状态

解决方案

// 添加超时处理
const timeoutRef = useRef<NodeJS.Timeout | null>(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:内存泄漏

影响:长时间使用导致页面卡顿

解决方案

// 清理定时器和事件监听
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:快速切换导致闪烁

影响:视觉体验差

解决方案

// 添加防抖延迟
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 数据

解决方案

// 使用 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 的最佳实践,代码结构清晰,易于维护和扩展。