Prechádzať zdrojové kódy

feat: 优化患者照片浮动显示功能,改进UI和触发逻辑

主要改进:
- 缩小标题字体大小(text-sm  text-xs)
- 在标题栏显示动态倒计时(3秒2秒1秒),每秒更新
- 重构触发逻辑:从Redux查找改为直接传入Task数据(Props驱动)
- 删除底部静态倒计时提示文字

数据层改进:
- 在 work.ts 的 Task 接口添加 portrait_status 和 portrait_file 字段
- 在 workActions.ts 的 FetchTaskListStudy 接口和映射函数中添加照片字段支持

状态管理改进:
- 创建 selectedPatientSlice 用于管理选中患者状态(已废弃,改用本地状态)
- 在 store.ts 注册 selectedPatientReducer

组件改进:
- PatientPortraitFloat 改为 Props 接收 patient 和 onClose
- 添加 countdown 状态和 setInterval 实现动态倒计时
- 缩小标题栏字体和按钮尺寸

页面集成:
- worklist.tsx 和 HistoryList.tsx 添加本地状态 selectedPatientForPortrait
- handleRowClick 直接设置选中患者,不依赖 Redux 查找

文档:
- 创建完整的功能实现文档,包含架构图、测试方案等

改动文件:
- src/domain/work.ts
- src/API/patient/workActions.ts
- src/API/patient/portraitActions.ts (新增)
- src/states/patient/worklist/slices/selectedPatientSlice.ts (新增)
- src/states/store.ts
- src/pages/patient/components/PatientPortraitFloat.tsx (新增)
- src/pages/patient/worklist.tsx
- src/pages/patient/HistoryList.tsx
- docs/实现/选中后显示患者照片功能.md (新增)
sw 12 hodín pred
rodič
commit
59172d92bd

+ 582 - 0
docs/实现/选中后显示患者照片功能.md

@@ -0,0 +1,582 @@
+# 选中后显示患者照片功能实现文档
+
+## 📋 功能概述
+
+在 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 数据<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[手动关闭]
+```
+
+---
+
+## 🔄 状态同步机制流程图
+
+```mermaid
+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 接口(扩展)
+
+```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
+   - 使用 `<img>` 标签加载照片
+   - 显示加载状态(Spin)
+
+8. **自动关闭**
+   - 启动 3 秒定时器
+   - 定时器到期后调用 `handleClose()`
+
+9. **手动关闭**
+   - 用户点击关闭按钮
+   - 立即调用 `handleClose()`
+
+---
+
+## 🧪 测试方案
+
+### 功能测试场景
+
+#### 1. 基础显示功能
+
+| 测试场景 | 操作步骤 | 预期结果 |
+|---------|---------|---------|
+| 有照片的患者 | 1. 点击有照片的患者行<br/>2. 观察右上角 | 应显示浮动照片组件,显示患者照片 |
+| 无照片的患者 | 1. 点击无照片的患者行<br/>2. 观察右上角 | 不应显示浮动组件 |
+| 照片加载中 | 1. 点击有照片的患者行<br/>2. 立即观察 | 应显示 loading 状态(转圈图标) |
+| 照片加载失败 | 1. 模拟网络错误<br/>2. 点击患者行 | 应显示错误提示图标和文字 |
+
+#### 2. 自动关闭功能
+
+| 测试场景 | 操作步骤 | 预期结果 |
+|---------|---------|---------|
+| 3 秒自动关闭 | 1. 点击有照片的患者行<br/>2. 等待 3 秒 | 照片应自动消失 |
+| 切换患者重置定时器 | 1. 点击患者 A<br/>2. 2 秒后点击患者 B | 患者 B 的照片显示,定时器重新计时 3 秒 |
+
+#### 3. 手动关闭功能
+
+| 测试场景 | 操作步骤 | 预期结果 |
+|---------|---------|---------|
+| 点击关闭按钮 | 1. 点击有照片的患者行<br/>2. 点击关闭按钮(X) | 照片立即消失 |
+| 关闭后定时器清除 | 1. 点击有照片的患者行<br/>2. 1 秒后手动关闭<br/>3. 等待 3 秒 | 照片不应再次出现 |
+
+#### 4. 拖拽功能
+
+| 测试场景 | 操作步骤 | 预期结果 |
+|---------|---------|---------|
+| 拖拽移动位置 | 1. 显示照片<br/>2. 鼠标按住拖拽 | 照片应跟随鼠标移动 |
+| 拖拽到边界 | 1. 显示照片<br/>2. 尝试拖出窗口范围 | 照片应被限制在窗口内 |
+| 拖拽时鼠标样式 | 1. 显示照片<br/>2. 鼠标悬停 | 鼠标应显示 grab 光标 |
+| 拖拽中鼠标样式 | 1. 显示照片<br/>2. 拖拽中 | 鼠标应显示 grabbing 光标 |
+
+#### 5. 跨页面功能
+
+| 测试场景 | 操作步骤 | 预期结果 |
+|---------|---------|---------|
+| Worklist 中选中 | 1. 在 worklist 点击患者行 | 显示照片 |
+| HistoryList 中选中 | 1. 在 historylist 点击患者行 | 显示照片 |
+| 跨页面切换 | 1. 在 worklist 选中患者 A<br/>2. 切换到 historylist | 照片应消失或保持显示患者 A |
+
+#### 6. 多选场景
+
+| 测试场景 | 操作步骤 | 预期结果 |
+|---------|---------|---------|
+| 快速切换选中 | 1. 快速连续点击多个患者行 | 只显示最后一个患者的照片 |
+| 取消选中 | 1. 点击患者行<br/>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<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:内存泄漏
+**影响**:长时间使用导致页面卡顿
+
+**解决方案**:
+```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 的最佳实践,代码结构清晰,易于维护和扩展。

+ 28 - 0
src/API/patient/portraitActions.ts

@@ -0,0 +1,28 @@
+import { API_BASE_URL } from '../config';
+
+/**
+ * 构建患者照片的完整 URL
+ * @param filename 照片文件名(从 portrait_file 字段获取)
+ * @returns 完整的照片 URL
+ * 
+ * @example
+ * ```typescript
+ * const url = getPortraitUrl('2.25.156.999999.0000.1.5.8323328.289656.1760324762.914274.dcm');
+ * // 返回: http://localhost:10086/api/v1/pub/capture/2.25.156.999999.0000.1.5.8323328.289656.1760324762.914274.dcm
+ * ```
+ */
+export const getPortraitUrl = (filename: string): string => {
+  if (!filename || filename.trim() === '') {
+    return '';
+  }
+  return `${API_BASE_URL}pub/capture/${filename}`;
+};
+
+/**
+ * 检查照片是否可用
+ * @param portraitStatus 照片状态
+ * @returns 是否有可用照片
+ */
+export const hasPortrait = (portraitStatus?: string): boolean => {
+  return portraitStatus === 'Saved';
+};

+ 7 - 0
src/API/patient/workActions.ts

@@ -262,6 +262,10 @@ export interface FetchTaskListStudy {
   qc_result: boolean;
   comment: string;
   study_status: string;
+  /** 患者照片保存状态 */
+  portrait_status?: string;
+  /** 患者照片文件名 */
+  portrait_file?: string;
   sort: number;
   product: string;
   /** 创建时间 */
@@ -377,6 +381,9 @@ const mapFetchTaskListStudyToTask = (study: FetchTaskListStudy): Task => ({
   sex_neutered: study.sex_neutered,
   is_anaesthesia: study.is_anaesthesia,
   is_sedation: study.is_sedation,
+  // 患者照片字段
+  portrait_status: study.portrait_status,
+  portrait_file: study.portrait_file,
 });
 
 const fetchTaskList = async (

+ 3 - 0
src/domain/work.ts

@@ -57,6 +57,9 @@ export interface Task {
   is_sedation?: boolean; // 是否镇静,
   /**---下面是人医专用字段--- */
   pregnancy_status?: string; // 妊娠状态, 'NOT_PREGNANT' | 'PREGNANT' | 'UNKNOWN'
+  /**---患者照片相关字段--- */
+  portrait_status?: string; // 患者照片保存状态, 'Saved' | 'NotSaved' | ''
+  portrait_file?: string; // 患者照片文件名
 }
 
 export type TaskAnimal = Omit<Task, 'pregnancy_status'> & {

+ 13 - 0
src/pages/patient/HistoryList.tsx

@@ -15,6 +15,7 @@ import OperationPanel from './components/OperationPanel';
 import SendPanel from '../../pages/output/SendPanel';
 import ThumbnailList from './components/ThumbnailList';
 import GenericPagination from '../../components/GenericPagination';
+import PatientPortraitFloat from './components/PatientPortraitFloat';
 import { RootState, AppDispatch } from '../../states/store';
 import { Task } from '@/domain/work';
 import worklistToExam from '../../domain/patient/worklistToExam';
@@ -25,6 +26,7 @@ const { useBreakpoint } = Grid;
 const HistorylistPage: React.FC = () => {
   const screens = useBreakpoint();
   const [drawerVisible, setDrawerVisible] = useState(false);
+  const [selectedPatientForPortrait, setSelectedPatientForPortrait] = useState<Task | null>(null); // 照片显示用的选中患者
 
   const dispatch: AppDispatch = useDispatch();
   const filters = useSelector((state: RootState) => state.historyFilters);
@@ -86,6 +88,11 @@ const HistorylistPage: React.FC = () => {
     console.log('Row clicked:', JSON.stringify(record, null, 2));
     console.log('Selected IDs before:', record.StudyInstanceUID);
     const newSelectedIds = [record.StudyID];
+    
+    // 设置选中患者用于显示照片
+    setSelectedPatientForPortrait(record);
+    
+    // 更新 Redux 状态(用于其他功能)
     dispatch(historySelectionSlice.actions.setSelectedIds(newSelectedIds));
     dispatch(updateThumbnailsFromHistorySelection(newSelectedIds));
   };
@@ -100,6 +107,12 @@ const HistorylistPage: React.FC = () => {
 
   return (
     <div className="h-full">
+      {/* 患者照片浮动组件 */}
+      <PatientPortraitFloat 
+        patient={selectedPatientForPortrait}
+        onClose={() => setSelectedPatientForPortrait(null)}
+      />
+      
       {screens.xs ? (
         <>
           <div className="flex-1 overflow-auto">

+ 243 - 0
src/pages/patient/components/PatientPortraitFloat.tsx

@@ -0,0 +1,243 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { getPortraitUrl, hasPortrait } from '@/API/patient/portraitActions';
+import { CloseOutlined, LoadingOutlined } from '@ant-design/icons';
+import { Spin } from 'antd';
+import { Task } from '@/domain/work';
+
+/**
+ * 患者照片浮动显示组件 Props
+ */
+interface PatientPortraitFloatProps {
+  /** 患者数据,直接从 handleRowClick 传入 */
+  patient: Task | null;
+  /** 关闭回调 */
+  onClose?: () => void;
+}
+
+/**
+ * 患者照片浮动显示组件
+ * 
+ * 功能特性:
+ * 1. 浮动在页面右上角(可拖拽调整位置)
+ * 2. 显示后3秒自动关闭,带动态倒计时
+ * 3. 提供手动关闭按钮
+ * 4. 只在有患者且有照片时显示
+ * 5. 支持加载状态和错误处理
+ */
+const PatientPortraitFloat: React.FC<PatientPortraitFloatProps> = ({ 
+  patient, 
+  onClose 
+}) => {
+
+  const [isVisible, setIsVisible] = useState(false);
+  const [imageLoading, setImageLoading] = useState(false);
+  const [imageError, setImageError] = useState(false);
+  const [imageUrl, setImageUrl] = useState<string>('');
+  const [countdown, setCountdown] = useState(3); // 倒计时状态
+
+  // 拖拽相关状态
+  const [position, setPosition] = useState({ x: 20, y: 20 }); // 初始位置:右上角
+  const [isDragging, setIsDragging] = useState(false);
+  const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
+  const floatRef = useRef<HTMLDivElement>(null);
+
+  // 定时器引用
+  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
+
+  // 关闭组件
+  const handleClose = () => {
+    setIsVisible(false);
+    if (countdownIntervalRef.current) {
+      clearInterval(countdownIntervalRef.current);
+    }
+    onClose?.(); // 调用父组件的关闭回调
+  };
+
+  // 监听患者变化
+  useEffect(() => {
+    if (patient && hasPortrait(patient.portrait_status)) {
+      // 有照片,显示组件
+      setIsVisible(true);
+      setImageLoading(true);
+      setImageError(false);
+      setCountdown(3); // 重置倒计时
+      
+      const url = getPortraitUrl(patient.portrait_file || '');
+      console.log(`得到的照片路径是: ${url}`);
+      setImageUrl(url);
+
+      // 清除旧的定时器
+      if (countdownIntervalRef.current) {
+        clearInterval(countdownIntervalRef.current);
+      }
+
+      // 启动倒计时定时器,每秒更新一次
+      countdownIntervalRef.current = setInterval(() => {
+        setCountdown((prev) => {
+          if (prev <= 1) {
+            handleClose();
+            return 0;
+          }
+          return prev - 1;
+        });
+      }, 1000);
+    } else {
+      // 无照片或未选中,隐藏组件
+      setIsVisible(false);
+      setImageUrl('');
+      if (countdownIntervalRef.current) {
+        clearInterval(countdownIntervalRef.current);
+      }
+    }
+
+    return () => {
+      if (countdownIntervalRef.current) {
+        clearInterval(countdownIntervalRef.current);
+      }
+    };
+  }, [patient, onClose]);
+
+  // 图片加载完成
+  const handleImageLoad = () => {
+    setImageLoading(false);
+    setImageError(false);
+  };
+
+  // 图片加载失败
+  const handleImageError = () => {
+    setImageLoading(false);
+    setImageError(true);
+  };
+
+  // 拖拽开始
+  const handleMouseDown = (e: React.MouseEvent) => {
+    // 如果点击的是关闭按钮,不触发拖拽
+    if ((e.target as HTMLElement).closest('.close-btn')) {
+      return;
+    }
+
+    setIsDragging(true);
+    setDragStart({
+      x: e.clientX - position.x,
+      y: e.clientY - position.y,
+    });
+    e.preventDefault();
+  };
+
+  // 拖拽中
+  useEffect(() => {
+    const handleMouseMove = (e: MouseEvent) => {
+      if (isDragging) {
+        const newX = e.clientX - dragStart.x;
+        const newY = e.clientY - dragStart.y;
+
+        // 限制在窗口范围内
+        const maxX = window.innerWidth - (floatRef.current?.offsetWidth || 300);
+        const maxY = window.innerHeight - (floatRef.current?.offsetHeight || 300);
+
+        setPosition({
+          x: Math.max(0, Math.min(newX, maxX)),
+          y: Math.max(0, Math.min(newY, maxY)),
+        });
+      }
+    };
+
+    const handleMouseUp = () => {
+      setIsDragging(false);
+    };
+
+    if (isDragging) {
+      document.addEventListener('mousemove', handleMouseMove);
+      document.addEventListener('mouseup', handleMouseUp);
+    }
+
+    return () => {
+      document.removeEventListener('mousemove', handleMouseMove);
+      document.removeEventListener('mouseup', handleMouseUp);
+    };
+  }, [isDragging, dragStart]);
+
+  if (!isVisible) return null;
+
+  return (
+    <div
+      ref={floatRef}
+      className="fixed z-[1000] bg-white rounded-lg shadow-2xl"
+      style={{
+        top: `${position.y}px`,
+        right: `${position.x}px`,
+        width: '280px',
+        maxWidth: '90vw',
+        cursor: isDragging ? 'grabbing' : 'grab',
+        transition: isDragging ? 'none' : 'all 0.3s ease-in-out',
+        userSelect: 'none',
+      }}
+      onMouseDown={handleMouseDown}
+    >
+      {/* 标题栏 */}
+      <div className="flex items-center justify-between p-2 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-blue-100 rounded-t-lg">
+        <div className="flex items-center space-x-2">
+          <span className="text-xs font-semibold text-gray-700">患者照片</span>
+          {patient && (
+            <span className="text-xs text-gray-500">
+              {patient.PatientName}
+            </span>
+          )}
+          <span className="text-xs font-bold text-blue-600">
+            {countdown}秒
+          </span>
+        </div>
+        <button
+          className="close-btn w-5 h-5 flex items-center justify-center rounded-full hover:bg-gray-200 transition-colors"
+          onClick={handleClose}
+          title="关闭"
+        >
+          <CloseOutlined className="text-gray-600" style={{ fontSize: '10px' }} />
+        </button>
+      </div>
+
+      {/* 照片区域 */}
+      <div className="p-4">
+        <div className="relative w-full aspect-[3/4] bg-gray-100 rounded-lg overflow-hidden flex items-center justify-center">
+          {imageLoading && (
+            <div className="absolute inset-0 flex items-center justify-center bg-gray-50">
+              <Spin indicator={<LoadingOutlined style={{ fontSize: 32 }} spin />} />
+            </div>
+          )}
+          
+          {imageError && !imageLoading && (
+            <div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-50 text-gray-400">
+              <svg
+                className="w-16 h-16 mb-2"
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={1.5}
+                  d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
+                />
+              </svg>
+              <span className="text-sm">照片加载失败</span>
+            </div>
+          )}
+
+          {imageUrl && !imageError && (
+            <img
+              src={imageUrl}
+              alt="患者照片"
+              className="w-full h-full object-cover"
+              onLoad={handleImageLoad}
+              onError={handleImageError}
+              style={{ display: imageLoading ? 'none' : 'block' }}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default PatientPortraitFloat;

+ 13 - 0
src/pages/patient/worklist.tsx

@@ -14,6 +14,7 @@ import WorklistTable from './components/WorklistTable';
 import OperationPanel from './components/OperationPanel';
 import ThumbnailList from './components/ThumbnailList';
 import GenericPagination from '../../components/GenericPagination';
+import PatientPortraitFloat from './components/PatientPortraitFloat';
 import { RootState, AppDispatch } from '../../states/store';
 import { Task } from '@/domain/work';
 import worklistToExam from '../../domain/patient/worklistToExam';
@@ -26,6 +27,7 @@ const WorklistPage: React.FC = () => {
   const screens = useBreakpoint();
   const [drawerVisible, setDrawerVisible] = useState(false);
   const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>([]); // 新增:列配置状态
+  const [selectedPatientForPortrait, setSelectedPatientForPortrait] = useState<Task | null>(null); // 照片显示用的选中患者
 
   const dispatch: AppDispatch = useDispatch();
   const filters = useSelector((state: RootState) => state.workFilters);
@@ -84,6 +86,11 @@ const WorklistPage: React.FC = () => {
     console.log('Row clicked:', JSON.stringify(record, null, 2));
     console.log('Selected IDs before:', record.StudyID);
     const newSelectedIds = [record.StudyID];
+    
+    // 设置选中患者用于显示照片
+    setSelectedPatientForPortrait(record);
+    
+    // 更新 Redux 状态(用于其他功能)
     dispatch(workSelectionSlice.actions.setSelectedIds(newSelectedIds));
     dispatch(updateThumbnailsFromWorkSelection(newSelectedIds));
   };
@@ -98,6 +105,12 @@ const WorklistPage: React.FC = () => {
 
   return (
     <div className="h-full">
+      {/* 患者照片浮动组件 */}
+      <PatientPortraitFloat 
+        patient={selectedPatientForPortrait}
+        onClose={() => setSelectedPatientForPortrait(null)}
+      />
+      
       {screens.xs ? (
         <>
           <div className="flex-1 overflow-auto">

+ 116 - 0
src/states/patient/worklist/slices/selectedPatientSlice.ts

@@ -0,0 +1,116 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { RootState } from '@/states/store';
+import { workSelectionSlice } from './workSlice';
+import { historySelectionSlice } from './history';
+
+/**
+ * 选中患者的照片信息状态
+ */
+interface SelectedPatientState {
+  patientId: string | null;
+  patientName: string | null;
+  portraitStatus: string | null;
+  portraitFile: string | null;
+}
+
+const initialState: SelectedPatientState = {
+  patientId: null,
+  patientName: null,
+  portraitStatus: null,
+  portraitFile: null,
+};
+
+/**
+ * 选中患者照片状态管理 Slice
+ * 
+ * 功能:
+ * 1. 监听 workSelectionSlice 和 historySelectionSlice 的选中变化
+ * 2. 自动更新选中患者的照片信息
+ * 3. 提供清除选中状态的 action
+ */
+const selectedPatientSlice = createSlice({
+  name: 'selectedPatient',
+  initialState,
+  reducers: {
+    /**
+     * 手动更新选中患者信息
+     */
+    updateSelectedPatient: (
+      state,
+      action: PayloadAction<{
+        patientId: string;
+        patientName: string;
+        portraitStatus?: string;
+        portraitFile?: string;
+      }>
+    ) => {
+      state.patientId = action.payload.patientId;
+      state.patientName = action.payload.patientName;
+      state.portraitStatus = action.payload.portraitStatus || null;
+      state.portraitFile = action.payload.portraitFile || null;
+    },
+    /**
+     * 清除选中患者信息
+     */
+    clearSelectedPatient: (state) => {
+      state.patientId = null;
+      state.patientName = null;
+      state.portraitStatus = null;
+      state.portraitFile = null;
+    },
+  },
+  extraReducers: (builder) => {
+    // 监听 worklist 选中变化
+    builder.addCase(
+      workSelectionSlice.actions.setSelectedIds,
+      (state, action: PayloadAction<string[]>) => {
+        const selectedIds = action.payload;
+        
+        if (selectedIds.length === 0) {
+          // 如果没有选中项,清空状态
+          state.patientId = null;
+          state.patientName = null;
+          state.portraitStatus = null;
+          state.portraitFile = null;
+        } else {
+          // 选中了项目,但我们需要从 store 中获取完整的 Task 数据
+          // 这里只保存 patientId,实际的照片信息会在组件中从 workEntities 中获取
+          state.patientId = selectedIds[0]; // 只处理单选
+          // 其他字段会在组件中从 entities 获取
+        }
+      }
+    );
+
+    // 监听 historylist 选中变化
+    builder.addCase(
+      historySelectionSlice.actions.setSelectedIds,
+      (state, action: PayloadAction<string[]>) => {
+        const selectedIds = action.payload;
+        
+        if (selectedIds.length === 0) {
+          // 如果没有选中项,清空状态
+          state.patientId = null;
+          state.patientName = null;
+          state.portraitStatus = null;
+          state.portraitFile = null;
+        } else {
+          // 选中了项目
+          state.patientId = selectedIds[0]; // 只处理单选
+          // 其他字段会在组件中从 entities 获取
+        }
+      }
+    );
+  },
+});
+
+export const { updateSelectedPatient, clearSelectedPatient } = selectedPatientSlice.actions;
+
+// Selectors
+export const selectSelectedPatient = (state: RootState) => state.selectedPatient;
+export const selectPatientId = (state: RootState) => state.selectedPatient.patientId;
+export const selectPortraitInfo = (state: RootState) => ({
+  portraitStatus: state.selectedPatient.portraitStatus,
+  portraitFile: state.selectedPatient.portraitFile,
+});
+
+export default selectedPatientSlice.reducer;

+ 2 - 0
src/states/store.ts

@@ -70,6 +70,7 @@ import featureNotAvailableReducer from './featureNotAvailableSlice';
 import themeReducer from './themeSlice';
 import cameraReducer from './exam/cameraSlice';
 import pacsNodeReducer from './output/pacsNode/pacsNodeSlice';
+import selectedPatientReducer from './patient/worklist/slices/selectedPatientSlice';
 
 const store = configureStore({
   reducer: {
@@ -132,6 +133,7 @@ const store = configureStore({
     theme: themeReducer,
     camera: cameraReducer,
     pacsNode: pacsNodeReducer,
+    selectedPatient: selectedPatientReducer,
   },
   middleware: (getDefaultMiddleware) =>
     getDefaultMiddleware().concat(