2026-05-03-22-36-18 增加DICOM阅览和单项下载
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user