add DICOM metadata details modal
This commit is contained in:
@@ -30,7 +30,9 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Server,
|
Server,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Eye
|
Eye,
|
||||||
|
Info,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
@@ -66,6 +68,16 @@ type LibraryItem = {
|
|||||||
source?: 'seed' | 'upload';
|
source?: 'seed' | 'upload';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LibraryInfo = {
|
||||||
|
id: string;
|
||||||
|
patientId: string;
|
||||||
|
fileCount: number;
|
||||||
|
groups: {
|
||||||
|
title: string;
|
||||||
|
items: { label: string; value: string }[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
const API_BASE = typeof window === 'undefined'
|
const API_BASE = typeof window === 'undefined'
|
||||||
? 'http://127.0.0.1:8787'
|
? 'http://127.0.0.1:8787'
|
||||||
: `${window.location.protocol}//${window.location.hostname}:8787`;
|
: `${window.location.protocol}//${window.location.hostname}:8787`;
|
||||||
@@ -183,6 +195,8 @@ export default function App() {
|
|||||||
const [libraryData, setLibraryData] = useState<LibraryItem[]>([]);
|
const [libraryData, setLibraryData] = useState<LibraryItem[]>([]);
|
||||||
const [selectedLibraryId, setSelectedLibraryId] = useState('');
|
const [selectedLibraryId, setSelectedLibraryId] = useState('');
|
||||||
const [isUploadingDicom, setIsUploadingDicom] = useState(false);
|
const [isUploadingDicom, setIsUploadingDicom] = useState(false);
|
||||||
|
const [libraryInfo, setLibraryInfo] = useState<LibraryInfo | null>(null);
|
||||||
|
const [isLibraryInfoLoading, setIsLibraryInfoLoading] = useState(false);
|
||||||
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
@@ -416,6 +430,19 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showLibraryInfo = async (item: LibraryItem) => {
|
||||||
|
setIsLibraryInfoLoading(true);
|
||||||
|
setLibraryInfo(null);
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/api/library/info?id=${encodeURIComponent(item.id)}`) as LibraryInfo;
|
||||||
|
setLibraryInfo(data);
|
||||||
|
} catch (error) {
|
||||||
|
showToast((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setIsLibraryInfoLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const changePassword = (userId: string, newPass: string) => {
|
const changePassword = (userId: string, newPass: string) => {
|
||||||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||||||
setPwChangeInput('');
|
setPwChangeInput('');
|
||||||
@@ -904,7 +931,7 @@ export default function App() {
|
|||||||
<span>{item.fileCount || 0} 张</span>
|
<span>{item.fileCount || 0} 张</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.status === 'processed') {
|
if (item.status === 'processed') {
|
||||||
@@ -919,13 +946,19 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
className={`py-2.5 text-[11px] font-black rounded-xl transition-all ${item.status === 'processed' ? 'bg-blue-600 text-white hover:bg-black shadow-lg shadow-blue-500/20' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
|
className={`py-2.5 text-[11px] font-black rounded-xl transition-all ${item.status === 'processed' ? 'bg-blue-600 text-white hover:bg-black shadow-lg shadow-blue-500/20' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
调阅工作站
|
调阅
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => showLibraryInfo(item)}
|
||||||
|
className="py-2.5 bg-slate-900 text-white text-[11px] font-black rounded-xl hover:bg-blue-600 transition-all flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Info size={12} /> 信息
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteImage(item.id)}
|
onClick={() => deleteImage(item.id)}
|
||||||
className="py-2.5 bg-slate-50 text-slate-400 text-[11px] font-black rounded-xl hover:bg-red-50 hover:text-red-500 transition-all border border-slate-100"
|
className="py-2.5 bg-slate-50 text-slate-400 text-[11px] font-black rounded-xl hover:bg-red-50 hover:text-red-500 transition-all border border-slate-100"
|
||||||
>
|
>
|
||||||
删除影像
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1093,6 +1126,54 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{(libraryInfo || isLibraryInfoLoading) && (
|
||||||
|
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm z-40 flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-4xl max-h-[85vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-7 py-5 border-b flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">DICOM 基本信息</p>
|
||||||
|
<h3 className="text-xl font-black text-slate-800 mt-1">
|
||||||
|
{libraryInfo?.patientId || '正在读取影像信息'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLibraryInfo(null);
|
||||||
|
setIsLibraryInfoLoading(false);
|
||||||
|
}}
|
||||||
|
className="w-10 h-10 rounded-xl bg-slate-50 text-slate-400 hover:bg-red-50 hover:text-red-500 flex items-center justify-center transition-all"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLibraryInfoLoading ? (
|
||||||
|
<div className="h-72 flex items-center justify-center text-slate-400 text-sm font-bold">
|
||||||
|
正在读取 DICOM 头信息...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-7 overflow-y-auto max-h-[68vh]">
|
||||||
|
<div className="grid grid-cols-2 gap-5">
|
||||||
|
{libraryInfo?.groups.map(group => (
|
||||||
|
<div key={group.title} className="border border-slate-100 rounded-2xl p-5 bg-slate-50/50">
|
||||||
|
<h4 className="text-xs font-black text-slate-700 mb-4">{group.title}</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{group.items.map(item => (
|
||||||
|
<div key={`${group.title}-${item.label}`} className="flex items-start justify-between gap-4 text-xs">
|
||||||
|
<span className="text-slate-400 font-bold shrink-0">{item.label}</span>
|
||||||
|
<span className="text-slate-700 font-mono text-right break-all">{item.value || '-'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
<div className="fixed bottom-10 right-10 bg-slate-900 text-white px-6 py-4 rounded-2xl shadow-2xl z-50 flex items-center gap-3 animate-in fade-in slide-in-from-right-10">
|
<div className="fixed bottom-10 right-10 bg-slate-900 text-white px-6 py-4 rounded-2xl shadow-2xl z-50 flex items-center gap-3 animate-in fade-in slide-in-from-right-10">
|
||||||
|
|||||||
104
web_backend.py
104
web_backend.py
@@ -17,6 +17,7 @@ from urllib.parse import parse_qs, quote, unquote, urlparse
|
|||||||
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
os.environ.setdefault("MPLCONFIGDIR", "/tmp/head_ct_morph_matplotlib")
|
||||||
|
|
||||||
import pydicom
|
import pydicom
|
||||||
|
from pydicom.multival import MultiValue
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from generate_head_extension_video import generate_video
|
from generate_head_extension_video import generate_video
|
||||||
@@ -187,6 +188,103 @@ def make_library_slice_preview(item_id, index):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dicom_value(ds, name, fallback="-"):
|
||||||
|
value = getattr(ds, name, fallback)
|
||||||
|
if value in [None, ""]:
|
||||||
|
return fallback
|
||||||
|
if isinstance(value, (list, tuple, MultiValue)):
|
||||||
|
return " / ".join(str(item) for item in value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dicom_date(value):
|
||||||
|
value = str(value or "")
|
||||||
|
if len(value) == 8 and value.isdigit():
|
||||||
|
return f"{value[0:4]}-{value[4:6]}-{value[6:8]}"
|
||||||
|
return value or "-"
|
||||||
|
|
||||||
|
|
||||||
|
def dicom_time(value):
|
||||||
|
value = str(value or "")
|
||||||
|
if len(value) >= 6 and value[:6].isdigit():
|
||||||
|
return f"{value[0:2]}:{value[2:4]}:{value[4:6]}"
|
||||||
|
return value or "-"
|
||||||
|
|
||||||
|
|
||||||
|
def make_library_info(item_id):
|
||||||
|
item = find_library_item(item_id)
|
||||||
|
if not item:
|
||||||
|
raise RuntimeError("影像库中没有找到该数据。")
|
||||||
|
|
||||||
|
dicom_files = sorted_dicom_files(item["dicomPath"])
|
||||||
|
if not dicom_files:
|
||||||
|
raise RuntimeError("该影像数据没有可读取的 .dcm 文件。")
|
||||||
|
|
||||||
|
first = pydicom.dcmread(str(dicom_files[0]), stop_before_pixels=True, force=True)
|
||||||
|
last = pydicom.dcmread(str(dicom_files[-1]), stop_before_pixels=True, force=True)
|
||||||
|
pixel_spacing = dicom_value(first, "PixelSpacing")
|
||||||
|
matrix = f"{dicom_value(first, 'Columns')} x {dicom_value(first, 'Rows')}"
|
||||||
|
instance_range = f"{dicom_value(first, 'InstanceNumber')} - {dicom_value(last, 'InstanceNumber')}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": item["id"],
|
||||||
|
"patientId": item["patientId"],
|
||||||
|
"fileCount": len(dicom_files),
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"title": "患者信息",
|
||||||
|
"items": [
|
||||||
|
{"label": "患者姓名", "value": dicom_value(first, "PatientName")},
|
||||||
|
{"label": "患者 ID", "value": dicom_value(first, "PatientID")},
|
||||||
|
{"label": "性别", "value": dicom_value(first, "PatientSex")},
|
||||||
|
{"label": "年龄", "value": dicom_value(first, "PatientAge")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "检查信息",
|
||||||
|
"items": [
|
||||||
|
{"label": "检查日期", "value": dicom_date(getattr(first, "StudyDate", ""))},
|
||||||
|
{"label": "检查时间", "value": dicom_time(getattr(first, "StudyTime", ""))},
|
||||||
|
{"label": "检查描述", "value": dicom_value(first, "StudyDescription")},
|
||||||
|
{"label": "检查号", "value": dicom_value(first, "AccessionNumber")},
|
||||||
|
{"label": "机构", "value": dicom_value(first, "InstitutionName")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "序列信息",
|
||||||
|
"items": [
|
||||||
|
{"label": "模态", "value": dicom_value(first, "Modality")},
|
||||||
|
{"label": "部位", "value": dicom_value(first, "BodyPartExamined")},
|
||||||
|
{"label": "序列描述", "value": dicom_value(first, "SeriesDescription")},
|
||||||
|
{"label": "序列号", "value": dicom_value(first, "SeriesNumber")},
|
||||||
|
{"label": "制造商", "value": dicom_value(first, "Manufacturer")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "图像参数",
|
||||||
|
"items": [
|
||||||
|
{"label": "切片数", "value": str(len(dicom_files))},
|
||||||
|
{"label": "Instance 范围", "value": instance_range},
|
||||||
|
{"label": "矩阵", "value": matrix},
|
||||||
|
{"label": "像素间距", "value": pixel_spacing},
|
||||||
|
{"label": "层厚", "value": dicom_value(first, "SliceThickness")},
|
||||||
|
{"label": "层间距", "value": dicom_value(first, "SpacingBetweenSlices")},
|
||||||
|
{"label": "卷积核", "value": dicom_value(first, "ConvolutionKernel")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "扫描参数",
|
||||||
|
"items": [
|
||||||
|
{"label": "KVP", "value": dicom_value(first, "KVP")},
|
||||||
|
{"label": "管电流", "value": dicom_value(first, "XRayTubeCurrent")},
|
||||||
|
{"label": "曝光时间", "value": dicom_value(first, "ExposureTime")},
|
||||||
|
{"label": "重建直径", "value": dicom_value(first, "ReconstructionDiameter")},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_multipart(headers, body):
|
def parse_multipart(headers, body):
|
||||||
content_type = headers.get("content-type", "")
|
content_type = headers.get("content-type", "")
|
||||||
message = BytesParser(policy=default).parsebytes(
|
message = BytesParser(policy=default).parsebytes(
|
||||||
@@ -418,6 +516,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(make_library_slice_preview(item_id, index))
|
self.send_json(make_library_slice_preview(item_id, index))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/library/info":
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
item_id = params.get("id", [""])[0]
|
||||||
|
self.send_json(make_library_info(item_id))
|
||||||
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/job":
|
if parsed.path == "/api/job":
|
||||||
params = parse_qs(parsed.query)
|
params = parse_qs(parsed.query)
|
||||||
job_id = params.get("id", [""])[0]
|
job_id = params.get("id", [""])[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user