feat(V31): CSSD消毒供应追溯+影像3D重建(选配模块深度实现)
- V31 Flyway: 8张新表(CSSD器械包/追溯记录/灭菌批次/灭菌明细/过期预警+3D重建任务/结果/报告) - CSSD: 全流程追溯(回收→清洗→消毒→包装→灭菌→储存→发放)+扫码+灭菌三要素+过期预警 - 3D重建: Cornerstone.js+VTK.js架构设计+VR/MPR/MIP渲染+传递函数预设+测量工具 - 608行深度技术设计文档(MD/specs/RECONSTRUCTION_3D_DEEP_DESIGN.md) - 2个前端页面(CSSD追溯/3D重建查看器) - 后端编译通过,前端构建通过
This commit is contained in:
188
MD/specs/CSSD_AND_3D_DESIGN.md
Normal file
188
MD/specs/CSSD_AND_3D_DESIGN.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 消毒供应中心(CSSD)追溯管理 + 影像3D重建 深度设计文档
|
||||
|
||||
> **文档类型**: 技术设计
|
||||
> **版本**: v1.0
|
||||
> **编制日期**: 2026-06-07
|
||||
> **依据标准**: WS 310.1-310.3《医院消毒供应中心》、DICOM 3D重建规范
|
||||
|
||||
---
|
||||
|
||||
## 一、消毒供应中心(CSSD)追溯管理
|
||||
|
||||
### 1.1 业务背景
|
||||
|
||||
根据《医院消毒供应中心 第1部分:管理规范》(WS 310.1-2016)和《医院消毒供应中心 第3部分:清洗消毒及灭菌效果监测标准》(WS 310.3-2016),三甲医院CSSD必须实现:
|
||||
|
||||
- **全流程追溯**: 从器械回收→分类→清洗→消毒→干燥→检查→包装→灭菌→储存→发放的全链路可追溯
|
||||
- **条码管理**: 每个器械包有唯一追溯码,支持扫码追溯
|
||||
- **灭菌监测**: 生物监测、化学监测、物理监测的记录和预警
|
||||
- **有效期管理**: 灭菌后器械包的有效期自动计算和过期预警
|
||||
- **质量统计**: 清洗合格率、灭菌合格率、器械使用次数统计
|
||||
|
||||
### 1.2 业务流程
|
||||
|
||||
```
|
||||
手术室/科室 使用后器械
|
||||
↓ (回收)
|
||||
CSSD回收清点 → 扫码登记(器械包ID+数量+来源科室)
|
||||
↓
|
||||
分类 → 按器械类型分拣(手术器械/管腔器械/精密器械/普通器械)
|
||||
↓
|
||||
清洗 → 手工清洗/超声清洗/机器清洗 → 清洗效果监测(ATP/蛋白残留)
|
||||
↓
|
||||
消毒 → 湿热消毒(≥90℃/5min) 或 化学消毒
|
||||
↓
|
||||
干燥 → 热风干燥/自然干燥
|
||||
↓
|
||||
检查保养 → 功能检查+润滑+包装材料选择
|
||||
↓
|
||||
包装 → 器械放入包装袋/容器 → 封口 → 贴追溯标签(条码+灭菌日期+有效期)
|
||||
↓
|
||||
灭菌 → 压力蒸汽灭菌(134℃/4min) 或 低温灭菌 → 化学指示卡变色确认
|
||||
↓
|
||||
储存 → 无菌物品存放区 → 按效期管理(先进先出)
|
||||
↓
|
||||
发放 → 扫码出库 → 送达手术室/科室 → 签收确认
|
||||
```
|
||||
|
||||
### 1.3 数据模型
|
||||
|
||||
#### 核心实体
|
||||
|
||||
1. **CssdTray** (器械包) — 器械包基础信息
|
||||
- id, tray_code(唯一编码), tray_name, tray_type(手术/管腔/精密/普通)
|
||||
- department_source(来源科室), status(在用/清洗中/灭菌中/储存中/已发放)
|
||||
- current_location(当前位置), total_uses(使用次数)
|
||||
- sterilize_count(灭菌次数), last_sterilize_time
|
||||
|
||||
2. **CssdTraceRecord** (追溯记录) — 每次流转记录
|
||||
- id, tray_id, step_type(回收/清洗/消毒/包装/灭菌/储存/发放)
|
||||
- operator_id, operator_name, operation_time
|
||||
- device_name(设备名称), device_code
|
||||
- parameters(JSON: 温度/时间/压力等工艺参数)
|
||||
- result(合格/不合格), remark
|
||||
|
||||
3. **CssdSterilizeBatch** (灭菌批次) — 每锅灭菌记录
|
||||
- id, batch_code, sterilizer_name, sterilizer_code
|
||||
- start_time, end_time, cycle_type(预真空/下排气/快速)
|
||||
- temperature, pressure, exposure_time
|
||||
- biological_result(生物监测: 合格/不合格/待检)
|
||||
- chemical_result(化学监测: 合格/不合格)
|
||||
- physical_result(物理监测: 合格/不合格)
|
||||
- batch_status(进行中/已完成/已释放)
|
||||
|
||||
4. **CssdSterilizeItem** (灭菌包明细) — 批次内的器械包
|
||||
- id, batch_id, tray_id
|
||||
- chemical_indicator(化学指示卡颜色变化)
|
||||
- bi_indicator(生物指示剂结果)
|
||||
|
||||
5. **CssdExpiryAlert** (过期预警) — 有效期管理
|
||||
- id, tray_id, batch_id
|
||||
- sterilize_time, expiry_time, alert_time
|
||||
- status(正常/预警/过期)
|
||||
|
||||
### 1.4 业务规则
|
||||
|
||||
| 规则编号 | 规则名称 | 规则描述 |
|
||||
|---------|---------|---------|
|
||||
| R1 | 回收扫码 | 所有器械包回收时必须扫码登记 |
|
||||
| R2 | 清洗监测 | 清洗后必须进行ATP或蛋白残留检测 |
|
||||
| R3 | 包装标签 | 包装必须贴追溯标签(条码+日期+有效期) |
|
||||
| R4 | 灭菌三要素 | 生物+化学+物理三项监测全部合格才能释放 |
|
||||
| R5 | 有效期管理 | 压力蒸汽灭菌: 无菌包装180天, 无纺布14天, 棉布7天 |
|
||||
| R6 | 过期拦截 | 过期器械包禁止发放,必须重新灭菌 |
|
||||
| R7 | 使用次数 | 器械包达到最大使用次数(可配置)时提醒报废 |
|
||||
|
||||
### 1.5 接口设计
|
||||
|
||||
| API | 方法 | 说明 |
|
||||
|-----|------|------|
|
||||
| /cssd/tray/page | GET | 器械包列表 |
|
||||
| /cssd/tray/add | POST | 新建器械包 |
|
||||
| /cssd/trace/scan | POST | 扫码追溯(流转) |
|
||||
| /cssd/trace/history/{trayId} | GET | 器械包追溯历史 |
|
||||
| /cssd/sterilize/batch/page | GET | 灭菌批次列表 |
|
||||
| /cssd/sterilize/batch/add | POST | 新建灭菌批次 |
|
||||
| /cssd/sterilize/batch/complete/{id} | PUT | 完成灭菌(录入监测结果) |
|
||||
| /cssd/sterilize/batch/release/{id} | PUT | 释放批次(三项监测合格) |
|
||||
| /cssd/expiry/alerts | GET | 过期预警列表 |
|
||||
| /cssd/stats/overview | GET | 统计概览(清洗率/灭菌率/过期数) |
|
||||
|
||||
---
|
||||
|
||||
## 二、影像3D重建
|
||||
|
||||
### 2.1 业务背景
|
||||
|
||||
影像3D重建是影像诊断的高级功能,主要应用于:
|
||||
- **骨科**: 骨折三维重建,辅助手术规划
|
||||
- **心血管**: 冠脉CTA三维重建,评估狭窄程度
|
||||
- **胸腹部**: 肿瘤三维定位,评估与周围组织关系
|
||||
- **口腔**: 颌面骨三维重建,正畸/种植规划
|
||||
|
||||
### 2.2 业务流程
|
||||
|
||||
```
|
||||
CT/MRI扫描 → DICOM图像导入
|
||||
↓
|
||||
图像预处理 → 去噪/增强/分割
|
||||
↓
|
||||
三维重建 → Volume Rendering(容积渲染) / MPR(多平面重建) / MIP(最大密度投影)
|
||||
↓
|
||||
后处理 → 测量(距离/角度/体积) / 标注 / 裁剪
|
||||
↓
|
||||
生成报告 → 3D截图 + 测量数据 + 诊断结论
|
||||
↓
|
||||
审核发布 → 主治医师审核 → 发布到PACS/病历系统
|
||||
```
|
||||
|
||||
### 2.3 数据模型
|
||||
|
||||
#### 核心实体
|
||||
|
||||
1. **ReconstructionTask** (重建任务) — 3D重建任务
|
||||
- id, patient_id, patient_name, encounter_id
|
||||
- apply_id(检查申请ID), study_uid(DICOM StudyUID)
|
||||
- modality(CT/MRI), body_part, scan_range
|
||||
- task_status(PENDING/PROCESSING/COMPLETED/FAILED)
|
||||
- reconstruction_type(VR/MPR/MIP/VR+MPR)
|
||||
- result_path(重建结果存储路径)
|
||||
- slice_thickness, pixel_spacing
|
||||
- request_doctor, complete_time
|
||||
|
||||
2. **ReconstructionResult** (重建结果) — 3D重建输出
|
||||
- id, task_id, result_type(VR/MPR/MIP)
|
||||
- image_path(截图路径), volume_data_path(体数据路径)
|
||||
- measurements(JSON: 距离/角度/体积等测量数据)
|
||||
- annotations(JSON: 标注信息)
|
||||
- report_text(报告文本)
|
||||
|
||||
3. **ReconstructionReport** (重建报告) — 3D重建报告
|
||||
- id, task_id, patient_id, encounter_id
|
||||
- findings(所见), impression(印象), conclusion(结论)
|
||||
- report_doctor, report_time
|
||||
- verify_doctor, verify_time
|
||||
- status(DRAFT/REPORTED/VERIFIED)
|
||||
|
||||
### 2.4 业务规则
|
||||
|
||||
| 规则编号 | 规则名称 | 规则描述 |
|
||||
|---------|---------|---------|
|
||||
| R1 | 任务触发 | 由影像科医生从PACS工作台发起3D重建任务 |
|
||||
| R2 | 图像要求 | 至少需要50层连续断层图像才能进行3D重建 |
|
||||
| R3 | 重建类型 | 支持VR(容积渲染)/MPR(多平面)/MIP(最大密度投影) |
|
||||
| R4 | 测量功能 | 支持距离/角度/面积/体积测量 |
|
||||
| R5 | 报告审核 | 3D重建报告必须由主治以上医师审核 |
|
||||
| R6 | 图像存储 | 重建结果存储在PACS归档系统,保留期≥1年 |
|
||||
|
||||
### 2.5 接口设计
|
||||
|
||||
| API | 方法 | 说明 |
|
||||
|-----|------|------|
|
||||
| /reconstruction/task/page | GET | 重建任务列表 |
|
||||
| /reconstruction/task/add | POST | 新建重建任务 |
|
||||
| /reconstruction/task/complete/{id} | PUT | 完成重建 |
|
||||
| /reconstruction/result/list/{taskId} | GET | 重建结果列表 |
|
||||
| /reconstruction/report/add | POST | 新建报告 |
|
||||
| /reconstruction/report/verify/{id} | PUT | 审核报告 |
|
||||
| /reconstruction/stats | GET | 统计(任务数/完成率/类型分布) |
|
||||
608
MD/specs/RECONSTRUCTION_3D_DEEP_DESIGN.md
Normal file
608
MD/specs/RECONSTRUCTION_3D_DEEP_DESIGN.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# 影像3D重建 — 深度技术设计文档
|
||||
|
||||
> **文档类型**: 深度技术设计
|
||||
> **版本**: v1.0
|
||||
> **编制日期**: 2026-06-07
|
||||
> **技术栈**: Cornerstone.js(DICOM解析) + VTK.js(3D渲染) + Spring Boot(后端处理)
|
||||
|
||||
---
|
||||
|
||||
## 一、技术选型分析
|
||||
|
||||
### 1.1 前端3D渲染方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 | 推荐度 |
|
||||
|------|------|------|--------|
|
||||
| **Cornerstone.js + VTK.js** | 专为医学影像设计,DICOM原生支持,WebGL GPU加速 | 学习曲线较陡 | ⭐⭐⭐⭐⭐ |
|
||||
| **Three.js** | 通用3D引擎,社区大 | 无DICOM支持,需自行解析 | ⭐⭐⭐ |
|
||||
| **OHIF Viewer** | 完整PACS查看器 | 太重,集成复杂 | ⭐⭐⭐ |
|
||||
| **MITK** | 功能全面的医学影像工具包 | C++为主,Web支持弱 | ⭐⭐ |
|
||||
|
||||
**推荐方案**: Cornerstone.js + VTK.js
|
||||
- **Cornerstone.js**: DICOM图像解析、2D查看、MPR重建
|
||||
- **VTK.js**: 容积渲染(VR)、等值面提取、3D测量
|
||||
|
||||
### 1.2 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端 (Vue 3 + Vite) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
|
||||
│ │Cornerstone│ │ VTK.js │ │测量工具栏│ │报告编辑器│ │
|
||||
│ │ DICOM解析 │ │ 3D渲染 │ │距离/角度 │ │所见/印象 │ │
|
||||
│ │ 2D/MPR │ │VR/MIP │ │体积/面积 │ │结论 │ │
|
||||
│ └─────┬────┘ └─────┬────┘ └────┬─────┘ └────┬────┘ │
|
||||
│ └─────────────┴────────────┴──────────────┘ │
|
||||
│ ↓ HTTP/REST API │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 后端 (Spring Boot 4.0.6) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
|
||||
│ │DICOM解析 │ │任务调度 │ │结果存储 │ │PACS对接 │ │
|
||||
│ │dcm4che │ │异步处理 │ │MinIO/NFS │ │WADO-RS │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 存储层 │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │PostgreSQL │ │文件存储 │ │PACS系统 │ │
|
||||
│ │ 元数据 │ │MinIO/NFS │ │DICOM节点 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、后端深度设计
|
||||
|
||||
### 2.1 DICOM解析服务
|
||||
|
||||
#### 2.1.1 dcm4che集成
|
||||
|
||||
```java
|
||||
// DICOM文件解析流程
|
||||
public class DicomParserService {
|
||||
|
||||
// 解析DICOM文件元数据
|
||||
public DicomMetadata parseDicomFile(InputStream dicomStream) {
|
||||
// 1. 使用dcm4che读取DICOM文件
|
||||
Dataset ds = DicomInputStream.read(dicomStream);
|
||||
|
||||
// 2. 提取关键元数据
|
||||
DicomMetadata metadata = new DicomMetadata();
|
||||
metadata.setPatientName(ds.getString(Tag.PatientName)); // 患者姓名
|
||||
metadata.setPatientId(ds.getString(Tag.PatientID)); // 患者ID
|
||||
metadata.setStudyInstanceUID(ds.getString(Tag.StudyInstanceUID)); // 检查UID
|
||||
metadata.setSeriesInstanceUID(ds.getString(Tag.SeriesInstanceUID)); // 序列UID
|
||||
metadata.setSopInstanceUID(ds.getString(Tag.SOPInstanceUID)); // 实例UID
|
||||
metadata.setModality(ds.getString(Tag.Modality)); // CT/MRI/US
|
||||
metadata.setStudyDate(ds.getString(Tag.StudyDate)); // 检查日期
|
||||
metadata.setBodyPartExamined(ds.getString(Tag.BodyPartExamined)); // 检查部位
|
||||
metadata.setImageOrientationPatient(ds.getStrings(Tag.ImageOrientationPatient)); // 图像方向
|
||||
metadata.setImagePositionPatient(ds.getStrings(Tag.ImagePositionPatient)); // 图像位置
|
||||
metadata.setPixelSpacing(ds.getStrings(Tag.PixelSpacing)); // 像素间距
|
||||
metadata.setSliceThickness(ds.getString(Tag.SliceThickness)); // 层厚
|
||||
metadata.setRows(ds.getInt(Tag.Rows)); // 行数
|
||||
metadata.setColumns(ds.getInt(Tag.Columns)); // 列数
|
||||
metadata.setBitsAllocated(ds.getInt(Tag.BitsAllocated)); // 位深
|
||||
metadata.setWindowCenter(ds.getString(Tag.WindowCenter)); // 窗位
|
||||
metadata.setWindowWidth(ds.getString(Tag.WindowWidth)); // 窗宽
|
||||
|
||||
// 3. 提取像素数据
|
||||
byte[] pixelData = ds.getBytes(Tag.PixelData);
|
||||
metadata.setPixelData(pixelData);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// 批量解析同一Series的所有DICOM文件
|
||||
public List<DicomMetadata> parseSeries(List<InputStream> dicomFiles) {
|
||||
List<DicomMetadata> series = new ArrayList<>();
|
||||
for (InputStream file : dicomFiles) {
|
||||
series.add(parseDicomFile(file));
|
||||
}
|
||||
// 按ImagePositionPatient排序(确保层序正确)
|
||||
series.sort(Comparator.comparing(m ->
|
||||
Double.parseDouble(m.getImagePositionPatient()[2])));
|
||||
return series;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.1.2 3D重建处理服务
|
||||
|
||||
```java
|
||||
// 3D重建处理服务
|
||||
@Service
|
||||
public class ReconstructionProcessingService {
|
||||
|
||||
@Async("reconstructionExecutor")
|
||||
public void processReconstruction(Long taskId) {
|
||||
// 1. 获取任务信息
|
||||
ReconstructionTask task = taskMapper.selectById(taskId);
|
||||
task.setTaskStatus("PROCESSING");
|
||||
taskMapper.updateById(task);
|
||||
|
||||
try {
|
||||
// 2. 加载DICOM序列数据
|
||||
List<DicomMetadata> series = loadDicomSeries(task.getApplyId());
|
||||
|
||||
// 3. 预处理: 去噪 + 窗宽窗位调整
|
||||
float[][][] volumeData = preprocessVolume(series);
|
||||
|
||||
// 4. 根据重建类型执行
|
||||
switch (task.getReconstructionType()) {
|
||||
case "VR": // 容积渲染
|
||||
processVolumeRendering(task, volumeData);
|
||||
break;
|
||||
case "MPR": // 多平面重建
|
||||
processMPR(task, volumeData);
|
||||
break;
|
||||
case "MIP": // 最大密度投影
|
||||
processMIP(task, volumeData);
|
||||
break;
|
||||
case "VR+MPR": // 混合重建
|
||||
processVolumeRendering(task, volumeData);
|
||||
processMPR(task, volumeData);
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. 生成结果截图
|
||||
saveResultImages(task);
|
||||
|
||||
// 6. 更新任务状态
|
||||
task.setTaskStatus("COMPLETED");
|
||||
task.setCompleteTime(new Date());
|
||||
task.setResultPath("/reconstruction/" + taskId + "/");
|
||||
taskMapper.updateById(task);
|
||||
|
||||
} catch (Exception e) {
|
||||
task.setTaskStatus("FAILED");
|
||||
taskMapper.updateById(task);
|
||||
log.error("3D重建任务失败: {}", taskId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 容积渲染(Volume Rendering)
|
||||
private void processVolumeRendering(ReconstructionTask task, float[][][] volume) {
|
||||
// 1. 建立体数据(Volume Data)
|
||||
int dimX = volume.length;
|
||||
int dimY = volume[0].length;
|
||||
int dimZ = volume[0][0].length;
|
||||
|
||||
// 2. 传递函数(Transfer Function)设置
|
||||
// CT值 → 颜色+透明度
|
||||
// 骨骼: 高CT值(>300), 不透明, 白色
|
||||
// 软组织: 中CT值(30-300), 半透明, 粉色
|
||||
// 空气: 低CT值(<-500), 全透明
|
||||
TransferFunction tf = new TransferFunction();
|
||||
tf.addMapping(-1000, 0.0f, 0.0f, 0.0f, 0.0f); // 空气: 全透明
|
||||
tf.addMapping(-500, 0.0f, 0.0f, 0.0f, 0.0f); // 肺: 全透明
|
||||
tf.addMapping(30, 0.8f, 0.2f, 0.2f, 0.4f); // 软组织: 半透明粉红
|
||||
tf.addMapping(300, 0.9f, 0.9f, 0.8f, 0.9f); // 骨骼: 不透明白
|
||||
tf.addMapping(3000, 1.0f, 1.0f, 1.0f, 1.0f); // 金属: 全不透明
|
||||
|
||||
// 3. 光线投射(Ray Casting)算法
|
||||
// 从每个像素发射光线,沿光线采样,累积颜色和透明度
|
||||
// C(积累) = Σ(Ci * αi * Π(1-αj))
|
||||
|
||||
// 4. 保存渲染结果为PNG
|
||||
saveVolumeRenderingResult(task, tf);
|
||||
}
|
||||
|
||||
// 多平面重建(Multi-Planar Reconstruction)
|
||||
private void processMPR(ReconstructionTask task, float[][][] volume) {
|
||||
// 1. 矢状面(Sagittal)重建: 沿X轴切割
|
||||
float[][] sagittalPlane = extractSagittalPlane(volume, volume.length / 2);
|
||||
|
||||
// 2. 冠状面(Coronal)重建: 沿Y轴切割
|
||||
float[][] coronalPlane = extractCoronalPlane(volume, volume[0].length / 2);
|
||||
|
||||
// 3. 轴位(Axial)重建: 沿Z轴切割(原始方向)
|
||||
float[][] axialPlane = extractAxialPlane(volume, volume[0][0].length / 2);
|
||||
|
||||
// 4. 交互式切割: 支持任意角度平面
|
||||
// 通过旋转矩阵变换采样坐标
|
||||
|
||||
saveMPRResult(task, sagittalPlane, coronalPlane, axialPlane);
|
||||
}
|
||||
|
||||
// 最大密度投影(Maximum Intensity Projection)
|
||||
private void processMIP(ReconstructionTask task, float[][][] volume) {
|
||||
int dimX = volume.length;
|
||||
int dimY = volume[0].length;
|
||||
int dimZ = volume[0][0].length;
|
||||
|
||||
float[][] mipImage = new float[dimX][dimY];
|
||||
for (int x = 0; x < dimX; x++) {
|
||||
for (int y = 0; y < dimY; y++) {
|
||||
float maxVal = Float.MIN_VALUE;
|
||||
for (int z = 0; z < dimZ; z++) {
|
||||
maxVal = Math.max(maxVal, volume[x][y][z]);
|
||||
}
|
||||
mipImage[x][y] = maxVal;
|
||||
}
|
||||
}
|
||||
saveMIPResult(task, mipImage);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 DICOM存储方案
|
||||
|
||||
```
|
||||
存储架构:
|
||||
├── PostgreSQL → 元数据(患者/检查/序列/任务/报告)
|
||||
├── MinIO/NFS → DICOM原始文件 + 重建结果(截图/体数据)
|
||||
└── PACS系统 → 通过WADO-RS/DICOMweb获取DICOM图像
|
||||
|
||||
获取DICOM数据流程:
|
||||
1. 任务创建时 → 从PACS获取StudyUID对应的DICOM文件
|
||||
2. 使用WADO-RS协议: GET /dicomweb/studies/{studyUid}/series/{seriesUid}
|
||||
3. 下载到本地临时目录 → 解析 → 处理 → 清理临时文件
|
||||
4. 重建结果保存到MinIO → 元数据保存到PostgreSQL
|
||||
```
|
||||
|
||||
### 2.3 接口设计(完整版)
|
||||
|
||||
| API | 方法 | 说明 | 参数 |
|
||||
|-----|------|------|------|
|
||||
| /reconstruction/task/page | GET | 任务列表(分页+筛选) | patientName,modality,status,pageNo,pageSize |
|
||||
| /reconstruction/task/add | POST | 新建任务(从PACS拉取) | patientId,studyUid,modality,bodyPart,reconstructionType |
|
||||
| /reconstruction/task/{id} | GET | 任务详情 | - |
|
||||
| /reconstruction/task/cancel/{id} | PUT | 取消任务 | - |
|
||||
| /reconstruction/result/list/{taskId} | GET | 重建结果列表 | - |
|
||||
| /reconstruction/result/{id}/image | GET | 获取结果截图 | width,height,window |
|
||||
| /reconstruction/result/{id}/volume | GET | 获取体数据(JSON格式) | resolution |
|
||||
| /reconstruction/report/add | POST | 新建报告 | taskId,findings,impression,conclusion |
|
||||
| /reconstruction/report/{id} | GET | 报告详情 | - |
|
||||
| /reconstruction/report/verify/{id} | PUT | 审核报告 | verifyDoctor |
|
||||
| /reconstruction/stats | GET | 统计概览 | startDate,endDate |
|
||||
|
||||
---
|
||||
|
||||
## 三、前端深度设计
|
||||
|
||||
### 3.1 技术栈
|
||||
|
||||
```json
|
||||
{
|
||||
"cornerstone-core": "^2.6.1", // DICOM图像解析与2D显示
|
||||
"cornerstone-wado-image-loader": "^4.13.2", // WADO加载器
|
||||
"cornerstone-tools": "^7.1.0", // 交互工具(测量/标注)
|
||||
"vtk.js": "^29.0.0", // 3D渲染引擎(WebGL)
|
||||
"dicom-parser": "^1.8.21" // DICOM文件解析
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 组件架构
|
||||
|
||||
```
|
||||
src/views/reconstruction/
|
||||
├── index.vue # 主页面(任务列表+工作台)
|
||||
├── api.js # API接口
|
||||
├── components/
|
||||
│ ├── DicomViewer.vue # 2D DICOM查看器(Cornerstone)
|
||||
│ ├── MprViewer.vue # MPR多平面重建查看器
|
||||
│ ├── VrViewer.vue # VR容积渲染查看器(VTK.js)
|
||||
│ ├── MipViewer.vue # MIP最大密度投影查看器
|
||||
│ ├── MeasurementToolbar.vue # 测量工具栏
|
||||
│ ├── ReconstructionTaskList.vue # 任务列表
|
||||
│ ├── ReconstructionReport.vue # 报告编辑器
|
||||
│ └── ReconstructionStats.vue # 统计面板
|
||||
```
|
||||
|
||||
### 3.3 核心组件实现
|
||||
|
||||
#### DicomViewer.vue (2D DICOM查看器)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="dicom-viewer">
|
||||
<div ref="viewerContainer" class="viewer-container" />
|
||||
<div class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button @click="setTool('windowing')">窗宽窗位</el-button>
|
||||
<el-button @click="setTool('pan')">平移</el-button>
|
||||
<el-button @click="setTool('zoom')">缩放</el-button>
|
||||
<el-button @click="setTool('length')">距离测量</el-button>
|
||||
<el-button @click="setTool('angle')">角度测量</el-button>
|
||||
<el-button @click="setTool('area')">面积测量</el-button>
|
||||
<el-button @click="setTool('ellipse')">椭圆面积</el-button>
|
||||
<el-button @click="setTool('probe')">CT值探针</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
<div class="info-overlay">
|
||||
<div>Patient: {{ patientInfo.name }}</div>
|
||||
<div>Window: {{ windowCenter }}/{{ windowWidth }}</div>
|
||||
<div>Zoom: {{ zoomLevel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import * as cornerstone from 'cornerstone-core'
|
||||
import * as cornerstoneTools from 'cornerstone-tools'
|
||||
import * as cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'
|
||||
import dicomParser from 'dicom-parser'
|
||||
|
||||
// 初始化Cornerstone
|
||||
cornerstoneWADOImageLoader.external.dicomParser = dicomParser
|
||||
cornerstoneWADOImageLoader.external.cornerstone = cornerstone
|
||||
|
||||
const viewerContainer = ref(null)
|
||||
let enabledElement = null
|
||||
|
||||
onMounted(() => {
|
||||
// 启用Cornerstone
|
||||
enabledElement = cornerstone.getEnabledElement(viewerContainer.value)
|
||||
cornerstone.enable(viewerContainer.value)
|
||||
|
||||
// 加载DICOM图像
|
||||
loadDicomImage()
|
||||
})
|
||||
|
||||
const loadDicomImage = async () => {
|
||||
const imageId = `wado:${wadoRoot}?requestType=WADO&studyUID=${studyUid}&seriesUID=${seriesUid}&objectUID=${sopUid}`
|
||||
const image = await cornerstone.loadAndCacheImage(imageId)
|
||||
cornerstone.displayImage(enabledElement, image)
|
||||
|
||||
// 启用工具
|
||||
cornerstoneTools.mouseInput.enable(enabledElement)
|
||||
cornerstoneTools.mouseWheelInput.enable(enabledElement)
|
||||
cornerstoneTools.wwwc.activate(enabledElement, 1) // 左键: 窗宽窗位
|
||||
cornerstoneTools.pan.activate(enabledElement, 2) // 右键: 平移
|
||||
cornerstoneTools.zoom.activate(enabledElement, 4) // 中键: 缩放
|
||||
cornerstoneTools.length.enable(enabledElement) // 距离测量
|
||||
cornerstoneTools.angle.enable(enabledElement) // 角度测量
|
||||
cornerstoneTools.ellipseRoi.enable(enabledElement) // 椭圆面积
|
||||
cornerstoneTools.probe.enable(enabledElement) // CT值探针
|
||||
}
|
||||
|
||||
const setTool = (toolName) => {
|
||||
// 切换工具并高亮按钮
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
#### VrViewer.vue (VR容积渲染)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="vr-viewer">
|
||||
<div ref="vtkContainer" class="vtk-container" />
|
||||
<div class="transfer-function-panel">
|
||||
<div class="preset-buttons">
|
||||
<el-button @click="applyPreset('bone')">骨骼</el-button>
|
||||
<el-button @click="applyPreset('softTissue')">软组织</el-button>
|
||||
<el-button @click="applyPreset('lung')">肺部</el-button>
|
||||
<el-button @click="applyPreset('angio')">血管</el-button>
|
||||
<el-button @click="applyPreset('skin')">皮肤</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import vtkProxyManager from 'vtk.js/Sources/Proxy/ProxyManager'
|
||||
|
||||
let proxyManager = null
|
||||
|
||||
onMounted(() => {
|
||||
initVtk()
|
||||
})
|
||||
|
||||
const initVtk = () => {
|
||||
// 创建VTK ProxyManager
|
||||
proxyManager = vtkProxyManager.newInstance({ container: vtkContainer.value })
|
||||
|
||||
// 加载体数据
|
||||
const source = proxyManager.createProxy('Sources', 'TrivialProducer')
|
||||
source.setInputData(volumeData)
|
||||
|
||||
// 创建VR表示
|
||||
const representation = proxyManager.createProxy('Representations', 'Volume')
|
||||
representation.setInput(source)
|
||||
|
||||
// 设置传递函数
|
||||
const actor = representation.getActor()
|
||||
const property = actor.getProperty()
|
||||
|
||||
// 设置预设
|
||||
applyPreset('bone')
|
||||
}
|
||||
|
||||
const applyPreset = (preset) => {
|
||||
const presets = {
|
||||
bone: [
|
||||
{ value: -1000, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: -300, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: 200, opacity: 0.2, color: [0.8, 0.4, 0.3] },
|
||||
{ value: 500, opacity: 0.8, color: [1, 1, 1] },
|
||||
{ value: 3000, opacity: 1, color: [1, 1, 1] }
|
||||
],
|
||||
softTissue: [
|
||||
{ value: -1000, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: -500, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: 30, opacity: 0.3, color: [0.8, 0.3, 0.3] },
|
||||
{ value: 100, opacity: 0.5, color: [0.9, 0.5, 0.5] },
|
||||
{ value: 300, opacity: 0.8, color: [1, 0.8, 0.7] },
|
||||
{ value: 3000, opacity: 1, color: [1, 1, 1] }
|
||||
],
|
||||
lung: [
|
||||
{ value: -1000, opacity: 0.3, color: [0.2, 0.2, 0.3] },
|
||||
{ value: -700, opacity: 0.1, color: [0.4, 0.4, 0.5] },
|
||||
{ value: -300, opacity: 0.3, color: [0.6, 0.6, 0.7] },
|
||||
{ value: 0, opacity: 0.8, color: [0.8, 0.8, 0.8] },
|
||||
{ value: 3000, opacity: 1, color: [1, 1, 1] }
|
||||
],
|
||||
angio: [
|
||||
{ value: -1000, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: 100, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: 200, opacity: 0.5, color: [1, 0, 0] },
|
||||
{ value: 500, opacity: 0.9, color: [1, 0.2, 0.2] },
|
||||
{ value: 3000, opacity: 1, color: [1, 0.5, 0.5] }
|
||||
],
|
||||
skin: [
|
||||
{ value: -1000, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: -300, opacity: 0, color: [0, 0, 0] },
|
||||
{ value: -100, opacity: 0.2, color: [0.9, 0.7, 0.6] },
|
||||
{ value: 50, opacity: 0.5, color: [0.95, 0.8, 0.7] },
|
||||
{ value: 200, opacity: 0.8, color: [1, 0.9, 0.85] },
|
||||
{ value: 3000, opacity: 1, color: [1, 1, 1] }
|
||||
]
|
||||
}
|
||||
|
||||
// 应用传递函数到VTK actor
|
||||
const preset = presets[preset] || presets.bone
|
||||
applyTransferFunction(preset)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
#### MeasurementToolbar.vue (测量工具)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="measurement-toolbar">
|
||||
<el-button-group>
|
||||
<el-tooltip content="距离测量(Ctrl+D)">
|
||||
<el-button :type="activeTool==='length'?'primary':''" @click="activateTool('length')">
|
||||
<el-icon><Ruler /></el-icon> 距离
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="角度测量(Ctrl+A)">
|
||||
<el-button :type="activeTool==='angle'?'primary':''" @click="activateTool('angle')">
|
||||
<el-icon><ScaleToOriginal /></el-icon> 角度
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="面积测量(Ctrl+M)">
|
||||
<el-button :type="activeTool==='area'?'primary':''" @click="activateTool('area')">
|
||||
<el-icon><Grid /></el-icon> 面积
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="CT值探针(Ctrl+P)">
|
||||
<el-button :type="activeTool==='probe'?'primary':''" @click="activateTool('probe')">
|
||||
<el-icon><Position /></el-icon> CT值
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 测量结果列表 -->
|
||||
<div class="measurement-results" v-if="measurements.length">
|
||||
<el-table :data="measurements" size="small" border>
|
||||
<el-table-column prop="type" label="类型" width="80"/>
|
||||
<el-table-column prop="value" label="测量值" width="120"/>
|
||||
<el-table-column prop="unit" label="单位" width="60"/>
|
||||
<el-table-column label="操作" width="60">
|
||||
<template #default="{row}">
|
||||
<el-button type="danger" link size="small" @click="removeMeasurement(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import * as cornerstoneTools from 'cornerstone-tools'
|
||||
|
||||
const activeTool = ref('')
|
||||
const measurements = ref([])
|
||||
|
||||
const activateTool = (toolName) => {
|
||||
activeTool.value = toolName
|
||||
|
||||
// 禁用所有工具
|
||||
cornerstoneTools.length.deactivate(enabledElement, 1)
|
||||
cornerstoneTools.angle.deactivate(enabledElement, 1)
|
||||
cornerstoneTools.ellipseRoi.deactivate(enabledElement, 1)
|
||||
cornerstoneTools.probe.deactivate(enabledElement, 1)
|
||||
|
||||
// 激活选中工具
|
||||
switch(toolName) {
|
||||
case 'length':
|
||||
cornerstoneTools.length.activate(enabledElement, 1)
|
||||
break
|
||||
case 'angle':
|
||||
cornerstoneTools.angle.activate(enabledElement, 1)
|
||||
break
|
||||
case 'area':
|
||||
cornerstoneTools.ellipseRoi.activate(enabledElement, 1)
|
||||
break
|
||||
case 'probe':
|
||||
cornerstoneTools.probe.activate(enabledElement, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库设计(补充)
|
||||
|
||||
### 4.1 补充字段
|
||||
|
||||
```sql
|
||||
-- 在reconstruction_task表补充字段
|
||||
ALTER TABLE reconstruction_task ADD COLUMN slice_count INT; -- 层数
|
||||
ALTER TABLE reconstruction_task ADD COLUMN pixel_spacing_x DECIMAL(6,3); -- X像素间距
|
||||
ALTER TABLE reconstruction_task ADD COLUMN pixel_spacing_y DECIMAL(6,3); -- Y像素间距
|
||||
ALTER TABLE reconstruction_task ADD COLUMN table_position VARCHAR(50); -- 床位位置
|
||||
ALTER TABLE reconstruction_task ADD COLUMN kvp INT; -- 管电压
|
||||
ALTER TABLE reconstruction_task ADD COLUMN mas DECIMAL(8,2); -- 管电流时间积
|
||||
|
||||
-- 在reconstruction_result表补充字段
|
||||
ALTER TABLE reconstruction_result ADD COLUMN rendering_time_ms INT; -- 渲染耗时
|
||||
ALTER TABLE reconstruction_result ADD COLUMN file_size_bytes BIGINT; -- 文件大小
|
||||
ALTER TABLE reconstruction_result ADD COLUMN thumbnail_path VARCHAR(500); -- 缩略图
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、部署架构
|
||||
|
||||
```
|
||||
前端构建: npm run build → dist/ → Nginx
|
||||
后端部署: Spring Boot JAR → Docker / 直接运行
|
||||
存储: MinIO(对象存储) / NFS(文件系统)
|
||||
PACS对接: WADO-RS / DICOM C-STORE
|
||||
GPU加速: 前端WebGL(VTK.js自带) / 后端可选CUDA加速(处理大型数据集)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、性能优化策略
|
||||
|
||||
### 6.1 前端优化
|
||||
- **LOD(Level of Detail)**: 根据缩放级别加载不同分辨率
|
||||
- **瓦片加载**: 大图像分块加载,减少内存占用
|
||||
- **Web Worker**: DICOM解析和预处理在Worker线程执行
|
||||
- **缓存策略**: Cornerstone缓存最近查看的图像
|
||||
|
||||
### 6.2 后端优化
|
||||
- **异步处理**: 3D重建任务异步执行,不阻塞请求
|
||||
- **批量解析**: 一次IO读取整个Series的DICOM文件
|
||||
- **结果缓存**: 重建结果缓存到Redis/文件系统
|
||||
- **并行处理**: 多个重建任务并行执行
|
||||
|
||||
### 6.3 存储优化
|
||||
- **压缩存储**: 体数据使用LZ4压缩
|
||||
- **增量保存**: 只保存变化部分
|
||||
- **分层存储**: 热数据SSD,冷数据HDD
|
||||
|
||||
---
|
||||
|
||||
## 七、安全设计
|
||||
|
||||
1. **访问控制**: 只有影像科医生可以发起重建任务
|
||||
2. **数据脱敏**: 患者敏感信息在非工作场景脱敏显示
|
||||
3. **操作审计**: 所有重建操作记录审计日志
|
||||
4. **数据加密**: DICOM文件传输使用HTTPS/TLS
|
||||
5. **权限分级**: 普通医生查看,主治以上审核报告
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.healthlink.his.web.cssd.controller;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.cssd.domain.*;
|
||||
import com.healthlink.his.cssd.service.*;
|
||||
import lombok.AllArgsConstructor;import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
@RestController @RequestMapping("/cssd") @Slf4j @AllArgsConstructor
|
||||
public class CssdController {
|
||||
private final ICssdTrayService trayService;
|
||||
private final ICssdTraceRecordService traceService;
|
||||
private final ICssdSterilizeBatchService batchService;
|
||||
private final ICssdSterilizeItemService itemService;
|
||||
private final ICssdExpiryAlertService expiryService;
|
||||
|
||||
// ==================== 器械包管理 ====================
|
||||
@GetMapping("/tray/page")
|
||||
public R<?> getTrayPage(@RequestParam(value="status",required=false) String status,
|
||||
@RequestParam(value="trayCode",required=false) String trayCode,
|
||||
@RequestParam(value="pageNo",defaultValue="1") Integer pageNo,
|
||||
@RequestParam(value="pageSize",defaultValue="20") Integer pageSize) {
|
||||
LambdaQueryWrapper<CssdTray> w = new LambdaQueryWrapper<>();
|
||||
w.eq(StringUtils.hasText(status), CssdTray::getStatus, status)
|
||||
.like(StringUtils.hasText(trayCode), CssdTray::getTrayCode, trayCode)
|
||||
.orderByDesc(CssdTray::getCreateTime);
|
||||
return R.ok(trayService.page(new Page<>(pageNo, pageSize), w));
|
||||
}
|
||||
@PostMapping("/tray/add") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> addTray(@RequestBody CssdTray t) { t.setStatus("IN_USE"); t.setTotalUses(0); t.setSterilizeCount(0); t.setCreateTime(new Date()); trayService.save(t); return R.ok(t); }
|
||||
@PutMapping("/tray/update") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> updateTray(@RequestBody CssdTray t) { trayService.updateById(t); return R.ok(t); }
|
||||
|
||||
// ==================== 扫码追溯(核心流程) ====================
|
||||
@PostMapping("/trace/scan") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> scanTrace(@RequestBody Map<String, Object> params) {
|
||||
String trayCode = (String) params.get("trayCode");
|
||||
String stepType = (String) params.get("stepType");
|
||||
String operatorName = (String) params.get("operatorName");
|
||||
String deviceName = (String) params.get("deviceName");
|
||||
String remark = (String) params.get("remark");
|
||||
|
||||
// 查找器械包
|
||||
LambdaQueryWrapper<CssdTray> tw = new LambdaQueryWrapper<>();
|
||||
tw.eq(CssdTray::getTrayCode, trayCode);
|
||||
CssdTray tray = trayService.getOne(tw);
|
||||
if (tray == null) return R.fail("器械包不存在: " + trayCode);
|
||||
|
||||
// 更新器械包状态
|
||||
Map<String, String> statusFlow = Map.of(
|
||||
"RECYCLE", "WASHING", "WASH", "DISINFECTING",
|
||||
"DISINFECT", "PACKING", "PACK", "STERILIZING",
|
||||
"STERILIZE", "STORED", "STORE", "DISTRIBUTED"
|
||||
);
|
||||
String newStatus = statusFlow.getOrDefault(stepType, tray.getStatus());
|
||||
tray.setStatus(newStatus);
|
||||
tray.setCurrentLocation(stepType);
|
||||
|
||||
if ("STERILIZE".equals(stepType)) {
|
||||
tray.setSterilizeCount(tray.getSterilizeCount() + 1);
|
||||
tray.setLastSterilizeTime(new Date());
|
||||
}
|
||||
if ("DISTRIBUTE".equals(stepType)) {
|
||||
tray.setTotalUses(tray.getTotalUses() + 1);
|
||||
}
|
||||
trayService.updateById(tray);
|
||||
|
||||
// 保存追溯记录
|
||||
CssdTraceRecord record = new CssdTraceRecord();
|
||||
record.setTrayId(tray.getId());
|
||||
record.setTrayCode(trayCode);
|
||||
record.setStepType(stepType);
|
||||
record.setOperatorName(operatorName);
|
||||
record.setDeviceName(deviceName);
|
||||
record.setOperationTime(new Date());
|
||||
record.setResult("PASS");
|
||||
record.setRemark(remark);
|
||||
record.setCreateTime(new Date());
|
||||
traceService.save(record);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("tray", tray);
|
||||
result.put("step", stepType);
|
||||
result.put("nextStep", statusFlow.get(stepType));
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/trace/history/{trayId}")
|
||||
public R<?> getTraceHistory(@PathVariable Long trayId) {
|
||||
LambdaQueryWrapper<CssdTraceRecord> w = new LambdaQueryWrapper<>();
|
||||
w.eq(CssdTraceRecord::getTrayId, trayId).orderByAsc(CssdTraceRecord::getOperationTime);
|
||||
return R.ok(traceService.list(w));
|
||||
}
|
||||
|
||||
// ==================== 灭菌批次 ====================
|
||||
@GetMapping("/sterilize/page")
|
||||
public R<?> getBatchPage(@RequestParam(value="batchStatus",required=false) String batchStatus,
|
||||
@RequestParam(value="pageNo",defaultValue="1") Integer pageNo,
|
||||
@RequestParam(value="pageSize",defaultValue="20") Integer pageSize) {
|
||||
LambdaQueryWrapper<CssdSterilizeBatch> w = new LambdaQueryWrapper<>();
|
||||
w.eq(StringUtils.hasText(batchStatus), CssdSterilizeBatch::getBatchStatus, batchStatus)
|
||||
.orderByDesc(CssdSterilizeBatch::getCreateTime);
|
||||
return R.ok(batchService.page(new Page<>(pageNo, pageSize), w));
|
||||
}
|
||||
@PostMapping("/sterilize/add") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> addBatch(@RequestBody CssdSterilizeBatch b) {
|
||||
b.setBatchStatus("RUNNING");
|
||||
b.setBiologicalResult("PENDING"); b.setChemicalResult("PENDING"); b.setPhysicalResult("PENDING");
|
||||
b.setStartTime(new Date()); b.setCreateTime(new Date());
|
||||
batchService.save(b); return R.ok(b);
|
||||
}
|
||||
@PutMapping("/sterilize/complete/{id}") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> completeBatch(@PathVariable Long id, @RequestBody Map<String, Object> params) {
|
||||
CssdSterilizeBatch b = batchService.getById(id); if (b == null) return R.fail("批次不存在");
|
||||
b.setEndTime(new Date());
|
||||
b.setBiologicalResult((String) params.getOrDefault("biologicalResult", "PASS"));
|
||||
b.setChemicalResult((String) params.getOrDefault("chemicalResult", "PASS"));
|
||||
b.setPhysicalResult((String) params.getOrDefault("physicalResult", "PASS"));
|
||||
b.setBatchStatus("COMPLETED");
|
||||
batchService.updateById(b); return R.ok(b);
|
||||
}
|
||||
@PutMapping("/sterilize/release/{id}") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> releaseBatch(@PathVariable Long id, @RequestParam("releaseBy") String releaseBy) {
|
||||
CssdSterilizeBatch b = batchService.getById(id); if (b == null) return R.fail("批次不存在");
|
||||
if (!"PASS".equals(b.getBiologicalResult()) || !"PASS".equals(b.getChemicalResult()) || !"PASS".equals(b.getPhysicalResult())) {
|
||||
return R.fail("三项监测未全部合格,禁止释放");
|
||||
}
|
||||
b.setBatchStatus("RELEASED"); b.setReleaseBy(releaseBy); b.setReleaseTime(new Date());
|
||||
batchService.updateById(b); return R.ok(b);
|
||||
}
|
||||
|
||||
// ==================== 过期预警 ====================
|
||||
@GetMapping("/expiry/alerts")
|
||||
public R<?> getExpiryAlerts() {
|
||||
LambdaQueryWrapper<CssdExpiryAlert> w = new LambdaQueryWrapper<>();
|
||||
w.in(CssdExpiryAlert::getStatus, "ALERT", "EXPIRED").orderByAsc(CssdExpiryAlert::getExpiryTime);
|
||||
return R.ok(expiryService.list(w));
|
||||
}
|
||||
|
||||
// ==================== 统计概览 ====================
|
||||
@GetMapping("/stats/overview")
|
||||
public R<?> getStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("totalTrays", trayService.count());
|
||||
LambdaQueryWrapper<CssdTray> w1 = new LambdaQueryWrapper<>();
|
||||
w1.eq(CssdTray::getStatus, "STORED");
|
||||
stats.put("storedTrays", trayService.count(w1));
|
||||
LambdaQueryWrapper<CssdExpiryAlert> w2 = new LambdaQueryWrapper<>();
|
||||
w2.eq(CssdExpiryAlert::getStatus, "ALERT");
|
||||
stats.put("alertCount", expiryService.count(w2));
|
||||
w2.eq(CssdExpiryAlert::getStatus, "EXPIRED");
|
||||
stats.put("expiredCount", expiryService.count(w2));
|
||||
stats.put("totalBatches", batchService.count());
|
||||
LambdaQueryWrapper<CssdSterilizeBatch> w3 = new LambdaQueryWrapper<>();
|
||||
w3.eq(CssdSterilizeBatch::getBatchStatus, "RELEASED");
|
||||
stats.put("releasedBatches", batchService.count(w3));
|
||||
return R.ok(stats);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.healthlink.his.web.reconstruction.controller;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.core.common.core.domain.R;
|
||||
import com.healthlink.his.reconstruction.domain.*;
|
||||
import com.healthlink.his.reconstruction.service.*;
|
||||
import lombok.AllArgsConstructor;import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
@RestController @RequestMapping("/reconstruction") @Slf4j @AllArgsConstructor
|
||||
public class Reconstruction3DController {
|
||||
private final IReconstructionTaskService taskService;
|
||||
private final IReconstructionResultService resultService;
|
||||
private final IReconstructionReportService reportService;
|
||||
|
||||
// ==================== 重建任务 ====================
|
||||
@GetMapping("/task/page")
|
||||
public R<?> getTaskPage(@RequestParam(value="taskStatus",required=false) String taskStatus,
|
||||
@RequestParam(value="patientName",required=false) String patientName,
|
||||
@RequestParam(value="modality",required=false) String modality,
|
||||
@RequestParam(value="pageNo",defaultValue="1") Integer pageNo,
|
||||
@RequestParam(value="pageSize",defaultValue="20") Integer pageSize) {
|
||||
LambdaQueryWrapper<ReconstructionTask> w = new LambdaQueryWrapper<>();
|
||||
w.eq(StringUtils.hasText(taskStatus), ReconstructionTask::getTaskStatus, taskStatus)
|
||||
.like(StringUtils.hasText(patientName), ReconstructionTask::getPatientName, patientName)
|
||||
.eq(StringUtils.hasText(modality), ReconstructionTask::getModality, modality)
|
||||
.orderByDesc(ReconstructionTask::getCreateTime);
|
||||
return R.ok(taskService.page(new Page<>(pageNo, pageSize), w));
|
||||
}
|
||||
@PostMapping("/task/add") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> addTask(@RequestBody ReconstructionTask t) {
|
||||
t.setTaskStatus("PENDING"); t.setCreateTime(new Date());
|
||||
taskService.save(t);
|
||||
// 模拟异步处理: 实际项目中使用@Async或消息队列
|
||||
t.setTaskStatus("COMPLETED"); t.setCompleteTime(new Date());
|
||||
taskService.updateById(t);
|
||||
return R.ok(t);
|
||||
}
|
||||
@GetMapping("/task/{id}")
|
||||
public R<?> getTask(@PathVariable Long id) { return R.ok(taskService.getById(id)); }
|
||||
@PutMapping("/task/cancel/{id}") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> cancelTask(@PathVariable Long id) {
|
||||
ReconstructionTask t = taskService.getById(id); if (t == null) return R.fail("任务不存在");
|
||||
t.setTaskStatus("CANCELLED"); taskService.updateById(t); return R.ok();
|
||||
}
|
||||
|
||||
// ==================== 重建结果 ====================
|
||||
@GetMapping("/result/list/{taskId}")
|
||||
public R<?> getResults(@PathVariable Long taskId) {
|
||||
LambdaQueryWrapper<ReconstructionResult> w = new LambdaQueryWrapper<>();
|
||||
w.eq(ReconstructionResult::getTaskId, taskId);
|
||||
return R.ok(resultService.list(w));
|
||||
}
|
||||
@PostMapping("/result/add") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> addResult(@RequestBody ReconstructionResult r) { r.setCreateTime(new Date()); resultService.save(r); return R.ok(r); }
|
||||
|
||||
// ==================== 重建报告 ====================
|
||||
@GetMapping("/report/page")
|
||||
public R<?> getReportPage(@RequestParam(value="status",required=false) String status,
|
||||
@RequestParam(value="patientName",required=false) String patientName,
|
||||
@RequestParam(value="pageNo",defaultValue="1") Integer pageNo,
|
||||
@RequestParam(value="pageSize",defaultValue="20") Integer pageSize) {
|
||||
LambdaQueryWrapper<ReconstructionReport> w = new LambdaQueryWrapper<>();
|
||||
w.eq(StringUtils.hasText(status), ReconstructionReport::getStatus, status)
|
||||
.orderByDesc(ReconstructionReport::getCreateTime);
|
||||
return R.ok(reportService.page(new Page<>(pageNo, pageSize), w));
|
||||
}
|
||||
@PostMapping("/report/add") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> addReport(@RequestBody ReconstructionReport r) { r.setStatus("DRAFT"); r.setCreateTime(new Date()); reportService.save(r); return R.ok(r); }
|
||||
@PutMapping("/report/submit/{id}") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> submitReport(@PathVariable Long id) {
|
||||
ReconstructionReport r = reportService.getById(id); if (r == null) return R.fail("报告不存在");
|
||||
r.setStatus("REPORTED"); r.setReportTime(new Date()); reportService.updateById(r); return R.ok();
|
||||
}
|
||||
@PutMapping("/report/verify/{id}") @Transactional(rollbackFor=Exception.class)
|
||||
public R<?> verifyReport(@PathVariable Long id, @RequestParam("doctor") String doctor) {
|
||||
ReconstructionReport r = reportService.getById(id); if (r == null) return R.fail("报告不存在");
|
||||
r.setStatus("VERIFIED"); r.setVerifyDoctor(doctor); r.setVerifyTime(new Date()); reportService.updateById(r); return R.ok();
|
||||
}
|
||||
|
||||
// ==================== 统计 ====================
|
||||
@GetMapping("/stats")
|
||||
public R<?> getStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("totalTasks", taskService.count());
|
||||
LambdaQueryWrapper<ReconstructionTask> w1 = new LambdaQueryWrapper<>();
|
||||
w1.eq(ReconstructionTask::getTaskStatus, "COMPLETED");
|
||||
stats.put("completedTasks", taskService.count(w1));
|
||||
w1.eq(ReconstructionTask::getTaskStatus, "PROCESSING");
|
||||
stats.put("processingTasks", taskService.count(w1));
|
||||
// 模态分布
|
||||
String[] modalities = {"CT", "MRI", "PET", "US"};
|
||||
for (String mod : modalities) {
|
||||
LambdaQueryWrapper<ReconstructionTask> w2 = new LambdaQueryWrapper<>();
|
||||
w2.eq(ReconstructionTask::getModality, mod);
|
||||
long count = taskService.count(w2);
|
||||
if (count > 0) stats.put("modality_" + mod, count);
|
||||
}
|
||||
stats.put("totalReports", reportService.count());
|
||||
return R.ok(stats);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
-- V31: CSSD消毒供应追溯 + 影像3D重建
|
||||
|
||||
-- 1. CSSD器械包
|
||||
CREATE TABLE IF NOT EXISTS cssd_tray (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tray_code VARCHAR(50) NOT NULL,
|
||||
tray_name VARCHAR(200) NOT NULL,
|
||||
tray_type VARCHAR(30) NOT NULL,
|
||||
department_source VARCHAR(100),
|
||||
status VARCHAR(20) DEFAULT 'IN_USE',
|
||||
current_location VARCHAR(100),
|
||||
total_uses INT DEFAULT 0,
|
||||
sterilize_count INT DEFAULT 0,
|
||||
last_sterilize_time TIMESTAMP,
|
||||
max_uses INT DEFAULT 100,
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
is_deleted INT NOT NULL DEFAULT 0,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE cssd_tray IS 'CSSD器械包';
|
||||
COMMENT ON COLUMN cssd_tray.tray_type IS '类型(OPERATION手术/TUBE管腔/PRECISION精密/COMMON普通)';
|
||||
COMMENT ON COLUMN cssd_tray.status IS '状态(IN_USE在用/WASHING清洗中/STERILIZING灭菌中/STORED储存中/DISTRIBUTED已发放)';
|
||||
CREATE UNIQUE INDEX idx_ct_code ON cssd_tray(tray_code);
|
||||
|
||||
-- 2. CSSD追溯记录
|
||||
CREATE TABLE IF NOT EXISTS cssd_trace_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tray_id BIGINT NOT NULL,
|
||||
tray_code VARCHAR(50),
|
||||
step_type VARCHAR(20) NOT NULL,
|
||||
operator_id BIGINT,
|
||||
operator_name VARCHAR(64),
|
||||
operation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
device_name VARCHAR(100),
|
||||
device_code VARCHAR(50),
|
||||
parameters TEXT,
|
||||
result VARCHAR(20) DEFAULT 'PASS',
|
||||
remark TEXT,
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE cssd_trace_record IS 'CSSD追溯记录';
|
||||
COMMENT ON COLUMN cssd_trace_record.step_type IS '步骤(RECYCLE回收/WASH清洗/DISINFECT消毒/PACK包装/STERILIZE灭菌/STORE储存/DISTRIBUTE发放)';
|
||||
CREATE INDEX idx_ctr_tray ON cssd_trace_record(tray_id);
|
||||
CREATE INDEX idx_ctr_time ON cssd_trace_record(operation_time);
|
||||
|
||||
-- 3. CSSD灭菌批次
|
||||
CREATE TABLE IF NOT EXISTS cssd_sterilize_batch (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
batch_code VARCHAR(50) NOT NULL,
|
||||
sterilizer_name VARCHAR(100),
|
||||
sterilizer_code VARCHAR(50),
|
||||
start_time TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
cycle_type VARCHAR(30),
|
||||
temperature DECIMAL(5,1),
|
||||
pressure DECIMAL(6,2),
|
||||
exposure_time INT,
|
||||
biological_result VARCHAR(20) DEFAULT 'PENDING',
|
||||
chemical_result VARCHAR(20) DEFAULT 'PENDING',
|
||||
physical_result VARCHAR(20) DEFAULT 'PENDING',
|
||||
batch_status VARCHAR(20) DEFAULT 'RUNNING',
|
||||
release_by VARCHAR(64),
|
||||
release_time TIMESTAMP,
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
is_deleted INT NOT NULL DEFAULT 0,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE cssd_sterilize_batch IS 'CSSD灭菌批次';
|
||||
COMMENT ON COLUMN cssd_sterilize_batch.batch_status IS '状态(RUNNING进行中/COMPLETED已完成/RELEASED已释放/REJECTED已拒绝)';
|
||||
CREATE UNIQUE INDEX idx_csb_code ON cssd_sterilize_batch(batch_code);
|
||||
|
||||
-- 4. CSSD灭菌包明细
|
||||
CREATE TABLE IF NOT EXISTS cssd_sterilize_item (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
batch_id BIGINT NOT NULL,
|
||||
tray_id BIGINT NOT NULL,
|
||||
tray_code VARCHAR(50),
|
||||
chemical_indicator VARCHAR(20),
|
||||
bi_indicator VARCHAR(20),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE cssd_sterilize_item IS '灭菌包明细';
|
||||
CREATE INDEX idx_csi_batch ON cssd_sterilize_item(batch_id);
|
||||
|
||||
-- 5. CSSD过期预警
|
||||
CREATE TABLE IF NOT EXISTS cssd_expiry_alert (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tray_id BIGINT NOT NULL,
|
||||
batch_id BIGINT NOT NULL,
|
||||
sterilize_time TIMESTAMP,
|
||||
expiry_time TIMESTAMP NOT NULL,
|
||||
alert_time TIMESTAMP,
|
||||
status VARCHAR(20) DEFAULT 'NORMAL',
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE cssd_expiry_alert IS 'CSSD过期预警';
|
||||
COMMENT ON COLUMN cssd_expiry_alert.status IS '状态(NORMAL正常/ALERT预警/EXPIRED过期)';
|
||||
CREATE INDEX idx_cea_status ON cssd_expiry_alert(status);
|
||||
|
||||
-- 6. 影像3D重建任务
|
||||
CREATE TABLE IF NOT EXISTS reconstruction_task (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
patient_id BIGINT NOT NULL,
|
||||
patient_name VARCHAR(50),
|
||||
encounter_id BIGINT,
|
||||
apply_id BIGINT,
|
||||
study_uid VARCHAR(100),
|
||||
modality VARCHAR(10),
|
||||
body_part VARCHAR(100),
|
||||
scan_range VARCHAR(200),
|
||||
task_status VARCHAR(20) DEFAULT 'PENDING',
|
||||
reconstruction_type VARCHAR(30),
|
||||
result_path VARCHAR(500),
|
||||
slice_thickness DECIMAL(6,2),
|
||||
pixel_spacing VARCHAR(50),
|
||||
request_doctor VARCHAR(64),
|
||||
complete_time TIMESTAMP,
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
is_deleted INT NOT NULL DEFAULT 0,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE reconstruction_task IS '影像3D重建任务';
|
||||
COMMENT ON COLUMN reconstruction_task.reconstruction_type IS '重建类型(VR容积渲染/MPR多平面/MIP最大密度投影)';
|
||||
CREATE INDEX idx_rt_status ON reconstruction_task(task_status);
|
||||
|
||||
-- 7. 影像3D重建结果
|
||||
CREATE TABLE IF NOT EXISTS reconstruction_result (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL,
|
||||
result_type VARCHAR(20) NOT NULL,
|
||||
image_path VARCHAR(500),
|
||||
volume_data_path VARCHAR(500),
|
||||
measurements TEXT,
|
||||
annotations TEXT,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE reconstruction_result IS '3D重建结果';
|
||||
CREATE INDEX idx_rr_task ON reconstruction_result(task_id);
|
||||
|
||||
-- 8. 影像3D重建报告
|
||||
CREATE TABLE IF NOT EXISTS reconstruction_report (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL,
|
||||
patient_id BIGINT,
|
||||
encounter_id BIGINT,
|
||||
findings TEXT,
|
||||
impression TEXT,
|
||||
conclusion TEXT,
|
||||
report_doctor VARCHAR(64),
|
||||
report_time TIMESTAMP,
|
||||
verify_doctor VARCHAR(64),
|
||||
verify_time TIMESTAMP,
|
||||
status VARCHAR(20) DEFAULT 'DRAFT',
|
||||
tenant_id BIGINT DEFAULT 0,
|
||||
is_deleted INT NOT NULL DEFAULT 0,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
COMMENT ON TABLE reconstruction_report IS '3D重建报告';
|
||||
CREATE INDEX idx_rrp_task ON reconstruction_report(task_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.healthlink.his.cssd.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;import java.util.Date;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("cssd_expiry_alert")
|
||||
public class CssdExpiryAlert extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private Long trayId; private Long batchId; private Date sterilizeTime;
|
||||
private Date expiryTime; private Date alertTime; private String status;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.healthlink.his.cssd.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;import java.math.BigDecimal;import java.util.Date;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("cssd_sterilize_batch")
|
||||
public class CssdSterilizeBatch extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private String batchCode; private String sterilizerName; private String sterilizerCode;
|
||||
private Date startTime; private Date endTime; private String cycleType;
|
||||
private BigDecimal temperature; private BigDecimal pressure; private Integer exposureTime;
|
||||
private String biologicalResult; private String chemicalResult; private String physicalResult;
|
||||
private String batchStatus; private String releaseBy; private Date releaseTime;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.healthlink.his.cssd.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("cssd_sterilize_item")
|
||||
public class CssdSterilizeItem extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private Long batchId; private Long trayId; private String trayCode;
|
||||
private String chemicalIndicator; private String biIndicator;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.healthlink.his.cssd.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;import java.util.Date;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("cssd_trace_record")
|
||||
public class CssdTraceRecord extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private Long trayId; private String trayCode; private String stepType;
|
||||
private Long operatorId; private String operatorName; private Date operationTime;
|
||||
private String deviceName; private String deviceCode; private String parameters;
|
||||
private String result; private String remark;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.healthlink.his.cssd.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;import java.util.Date;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("cssd_tray")
|
||||
public class CssdTray extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private String trayCode; private String trayName; private String trayType;
|
||||
private String departmentSource; private String status; private String currentLocation;
|
||||
private Integer totalUses; private Integer sterilizeCount; private Date lastSterilizeTime; private Integer maxUses;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.cssd.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.cssd.domain.CssdExpiryAlert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface CssdExpiryAlertMapper extends BaseMapper<CssdExpiryAlert> {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.cssd.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.cssd.domain.CssdSterilizeBatch;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface CssdSterilizeBatchMapper extends BaseMapper<CssdSterilizeBatch> {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.cssd.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.cssd.domain.CssdSterilizeItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface CssdSterilizeItemMapper extends BaseMapper<CssdSterilizeItem> {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.cssd.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.cssd.domain.CssdTraceRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface CssdTraceRecordMapper extends BaseMapper<CssdTraceRecord> {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.cssd.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.cssd.domain.CssdTray;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface CssdTrayMapper extends BaseMapper<CssdTray> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.cssd.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.cssd.domain.CssdExpiryAlert;
|
||||
public interface ICssdExpiryAlertService extends IService<CssdExpiryAlert> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.cssd.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.cssd.domain.CssdSterilizeBatch;
|
||||
public interface ICssdSterilizeBatchService extends IService<CssdSterilizeBatch> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.cssd.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.cssd.domain.CssdSterilizeItem;
|
||||
public interface ICssdSterilizeItemService extends IService<CssdSterilizeItem> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.cssd.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.cssd.domain.CssdTraceRecord;
|
||||
public interface ICssdTraceRecordService extends IService<CssdTraceRecord> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.cssd.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.cssd.domain.CssdTray;
|
||||
public interface ICssdTrayService extends IService<CssdTray> {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.cssd.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.cssd.domain.CssdExpiryAlert;
|
||||
import com.healthlink.his.cssd.mapper.CssdExpiryAlertMapper;
|
||||
import com.healthlink.his.cssd.service.ICssdExpiryAlertService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class CssdExpiryAlertServiceImpl extends ServiceImpl<CssdExpiryAlertMapper, CssdExpiryAlert> implements ICssdExpiryAlertService {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.cssd.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.cssd.domain.CssdSterilizeBatch;
|
||||
import com.healthlink.his.cssd.mapper.CssdSterilizeBatchMapper;
|
||||
import com.healthlink.his.cssd.service.ICssdSterilizeBatchService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class CssdSterilizeBatchServiceImpl extends ServiceImpl<CssdSterilizeBatchMapper, CssdSterilizeBatch> implements ICssdSterilizeBatchService {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.cssd.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.cssd.domain.CssdSterilizeItem;
|
||||
import com.healthlink.his.cssd.mapper.CssdSterilizeItemMapper;
|
||||
import com.healthlink.his.cssd.service.ICssdSterilizeItemService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class CssdSterilizeItemServiceImpl extends ServiceImpl<CssdSterilizeItemMapper, CssdSterilizeItem> implements ICssdSterilizeItemService {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.cssd.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.cssd.domain.CssdTraceRecord;
|
||||
import com.healthlink.his.cssd.mapper.CssdTraceRecordMapper;
|
||||
import com.healthlink.his.cssd.service.ICssdTraceRecordService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class CssdTraceRecordServiceImpl extends ServiceImpl<CssdTraceRecordMapper, CssdTraceRecord> implements ICssdTraceRecordService {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.cssd.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.cssd.domain.CssdTray;
|
||||
import com.healthlink.his.cssd.mapper.CssdTrayMapper;
|
||||
import com.healthlink.his.cssd.service.ICssdTrayService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class CssdTrayServiceImpl extends ServiceImpl<CssdTrayMapper, CssdTray> implements ICssdTrayService {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.healthlink.his.reconstruction.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;import java.util.Date;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("reconstruction_report")
|
||||
public class ReconstructionReport extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private Long taskId; private Long patientId; private Long encounterId;
|
||||
private String findings; private String impression; private String conclusion;
|
||||
private String reportDoctor; private Date reportTime;
|
||||
private String verifyDoctor; private Date verifyTime; private String status;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.healthlink.his.reconstruction.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("reconstruction_result")
|
||||
public class ReconstructionResult extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private Long taskId; private String resultType; private String imagePath;
|
||||
private String volumeDataPath; private String measurements; private String annotations;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.healthlink.his.reconstruction.domain;
|
||||
import com.baomidou.mybatisplus.annotation.*;import com.core.common.core.domain.HisBaseEntity;
|
||||
import lombok.Data;import lombok.EqualsAndHashCode;import java.math.BigDecimal;import java.util.Date;
|
||||
@Data @EqualsAndHashCode(callSuper=true) @TableName("reconstruction_task")
|
||||
public class ReconstructionTask extends HisBaseEntity {
|
||||
@TableId(value="id",type=IdType.ASSIGN_ID) private Long id;
|
||||
private Long patientId; private String patientName; private Long encounterId;
|
||||
private Long applyId; private String studyUid; private String modality;
|
||||
private String bodyPart; private String scanRange; private String taskStatus;
|
||||
private String reconstructionType; private String resultPath;
|
||||
private BigDecimal sliceThickness; private String pixelSpacing;
|
||||
private String requestDoctor; private Date completeTime;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.reconstruction.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionReport;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface ReconstructionReportMapper extends BaseMapper<ReconstructionReport> {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.reconstruction.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionResult;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface ReconstructionResultMapper extends BaseMapper<ReconstructionResult> {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.healthlink.his.reconstruction.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionTask;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper
|
||||
public interface ReconstructionTaskMapper extends BaseMapper<ReconstructionTask> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.reconstruction.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionReport;
|
||||
public interface IReconstructionReportService extends IService<ReconstructionReport> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.reconstruction.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionResult;
|
||||
public interface IReconstructionResultService extends IService<ReconstructionResult> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.healthlink.his.reconstruction.service;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionTask;
|
||||
public interface IReconstructionTaskService extends IService<ReconstructionTask> {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.reconstruction.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionReport;
|
||||
import com.healthlink.his.reconstruction.mapper.ReconstructionReportMapper;
|
||||
import com.healthlink.his.reconstruction.service.IReconstructionReportService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ReconstructionReportServiceImpl extends ServiceImpl<ReconstructionReportMapper, ReconstructionReport> implements IReconstructionReportService {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.reconstruction.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionResult;
|
||||
import com.healthlink.his.reconstruction.mapper.ReconstructionResultMapper;
|
||||
import com.healthlink.his.reconstruction.service.IReconstructionResultService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ReconstructionResultServiceImpl extends ServiceImpl<ReconstructionResultMapper, ReconstructionResult> implements IReconstructionResultService {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.healthlink.his.reconstruction.service.impl;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.healthlink.his.reconstruction.domain.ReconstructionTask;
|
||||
import com.healthlink.his.reconstruction.mapper.ReconstructionTaskMapper;
|
||||
import com.healthlink.his.reconstruction.service.IReconstructionTaskService;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ReconstructionTaskServiceImpl extends ServiceImpl<ReconstructionTaskMapper, ReconstructionTask> implements IReconstructionTaskService {}
|
||||
12
healthlink-his-ui/src/views/cssd/trace/api.js
Normal file
12
healthlink-his-ui/src/views/cssd/trace/api.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import request from '@/utils/request'
|
||||
export function getTrayPage(p){return request({url:'/cssd/tray/page',method:'get',params:p})}
|
||||
export function addTray(d){return request({url:'/cssd/tray/add',method:'post',data:d})}
|
||||
export function updateTray(d){return request({url:'/cssd/tray/update',method:'put',data:d})}
|
||||
export function scanTrace(d){return request({url:'/cssd/trace/scan',method:'post',data:d})}
|
||||
export function getTraceHistory(trayId){return request({url:'/cssd/trace/history/'+trayId,method:'get'})}
|
||||
export function getBatchPage(p){return request({url:'/cssd/sterilize/page',method:'get',params:p})}
|
||||
export function addBatch(d){return request({url:'/cssd/sterilize/add',method:'post',data:d})}
|
||||
export function completeBatch(id,d){return request({url:'/cssd/sterilize/complete/'+id,method:'put',data:d})}
|
||||
export function releaseBatch(id,by){return request({url:'/cssd/sterilize/release/'+id,method:'put',params:{releaseBy:by}})}
|
||||
export function getExpiryAlerts(){return request({url:'/cssd/expiry/alerts',method:'get'})}
|
||||
export function getStats(){return request({url:'/cssd/stats/overview',method:'get'})}
|
||||
166
healthlink-his-ui/src/views/cssd/trace/index.vue
Normal file
166
healthlink-his-ui/src/views/cssd/trace/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div style="padding:16px">
|
||||
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:18px;font-weight:bold">消毒供应中心(CSSD)追溯管理</span>
|
||||
<el-button type="primary" @click="loadStats">刷新统计</el-button>
|
||||
</div>
|
||||
<el-row :gutter="12" style="margin-bottom:16px">
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#409eff">{{ stats.totalTrays||0 }}</div><div style="font-size:12px;color:#999">器械包总数</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#67c23a">{{ stats.storedTrays||0 }}</div><div style="font-size:12px;color:#999">库存中</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#e6a23c">{{ stats.alertCount||0 }}</div><div style="font-size:12px;color:#999">预警中</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#f56c6c">{{ stats.expiredCount||0 }}</div><div style="font-size:12px;color:#999">已过期</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#909399">{{ stats.totalBatches||0 }}</div><div style="font-size:12px;color:#999">灭菌批次</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#67c23a">{{ stats.releasedBatches||0 }}</div><div style="font-size:12px;color:#999">已释放</div></div></el-card></el-col>
|
||||
</el-row>
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<el-tab-pane label="器械包管理" name="tray">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="trayQ.trayCode" placeholder="器械编码" clearable style="width:140px"/>
|
||||
<el-select v-model="trayQ.status" placeholder="状态" clearable style="width:120px">
|
||||
<el-option label="在用" value="IN_USE"/><el-option label="清洗中" value="WASHING"/>
|
||||
<el-option label="灭菌中" value="STERILIZING"/><el-option label="储存中" value="STORED"/>
|
||||
<el-option label="已发放" value="DISTRIBUTED"/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadTrays">查询</el-button>
|
||||
<el-button type="success" @click="trayDialog=true">新增器械包</el-button>
|
||||
</div>
|
||||
<el-table :data="trayData" border stripe>
|
||||
<el-table-column prop="trayCode" label="编码" width="120"/>
|
||||
<el-table-column prop="trayName" label="名称" width="150"/>
|
||||
<el-table-column prop="trayType" label="类型" width="80"/>
|
||||
<el-table-column prop="departmentSource" label="来源科室" width="100"/>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.status==='IN_USE'" type="info" size="small">在用</el-tag>
|
||||
<el-tag v-else-if="row.status==='WASHING'" type="warning" size="small">清洗中</el-tag>
|
||||
<el-tag v-else-if="row.status==='STORED'" type="success" size="small">储存中</el-tag>
|
||||
<el-tag v-else-if="row.status==='DISTRIBUTED'" size="small">已发放</el-tag>
|
||||
<el-tag v-else size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalUses" label="使用次数" width="80" align="center"/>
|
||||
<el-table-column prop="sterilizeCount" label="灭菌次数" width="80" align="center"/>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{row}">
|
||||
<el-button type="primary" link size="small" @click="viewTrace(row)">追溯</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="扫码追溯" name="scan">
|
||||
<el-form :inline="true" style="margin-bottom:12px">
|
||||
<el-form-item label="器械编码"><el-input v-model="scanForm.trayCode" placeholder="扫码或输入编码" style="width:200px"/></el-form-item>
|
||||
<el-form-item label="操作步骤">
|
||||
<el-select v-model="scanForm.stepType" style="width:140px">
|
||||
<el-option label="回收" value="RECYCLE"/><el-option label="清洗" value="WASH"/>
|
||||
<el-option label="消毒" value="DISINFECT"/><el-option label="包装" value="PACK"/>
|
||||
<el-option label="灭菌" value="STERILIZE"/><el-option label="储存" value="STORE"/>
|
||||
<el-option label="发放" value="DISTRIBUTE"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作人"><el-input v-model="scanForm.operatorName" style="width:120px"/></el-form-item>
|
||||
<el-form-item label="设备"><el-input v-model="scanForm.deviceName" style="width:120px"/></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="doScan">扫码追溯</el-button></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="scanResult" style="background:#f0f9eb;padding:12px;border-radius:4px;margin-top:8px">
|
||||
<strong>追溯成功!</strong> 器械包: {{ scanResult.tray?.trayName }} | 当前状态: {{ scanResult.tray?.status }} | 下一步: {{ scanResult.nextStep }}
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="灭菌批次" name="batch">
|
||||
<el-table :data="batchData" border stripe>
|
||||
<el-table-column prop="batchCode" label="批次号" width="140"/>
|
||||
<el-table-column prop="sterilizerName" label="灭菌器" width="120"/>
|
||||
<el-table-column prop="temperature" label="温度" width="70" align="center"/>
|
||||
<el-table-column prop="biologicalResult" label="生物监测" width="90" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.biologicalResult==='PASS'?'success':row.biologicalResult==='FAIL'?'danger':'info'" size="small">
|
||||
{{ row.biologicalResult==='PASS'?'合格':row.biologicalResult==='FAIL'?'不合格':'待检' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="chemicalResult" label="化学监测" width="90" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.chemicalResult==='PASS'?'success':row.chemicalResult==='FAIL'?'danger':'info'" size="small">
|
||||
{{ row.chemicalResult==='PASS'?'合格':row.chemicalResult==='FAIL'?'不合格':'待检' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="physicalResult" label="物理监测" width="90" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.physicalResult==='PASS'?'success':row.physicalResult==='FAIL'?'danger':'info'" size="small">
|
||||
{{ row.physicalResult==='PASS'?'合格':row.physicalResult==='FAIL'?'不合格':'待检' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batchStatus" label="状态" width="90">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.batchStatus==='RUNNING'" type="warning" size="small">进行中</el-tag>
|
||||
<el-tag v-else-if="row.batchStatus==='COMPLETED'" type="primary" size="small">已完成</el-tag>
|
||||
<el-tag v-else-if="row.batchStatus==='RELEASED'" type="success" size="small">已释放</el-tag>
|
||||
<el-tag v-else size="small">{{ row.batchStatus }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.batchStatus==='RUNNING'" type="primary" link size="small" @click="doCompleteBatch(row.id)">完成</el-button>
|
||||
<el-button v-if="row.batchStatus==='COMPLETED'" type="success" link size="small" @click="doRelease(row.id)">释放</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="过期预警" name="expiry">
|
||||
<el-table :data="expiryData" border stripe>
|
||||
<el-table-column prop="trayId" label="器械包ID" width="100"/>
|
||||
<el-table-column prop="sterilizeTime" label="灭菌时间" width="170"/>
|
||||
<el-table-column prop="expiryTime" label="过期时间" width="170"/>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.status==='ALERT'" type="warning" size="small">预警</el-tag>
|
||||
<el-tag v-else-if="row.status==='EXPIRED'" type="danger" size="small">已过期</el-tag>
|
||||
<el-tag v-else type="success" size="small">正常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-dialog v-model="trayDialog" title="新增器械包" width="500px">
|
||||
<el-form :model="trayForm" label-width="80px">
|
||||
<el-form-item label="编码"><el-input v-model="trayForm.trayCode"/></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="trayForm.trayName"/></el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="trayForm.trayType"><el-option label="手术器械" value="OPERATION"/><el-option label="管腔器械" value="TUBE"/><el-option label="精密器械" value="PRECISION"/><el-option label="普通器械" value="COMMON"/></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="来源科室"><el-input v-model="trayForm.departmentSource"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="trayDialog=false">取消</el-button><el-button type="primary" @click="doAddTray">保存</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="traceDialog" title="追溯历史" width="800px">
|
||||
<el-timeline>
|
||||
<el-timeline-item v-for="item in traceHistory" :key="item.id" :timestamp="item.operationTime" placement="top">
|
||||
<strong>{{ item.stepType }}</strong> — 操作人: {{ item.operatorName }} | 设备: {{ item.deviceName || '-' }} | 结果: {{ item.result }}
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {ref,onMounted} from 'vue';import {ElMessage} from 'element-plus';
|
||||
import {getTrayPage,addTray,scanTrace,getTraceHistory,getBatchPage,completeBatch,releaseBatch,getExpiryAlerts,getStats} from './api'
|
||||
const activeTab=ref('tray')
|
||||
const trayData=ref([]);const batchData=ref([]);const expiryData=ref([]);const stats=ref({})
|
||||
const trayQ=ref({pageNo:1,pageSize:20,trayCode:'',status:''})
|
||||
const trayDialog=ref(false);const trayForm=ref({trayCode:'',trayName:'',trayType:'COMMON',departmentSource:''})
|
||||
const scanForm=ref({trayCode:'',stepType:'RECYCLE',operatorName:'',deviceName:''})
|
||||
const scanResult=ref(null)
|
||||
const traceDialog=ref(false);const traceHistory=ref([])
|
||||
const loadTrays=async()=>{const r=await getTrayPage(trayQ.value);trayData.value=r.data?.records||[]}
|
||||
const loadBatch=async()=>{const r=await getBatchPage({pageNo:1,pageSize:20});batchData.value=r.data?.records||[]}
|
||||
const loadExpiry=async()=>{const r=await getExpiryAlerts();expiryData.value=r.data||[]}
|
||||
const loadStats=async()=>{const r=await getStats();stats.value=r.data||{}}
|
||||
const doAddTray=async()=>{await addTray(trayForm.value);ElMessage.success('新增成功');trayDialog.value=false;loadTrays()}
|
||||
const doScan=async()=>{const r=await scanTrace(scanForm.value);scanResult.value=r.data;ElMessage.success('追溯成功');loadTrays();loadBatch()}
|
||||
const viewTrace=async(row)=>{const r=await getTraceHistory(row.id);traceHistory.value=r.data||[];traceDialog.value=true}
|
||||
const doCompleteBatch=async(id)=>{await completeBatch(id,{biologicalResult:'PASS',chemicalResult:'PASS',physicalResult:'PASS'});ElMessage.success('批次完成');loadBatch()}
|
||||
const doRelease=async(id)=>{await releaseBatch(id,'系统管理员');ElMessage.success('批次已释放');loadBatch()}
|
||||
onMounted(()=>{loadTrays();loadBatch();loadExpiry();loadStats()})
|
||||
</script>
|
||||
12
healthlink-his-ui/src/views/reconstruction/3d/api.js
Normal file
12
healthlink-his-ui/src/views/reconstruction/3d/api.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import request from '@/utils/request'
|
||||
export function getTaskPage(p){return request({url:'/reconstruction/task/page',method:'get',params:p})}
|
||||
export function addTask(d){return request({url:'/reconstruction/task/add',method:'post',data:d})}
|
||||
export function getTask(id){return request({url:'/reconstruction/task/'+id,method:'get'})}
|
||||
export function cancelTask(id){return request({url:'/reconstruction/task/cancel/'+id,method:'put'})}
|
||||
export function getResults(taskId){return request({url:'/reconstruction/result/list/'+taskId,method:'get'})}
|
||||
export function addResult(d){return request({url:'/reconstruction/result/add',method:'post',data:d})}
|
||||
export function getReportPage(p){return request({url:'/reconstruction/report/page',method:'get',params:p})}
|
||||
export function addReport(d){return request({url:'/reconstruction/report/add',method:'post',data:d})}
|
||||
export function submitReport(id){return request({url:'/reconstruction/report/submit/'+id,method:'put'})}
|
||||
export function verifyReport(id,doctor){return request({url:'/reconstruction/report/verify/'+id,method:'put',params:{doctor}})}
|
||||
export function getStats(){return request({url:'/reconstruction/stats',method:'get'})}
|
||||
141
healthlink-his-ui/src/views/reconstruction/3d/index.vue
Normal file
141
healthlink-his-ui/src/views/reconstruction/3d/index.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div style="padding:16px">
|
||||
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:18px;font-weight:bold">影像3D重建</span>
|
||||
<el-button type="primary" @click="loadStats">刷新统计</el-button>
|
||||
</div>
|
||||
<el-row :gutter="12" style="margin-bottom:16px">
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#409eff">{{ stats.totalTasks||0 }}</div><div style="font-size:12px;color:#999">总任务</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#67c23a">{{ stats.completedTasks||0 }}</div><div style="font-size:12px;color:#999">已完成</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#e6a23c">{{ stats.processingTasks||0 }}</div><div style="font-size:12px;color:#999">处理中</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#909399">{{ stats.totalReports||0 }}</div><div style="font-size:12px;color:#999">报告数</div></div></el-card></el-col>
|
||||
</el-row>
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<el-tab-pane label="重建任务" name="task">
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-select v-model="taskQ.taskStatus" placeholder="状态" clearable style="width:120px">
|
||||
<el-option label="待处理" value="PENDING"/><el-option label="处理中" value="PROCESSING"/>
|
||||
<el-option label="已完成" value="COMPLETED"/>
|
||||
</el-select>
|
||||
<el-select v-model="taskQ.modality" placeholder="模态" clearable style="width:100px">
|
||||
<el-option label="CT" value="CT"/><el-option label="MRI" value="MRI"/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadTasks">查询</el-button>
|
||||
<el-button type="success" @click="newTaskDialog=true">新建任务</el-button>
|
||||
</div>
|
||||
<el-table :data="taskData" border stripe>
|
||||
<el-table-column prop="patientName" label="患者" width="100"/>
|
||||
<el-table-column prop="modality" label="模态" width="60" align="center"/>
|
||||
<el-table-column prop="bodyPart" label="部位" width="100"/>
|
||||
<el-table-column prop="reconstructionType" label="重建类型" width="100"/>
|
||||
<el-table-column prop="taskStatus" label="状态" width="90">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.taskStatus==='PENDING'" type="info" size="small">待处理</el-tag>
|
||||
<el-tag v-else-if="row.taskStatus==='PROCESSING'" type="warning" size="small">处理中</el-tag>
|
||||
<el-tag v-else-if="row.taskStatus==='COMPLETED'" type="success" size="small">已完成</el-tag>
|
||||
<el-tag v-else size="small">{{ row.taskStatus }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="requestDoctor" label="申请医生" width="100"/>
|
||||
<el-table-column prop="completeTime" label="完成时间" width="170"/>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.taskStatus==='COMPLETED'" type="primary" link size="small" @click="viewResults(row.id)">查看结果</el-button>
|
||||
<el-button v-if="row.taskStatus==='PENDING'" type="danger" link size="small" @click="doCancelTask(row.id)">取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="3D查看器" name="viewer">
|
||||
<div style="text-align:center;padding:40px;background:#1a1a2e;border-radius:8px;color:#fff">
|
||||
<div style="font-size:24px;margin-bottom:16px">3D影像查看器</div>
|
||||
<div style="color:#999;margin-bottom:24px">
|
||||
基于 Cornerstone.js + VTK.js 的医学影像3D重建查看器<br>
|
||||
支持: 容积渲染(VR) / 多平面重建(MPR) / 最大密度投影(MIP)
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:center">
|
||||
<el-button type="primary" @click="viewerMode='VR'">容积渲染(VR)</el-button>
|
||||
<el-button type="success" @click="viewerMode='MPR'">多平面(MPR)</el-button>
|
||||
<el-button type="warning" @click="viewerMode='MIP'">最大密度(MIP)</el-button>
|
||||
</div>
|
||||
<div style="margin-top:16px;color:#666;font-size:12px">
|
||||
当前模式: {{ viewerMode }} | 需要Cornerstone.js + VTK.js依赖
|
||||
</div>
|
||||
<div style="margin-top:24px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
|
||||
<el-button @click="renderPreset='bone'" :type="renderPreset==='bone'?'primary':''">骨骼预设</el-button>
|
||||
<el-button @click="renderPreset='soft'" :type="renderPreset==='soft'?'primary':''">软组织预设</el-button>
|
||||
<el-button @click="renderPreset='lung'" :type="renderPreset==='lung'?'primary':''">肺部预设</el-button>
|
||||
<el-button @click="renderPreset='angio'" :type="renderPreset==='angio'?'primary':''">血管预设</el-button>
|
||||
<el-button @click="renderPreset='skin'" :type="renderPreset==='skin'?'primary':''">皮肤预设</el-button>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:center">
|
||||
<el-button @click="measureTool='length'" :type="measureTool==='length'?'primary':''">📏 距离</el-button>
|
||||
<el-button @click="measureTool='angle'" :type="measureTool==='angle'?'primary':''">📐 角度</el-button>
|
||||
<el-button @click="measureTool='area'" :type="measureTool==='area'?'primary':''">⬜ 面积</el-button>
|
||||
<el-button @click="measureTool='ct'" :type="measureTool==='ct'?'primary':''">🔍 CT值</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="重建报告" name="report">
|
||||
<el-table :data="reportData" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="60"/>
|
||||
<el-table-column prop="findings" label="所见" min-width="200" show-overflow-tooltip/>
|
||||
<el-table-column prop="impression" label="印象" min-width="200" show-overflow-tooltip/>
|
||||
<el-table-column prop="reportDoctor" label="报告人" width="100"/>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.status==='DRAFT'" type="info" size="small">草稿</el-tag>
|
||||
<el-tag v-else-if="row.status==='REPORTED'" type="warning" size="small">已报告</el-tag>
|
||||
<el-tag v-else type="success" size="small">已审核</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.status==='DRAFT'" type="primary" link size="small" @click="doSubmitReport(row.id)">提交</el-button>
|
||||
<el-button v-if="row.status==='REPORTED'" type="success" link size="small" @click="doVerifyReport(row.id)">审核</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-dialog v-model="newTaskDialog" title="新建3D重建任务" width="500px">
|
||||
<el-form :model="taskForm" label-width="80px">
|
||||
<el-form-item label="患者ID"><el-input v-model.number="taskForm.patientId"/></el-form-item>
|
||||
<el-form-item label="患者姓名"><el-input v-model="taskForm.patientName"/></el-form-item>
|
||||
<el-form-item label="就诊ID"><el-input v-model.number="taskForm.encounterId"/></el-form-item>
|
||||
<el-form-item label="检查UID"><el-input v-model="taskForm.studyUid"/></el-form-item>
|
||||
<el-form-item label="模态">
|
||||
<el-select v-model="taskForm.modality"><el-option label="CT" value="CT"/><el-option label="MRI" value="MRI"/></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="检查部位"><el-input v-model="taskForm.bodyPart"/></el-form-item>
|
||||
<el-form-item label="重建类型">
|
||||
<el-select v-model="taskForm.reconstructionType">
|
||||
<el-option label="容积渲染(VR)" value="VR"/><el-option label="多平面(MPR)" value="MPR"/>
|
||||
<el-option label="最大密度投影(MIP)" value="MIP"/><el-option label="VR+MPR" value="VR+MPR"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请医生"><el-input v-model="taskForm.requestDoctor"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="newTaskDialog=false">取消</el-button><el-button type="primary" @click="doAddTask">创建任务</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {ref,onMounted} from 'vue';import {ElMessage,ElMessageBox} from 'element-plus';
|
||||
import {getTaskPage,addTask,cancelTask,getResults,getReportPage,addReport,submitReport,verifyReport,getStats} from './api'
|
||||
const activeTab=ref('task')
|
||||
const taskData=ref([]);const reportData=ref([]);const stats=ref({})
|
||||
const taskQ=ref({pageNo:1,pageSize:20,taskStatus:'',modality:''})
|
||||
const newTaskDialog=ref(false)
|
||||
const taskForm=ref({patientId:null,patientName:'',encounterId:null,studyUid:'',modality:'CT',bodyPart:'',reconstructionType:'VR',requestDoctor:''})
|
||||
const viewerMode=ref('VR');const renderPreset=ref('bone');const measureTool=ref('')
|
||||
const loadTasks=async()=>{const r=await getTaskPage(taskQ.value);taskData.value=r.data?.records||[]}
|
||||
const loadReports=async()=>{const r=await getReportPage({pageNo:1,pageSize:20});reportData.value=r.data?.records||[]}
|
||||
const loadStats=async()=>{const r=await getStats();stats.value=r.data||{}}
|
||||
const doAddTask=async()=>{await addTask(taskForm.value);ElMessage.success('任务已创建');newTaskDialog.value=false;loadTasks();loadStats()}
|
||||
const doCancelTask=async(id)=>{await cancelTask(id);ElMessage.success('已取消');loadTasks()}
|
||||
const viewResults=async(taskId)=>{activeTab.value='viewer';ElMessage.info('请在3D查看器中查看结果(需要集成Cornerstone.js)')}
|
||||
const doSubmitReport=async(id)=>{await submitReport(id);ElMessage.success('已提交');loadReports()}
|
||||
const doVerifyReport=async(id)=>{const {value}=await ElMessageBox.prompt('审核医生','审核报告');if(value){await verifyReport(id,value);ElMessage.success('已审核');loadReports()}}
|
||||
onMounted(()=>{loadTasks();loadReports();loadStats()})
|
||||
</script>
|
||||
Reference in New Issue
Block a user