前言
"基于cornerstone3D的dicom影像浏览器"系列文章中都是加载本地文件夹的的dicom图像。
作为一个合格的dicom影像浏览器需要对接PACS服务端,从PACS服务查询检查,下载图像。
本章实现一个查询界面,对接PACS服务。
效果如下 :
一、两个服务接口
PACS服务需要提供两个接口
- 查询检查接口
- 查询图像接口
1. 查询检查接口
接口名称可自定义,本文中的接口名称:“http://localhost:9000/queryStudy”
查询参数可自定义,本文中的接口参数有:
startDate // 检查开始日期
endDate // 检查结束日期
studyId // 检查号
modality // 设备类型
name // 姓名
示例:
http://localhost:9000/queryStudy?startDate=2000-06-09&endDate=2025-06-09&studyId=2&modality=CT
返回数据示例:
[
{
"No": "1",
"StudyId": "2",
"PatName": "程**",
"Sex": "女",
"Age": "43Y",
"ImageNum": "1",
"MzId": "",
"Modality": "CT",
"RadiId": "CT0000000001234567",
"StudyDate": "2020-08-25 10:03:33",
"ExamItem": "胸部X线计算机体层(CT)平扫",
"ExamPart": ""
},
{
"No": "2",
"StudyId": "4",
"PatName": "赢**",
"Sex": "女",
"Age": "63Y",
"ImageNum": "1",
"MzId": "",
"Modality": "CT",
"RadiId": "CT0000000002",
"StudyDate": "2020-08-25 10:17:01",
"ExamItem": "颅脑X线计算机体层(CT)平扫",
"ExamPart": ""
},
{
"No": "3",
"StudyId": "13",
"PatName": "余*",
"Sex": "女",
"Age": "37Y",
"ImageNum": "1",
"MzId": "",
"Modality": "CT",
"RadiId": "CT0000000003",
"StudyDate": "2022-11-14 16:41:29",
"ExamItem": "DR胸椎正位 *(1)",
"ExamPart": ""
}
...
]
2. 查询图像接口
接口名称可自定义,本文中的接口名称:“http://localhost:9000/queryImage”
参数:
studyId // 检查号
示例:http://localhost:9000/queryImage?studyId=2
返回数据示例:
[
"http://localhost:9000/pacs/CT/2020-08-25/2/c8353e44b039eca9b334e78b741f3b35/1.dcm",
"http://localhost:9000/pacs/CT/2020-08-25/2/c8353e44b039eca9b334e78b741f3b35/2.dcm",
"http://localhost:9000/pacs/CT/2020-08-25/2/c8353e44b039eca9b334e78b741f3b35/3.dcm",
"http://localhost:9000/pacs/CT/2020-08-25/2/c8353e44b039eca9b334e78b741f3b35/4.dcm",
...
]
二、查询界面组件
onSearch 查询检查,显示到列表
onRowClick 查询一次检查所有图像,并归档
<template>
<div class="pacs">
<el-dialog
:title="查询检查"
draggable
v-model="visible"
:modal="false"
:close-on-click-modal="false"
width="840px"
@keydown.native.stop
>
<div class="querycond">
<el-form :model="form" label-width="100px" @submit.prevent>
<el-row :gutter="0">
<el-col :span="12">
<el-form-item :label="检查号:">
<el-input v-model="form.idNumber"></el-input>
</el-form-item>
</el-col>
<el-col :span="7">
<el-form-item :label="姓名:">
<el-input v-model="form.name"></el-input>
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item class="right-align" label-width="30px">
<el-button type="primary" @click="onSearch">
<el-icon><Search /></el-icon>
<span style="vertical-align: middle">
查询
</span>
</el-button>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="0">
<el-col :span="12">
<el-form-item :label="检查日期:">
<el-date-picker
v-model="form.queryDate"
type="daterange"
align="right"
unlink-panels
:range-separator="至"
:shortcuts="shortcuts"
>
</el-date-picker>
</el-form-item>
</el-col>
<el-col :span="7">
<el-form-item :label="检查类型:">
<el-select v-model="form.modality" placeholder="">
<el-option
v-for="mod in modalities"
:key="mod"
:label="mod"
:value="mod"
></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item class="right-align" label-width="30px">
<el-button @click="onClear">
<el-icon><Delete /></el-icon>
<span style="vertical-align: middle">
清空
</span>
</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<el-table :data="studyData" border height="420px" @row-dblclick="onRowClick">
<el-table-column
property="StudyId"
:label="检查号"
width="100"
:show-overflow-tooltip="true"
></el-table-column>
<el-table-column
property="PatName"
:label="姓名"
width="100"
:show-overflow-tooltip="true"
></el-table-column>
<el-table-column
property="Sex"
:label="性别"
width="80"
></el-table-column>
<el-table-column
property="Age"
:label="年龄"
width="80"
></el-table-column>
<el-table-column
property="StudyDate"
:label="检查日期"
width="168"
:show-overflow-tooltip="true"
></el-table-column>
<el-table-column
property="Modality"
:label="设备"
width="90"
></el-table-column>
<el-table-column
property="ExamItem"
:label="检查项目"
width="220"
:show-overflow-tooltip="true"
></el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script lang="js" setup name="SearchPACS">
import { ref, reactive, onMounted } from "vue";
import { desensitizeSubstring } from "@/utils";
import { useArchiveStore } from "../stores/archive";
import { ElMessage } from "element-plus";
const archiveStore = useArchiveStore();
const studyData = ref([]);
const visible = ref(false);
const modalities = ["ALL", "CR", "DX", "MG", "CT", "MR", "RF", "OT", "XA"];
const form = reactive({
idNumber: "",
name: "",
modality: "ALL",
queryDate: [new Date(), new Date()],
});
const shortcuts: [
{
text: "今天",
value: () => {
const end = new Date();
const start = new Date();
return [start, end];
},
},
{
text: "昨天",
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24);
return [start, end];
},
},
{
text: "最近三天",
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 3);
return [start, end];
},
},
{
text: "最近一周",
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
return [start, end];
},
},
{
text: "最近一个月",
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
return [start, end];
},
},
{
text: "最近一年",
value: () => {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 365);
return [start, end];
},
},
];
const show = () => {
visible.value = true;
};
const onSearch = () => {
// 查询检查服务接口,实际可存储到配置中
const studyapi = "http://localhost:9000/queryStudy";
let url = studyapi;
const sdate = form.queryDate[0];
const edate = form.queryDate[1];
url += "?startDate=" + sdate + "&endDate=" + edate;
if (form.modality !== "ALL") {
url += "&modality=" + form.modality;
}
if (form.idNumber) {
url += "&id=" + form.idNumber;
}
if (form.name) {
url += "&name=" + form.name;
}
//console.log(url);
fetch(url)
.then((response) => response.json())
.then((data) => {
// console.log(data);
studyData.value = data.map((item) => {
return {...item, PatName: desensitizeSubstring(item.PatName, 1, -1) }
});
// console.log(studyData.value);
})
.catch((error) => {
console.log(error);
ElMessage.error("查询失败");
});
};
const onClear = () => {
form.idNumber = "";
form.name = "";
form.modality = "ALL";
form.queryDate = [new Date(), new Date()];
studyData.value = [];
};
const onRowClick = (row) => {
// 查询图像服务接口,实际可存储到配置中
const imageapi = "http://localhost:9000/queryImage";
let url;
let params = "studyId=" + row.StudyId;
url = imageapi + "?" + params;
// console.log(url);
ElMessage.success("正在获取图像, 请稍候...");
visible.value = false;
fetch(url)
.then((response) => response.json())
.then((data) => {
// console.log(data);
const imageIds = data.map((item) => "dicomweb:" + item);
//console.log("imageIds: ", imageIds);
imageIds.forEach((imageId) => {
archiveStore.archiveFile(imageId);
});
})
.catch((error) => {
console.log(error);
ElMessage.error("查询失败");
});
};
defineExpose({
show,
});
</script>
<style lang="scss" scoped>
:deep(.el-dialog) {
padding: 1px;
border: 1px solid gray;
border-radius: 4px;
}
:deep(.el-dialog__header) {
background-color: #eee;
height: 40px;
padding: 8px 8px;
border: none;
}
:deep(.el-dialog__title) {
color: #444;
}
:deep(.el-table__body-wrapper) {
background-color: white;
}
:deep(.el-table__header .is-leaf) {
background-color: white;
color: #444;
}
:deep(.el-table__cell) {
background-color: white;
color: #444;
padding: 4px 0px;
}
:deep(.el-table--enable-row-hover .el-table__body tr:hover > td) {
background-color: #d3e3fd;
}
:deep(.el-table--border .el-table__cell:first-child .cell) {
padding-left: 4px;
}
.querycond {
width: 100%;
height: 100px;
padding: 8px 0;
background-color: white;
}
:deep(.el-form-item) {
margin-bottom: 4px;
}
:deep(.el-form-item__label) {
color: #444;
}
:deep(.el-form-item__content .el-input .el-input__inner) {
height: 30px;
line-height: 30px;
padding: 0px 8px;
}
:deep(.el-button) {
height: 32px;
width: 100px;
line-height: 32px;
padding: 0;
}
:deep(.el-range-editor.el-input__inner) {
height: 32px;
padding: 0 10px;
}
:deep(.el-date-editor--daterange) {
width: 320px;
}
:deep(.el-date-editor .el-range-separator) {
padding: 0;
width: 8%;
}
</style>
三、修改归档
之前只对本地文件归档,现增加对网络文件归档的兼容
归档逻辑请查看第四章 加载本地文件夹中的dicom文件并归档
修改archiveStore.js文件中的archiveFile函数。
修改前:
async function archiveFile(file) {
const imageId = cornerstoneDICOMImageLoader.wadouri.fileManager.add(file);
const dcmImage = new DCMImage({ imageId });
await dcmImage.parse();
...
}
修改后:
async function archiveFile(file) {
let imageId = "";
if (typeof file === "string") {
imageId = file;
} else {
imageId = cornerstoneDICOMImageLoader.wadouri.fileManager.add(file);
}
const dcmImage = new DCMImage({ imageId });
await dcmImage.parse();
...
}
总结
本章实现从PACS服务器加载dicom图像。
说明了对接PACS服务所需的两个接口的定义和返回数据格式。