在 worklist 或 historylist 中,当表格行被点击后,在页面右上角浮动显示被点击患者的照片。照片组件支持拖拽调整位置,显示 3 秒后自动关闭,同时提供手动关闭按钮。
src/domain/work.ts
- Task 接口
portrait_status?: string
- 照片保存状态("Saved" | "NotSaved" | "")portrait_file?: string
- 照片文件名src/API/patient/portraitActions.ts
- 照片相关 API
getPortraitUrl(filename: string)
- 构建照片完整 URLhasPortrait(portraitStatus?: string)
- 检查照片是否可用src/states/patient/worklist/slices/selectedPatientSlice.ts
- 选中患者状态管理
workSelectionSlice
的选中变化historySelectionSlice
的选中变化src/states/store.ts
- 注册新的 reducersrc/pages/patient/components/PatientPortraitFloat.tsx
- 浮动照片组件
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 秒后自动关闭]
export interface Task {
// ... 现有字段
/**---患者照片相关字段--- */
portrait_status?: string; // "Saved" | "NotSaved" | ""
portrait_file?: string; // 照片文件名,如 "xxx.dcm"
}
interface SelectedPatientState {
patientId: string | null; // 选中患者的 Study ID
patientName: string | null; // 患者姓名(保留字段)
portraitStatus: string | null; // 照片状态(保留字段)
portraitFile: string | null; // 照片文件名(保留字段)
}
根据 API 文档 13.5 获取图像(pub)
:
GET /api/v1/pub/capture/{filename}
其中 filename
来自 Task.portrait_file
字段。
用户操作
触发事件
handleRowClick(record: Task)
被调用更新选中状态
workSelectionSlice.actions.setSelectedIds([record.StudyID])
historySelectionSlice.actions.setSelectedIds([record.StudyID])
状态自动同步
selectedPatientSlice
通过 extraReducers
监听到变化patientId
组件响应
PatientPortraitFloat
组件监听 selectedPatient
stateuseMemo
从 workEntities
或 historyEntities
获取完整的 Task 数据条件渲染
portrait_status === "Saved"
,显示照片加载照片
getPortraitUrl(portrait_file)
构建完整 URL<img>
标签加载照片自动关闭
handleClose()
手动关闭
handleClose()
测试场景 | 操作步骤 | 预期结果 |
---|---|---|
有照片的患者 | 1. 点击有照片的患者行 2. 观察右上角 |
应显示浮动照片组件,显示患者照片 |
无照片的患者 | 1. 点击无照片的患者行 2. 观察右上角 |
不应显示浮动组件 |
照片加载中 | 1. 点击有照片的患者行 2. 立即观察 |
应显示 loading 状态(转圈图标) |
照片加载失败 | 1. 模拟网络错误 2. 点击患者行 |
应显示错误提示图标和文字 |
测试场景 | 操作步骤 | 预期结果 |
---|---|---|
3 秒自动关闭 | 1. 点击有照片的患者行 2. 等待 3 秒 |
照片应自动消失 |
切换患者重置定时器 | 1. 点击患者 A 2. 2 秒后点击患者 B |
患者 B 的照片显示,定时器重新计时 3 秒 |
测试场景 | 操作步骤 | 预期结果 |
---|---|---|
点击关闭按钮 | 1. 点击有照片的患者行 2. 点击关闭按钮(X) |
照片立即消失 |
关闭后定时器清除 | 1. 点击有照片的患者行 2. 1 秒后手动关闭 3. 等待 3 秒 |
照片不应再次出现 |
测试场景 | 操作步骤 | 预期结果 |
---|---|---|
拖拽移动位置 | 1. 显示照片 2. 鼠标按住拖拽 |
照片应跟随鼠标移动 |
拖拽到边界 | 1. 显示照片 2. 尝试拖出窗口范围 |
照片应被限制在窗口内 |
拖拽时鼠标样式 | 1. 显示照片 2. 鼠标悬停 |
鼠标应显示 grab 光标 |
拖拽中鼠标样式 | 1. 显示照片 2. 拖拽中 |
鼠标应显示 grabbing 光标 |
测试场景 | 操作步骤 | 预期结果 |
---|---|---|
Worklist 中选中 | 1. 在 worklist 点击患者行 | 显示照片 |
HistoryList 中选中 | 1. 在 historylist 点击患者行 | 显示照片 |
跨页面切换 | 1. 在 worklist 选中患者 A 2. 切换到 historylist |
照片应消失或保持显示患者 A |
测试场景 | 操作步骤 | 预期结果 |
---|---|---|
快速切换选中 | 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",
// ... 其他必需字段
};
portrait_file
为空或格式错误影响:可能导致请求错误的 URL
解决方案:
// 在 getPortraitUrl 中添加验证
export const getPortraitUrl = (filename: string): string => {
if (!filename || filename.trim() === '') {
return ''; // 返回空字符串,组件不显示
}
return `${IP_PORT}/api/v1/pub/capture/${filename}`;
};
影响:TypeScript 可选字段可能为 undefined
解决方案:
?
)定义hasPortrait()
函数统一检查||
提供默认值影响:用户一直看到 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]);
影响:浪费带宽,可能导致显示错乱
解决方案:
影响:影响用户体验
解决方案:
影响:长时间使用导致页面卡顿
解决方案:
// 清理定时器和事件监听
useEffect(() => {
return () => {
if (autoCloseTimerRef.current) {
clearTimeout(autoCloseTimerRef.current);
}
};
}, []);
// 清理拖拽事件
useEffect(() => {
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
影响:用户无法操作被遮挡的区域
解决方案:
影响:视觉体验差
解决方案:
// 添加防抖延迟
const debouncedPatientId = useDebounce(selectedPatientId, 200);
影响:可能显示 undefined
解决方案:
?
)影响:拖拽可能不流畅
解决方案:
mousedown/mousemove/mouseup
事件e.preventDefault()
防止默认行为影响:可能显示错误的照片
解决方案:
selectedPatientSlice
中正确处理两个页面的选中事件影响:找不到对应的 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]);
设计思路:使用 Redux 的 extraReducers
监听机制实现自动状态同步
优点:
设计思路:独立的浮动组件,通过 Redux state 驱动显示
优点:
设计思路:简单的工具函数,封装 URL 构建逻辑
优点:
核心功能:
本功能成功实现了以下目标:
该实现遵循了 React + Redux 的最佳实践,代码结构清晰,易于维护和扩展。