2026-05-03-22-36-18 增加DICOM阅览和单项下载

This commit is contained in:
2026-05-03 22:46:46 +08:00
parent 525c2c1dda
commit bff7eead08
6 changed files with 509 additions and 6 deletions

View File

@@ -93,6 +93,19 @@ const VIDEO_SOURCE_OPTIONS = [
const PREVIEW_ALGORITHM_OPTIONS = VIDEO_SOURCE_OPTIONS;
const VIEWER_PLANE_OPTIONS = [
{ key: 'coronal', label: '冠状位' },
{ key: 'sagittal', label: '矢状位' },
];
const VIEWER_WINDOW_OPTIONS = [
{ key: 'default', label: '默认' },
{ key: 'bone', label: '骨窗' },
{ key: 'soft_tissue', label: '软组织' },
{ key: 'brain', label: '脑窗' },
{ key: 'lung', label: '肺窗' },
];
type LibraryItem = {
id: string;
patientId: string;
@@ -116,6 +129,16 @@ type LibraryInfo = {
}[];
};
type LibraryViewerPreview = {
imageUrl: string;
index: number;
count: number;
plane: string;
window: string;
windowLabel: string;
patientId: string;
};
type StoredDeformationJob = {
job: BackendJob;
progress: number;
@@ -354,6 +377,13 @@ export default function App() {
const [isUploadingDicom, setIsUploadingDicom] = useState(false);
const [libraryInfo, setLibraryInfo] = useState<LibraryInfo | null>(null);
const [isLibraryInfoLoading, setIsLibraryInfoLoading] = useState(false);
const [libraryViewerItem, setLibraryViewerItem] = useState<LibraryItem | null>(null);
const [viewerPlane, setViewerPlane] = useState('coronal');
const [viewerWindow, setViewerWindow] = useState('default');
const [viewerSliceIndex, setViewerSliceIndex] = useState<number | 'middle'>('middle');
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
const [isViewerLoading, setIsViewerLoading] = useState(false);
const [viewerError, setViewerError] = useState('');
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -868,6 +898,47 @@ export default function App() {
}
};
const openLibraryViewer = (item: LibraryItem) => {
setLibraryViewerItem(item);
setViewerPlane('coronal');
setViewerWindow('default');
setViewerSliceIndex('middle');
setViewerPreview(null);
setViewerError('');
};
const closeLibraryViewer = () => {
setLibraryViewerItem(null);
setViewerPreview(null);
setViewerError('');
setIsViewerLoading(false);
};
useEffect(() => {
if (!libraryViewerItem) return;
const controller = new AbortController();
setIsViewerLoading(true);
setViewerError('');
fetch(
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${encodeURIComponent(String(viewerSliceIndex))}&window=${encodeURIComponent(viewerWindow)}`,
{ signal: controller.signal }
)
.then(async response => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || '阅览图像生成失败');
setViewerPreview(data);
})
.catch(error => {
if ((error as Error).name !== 'AbortError') setViewerError((error as Error).message);
})
.finally(() => {
if (!controller.signal.aborted) setIsViewerLoading(false);
});
return () => controller.abort();
}, [libraryViewerItem?.id, viewerPlane, viewerSliceIndex, viewerWindow]);
const changePassword = (userId: string, newPass: string) => {
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
setPwChangeInput('');
@@ -1198,7 +1269,7 @@ export default function App() {
<Download size={14} />
{zipJobs.all?.status === 'running'
? `四状态 ZIP 打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
: '下载四状态 ZIP'}
: '下载结果'}
</button>
)}
{zipJobs.all?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJobs.all.error || '四状态 ZIP 打包失败'}</p>}
@@ -1369,7 +1440,7 @@ export default function App() {
<Download size={14} />
{zipJobs.all?.status === 'running'
? `打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
: '下载四状态 DICOM ZIP'}
: '下载结果'}
</button>
</div>
<div className="grid grid-cols-4 gap-4 p-4">
@@ -1384,9 +1455,27 @@ export default function App() {
return (
<div key={t.key} className="min-w-0">
<div className="flex justify-between items-center mb-3">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${t.key === 'original' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500'}`}>{t.label}</span>
<div className="flex items-center gap-2 min-w-0">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${t.key === 'original' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500'}`}>{t.label}</span>
<button
onClick={() => handlePackageDownload(t.key)}
disabled={deformationJob?.status !== 'completed' || !deformationJob.result?.outputs || zipJobs[t.key]?.status === 'running'}
className="w-7 h-7 rounded-lg bg-slate-100 text-slate-500 hover:bg-green-600 hover:text-white transition-all flex items-center justify-center disabled:opacity-40 disabled:hover:bg-slate-100 disabled:hover:text-slate-500"
title={`下载${t.label} DICOM`}
>
<Download size={13} />
</button>
</div>
<span className="text-[8px] font-mono text-slate-300">{t.sub}</span>
</div>
{zipJobs[t.key]?.status === 'running' && (
<p className="mb-2 text-[9px] font-bold text-green-600">
{formatProgress(progressFromJob(zipJobs[t.key], 0))}%
</p>
)}
{zipJobs[t.key]?.status === 'failed' && (
<p className="mb-2 text-[9px] font-bold text-red-500 break-all">{zipJobs[t.key].error || '本状态 DICOM ZIP 打包失败'}</p>
)}
<div className="h-44 bg-slate-900 rounded-xl relative flex justify-center items-center overflow-hidden border border-slate-800">
{imagePath ? (
<img src={fileUrl(imagePath)} className="w-full h-full object-contain" />
@@ -1519,7 +1608,7 @@ export default function App() {
<span>{item.fileCount || 0} </span>
</div>
<div className="grid grid-cols-3 gap-3 mt-2">
<div className="grid grid-cols-4 gap-2 mt-2">
<button
onClick={() => {
if (item.status === 'processed') {
@@ -1535,11 +1624,17 @@ 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'}`}
>
</button>
<button
onClick={() => item.status === 'processed' ? openLibraryViewer(item) : showToast('该影像尚在处理队列中,无法阅览')}
className={`py-2.5 text-[11px] font-black rounded-xl transition-all flex items-center justify-center gap-1 ${item.status === 'processed' ? 'bg-slate-900 text-white hover:bg-blue-600' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
>
<Eye size={12} />
</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"
className="py-2.5 bg-slate-100 text-slate-500 text-[11px] font-black rounded-xl hover:bg-blue-50 hover:text-blue-600 transition-all flex items-center justify-center gap-1"
>
<Info size={12} />
</button>
@@ -1831,6 +1926,112 @@ export default function App() {
</div>
)}
{libraryViewerItem && (
<div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-40 flex items-center justify-center p-6">
<div className="w-full max-w-6xl max-h-[90vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col">
<div className="px-7 py-5 border-b flex items-center justify-between gap-5">
<div className="min-w-0">
<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 truncate">{libraryViewerItem.patientId}</h3>
</div>
<button
onClick={closeLibraryViewer}
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 shrink-0"
>
<X size={20} />
</button>
</div>
<div className="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
<div className="space-y-5">
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3"></p>
<div className="grid grid-cols-2 gap-2">
{VIEWER_PLANE_OPTIONS.map(option => (
<button
key={option.key}
onClick={() => {
setViewerPlane(option.key);
setViewerSliceIndex('middle');
}}
className={`py-3 rounded-xl text-xs font-black transition-all ${
viewerPlane === option.key
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3"></p>
<select
value={viewerWindow}
onChange={event => setViewerWindow(event.target.value)}
className="w-full px-3 py-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
>
{VIEWER_WINDOW_OPTIONS.map(option => (
<option key={option.key} value={option.key}>{option.label}</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></p>
<span className="text-[10px] font-mono font-black text-blue-600">
{(viewerPreview?.index ?? 0) + 1} / {viewerPreview?.count || 1}
</span>
</div>
<input
type="range"
min="0"
max={Math.max(0, (viewerPreview?.count || 1) - 1)}
value={viewerPreview?.index ?? 0}
onChange={event => setViewerSliceIndex(parseInt(event.target.value, 10))}
className="w-full h-1.5 accent-blue-600 cursor-pointer"
/>
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-400"></span>
<span className="font-black text-slate-700">{VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-400"></span>
<span className="font-black text-slate-700">{viewerPreview?.windowLabel || VIEWER_WINDOW_OPTIONS.find(option => option.key === viewerWindow)?.label}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-400"></span>
<span className="font-black text-slate-700">{libraryViewerItem.fileCount || 0} </span>
</div>
</div>
</div>
<div className="min-h-[360px] lg:min-h-[560px] bg-slate-950 rounded-2xl border border-slate-900 overflow-hidden flex items-center justify-center relative">
{viewerPreview?.imageUrl && !viewerError ? (
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
) : (
<div className="text-center text-white/35">
<ImageIcon size={46} className="mx-auto mb-3" />
<p className="text-xs font-bold">{viewerError || '等待影像载入'}</p>
</div>
)}
{isViewerLoading && (
<div className="absolute top-4 right-4 px-3 py-1.5 rounded-lg bg-black/60 text-white text-[10px] font-black">
...
</div>
)}
</div>
</div>
</div>
</div>
)}
{(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">