refine workspace previews and library thumbnails
This commit is contained in:
@@ -54,6 +54,7 @@ type BackendJob = {
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
message: string;
|
||||
progress?: number;
|
||||
params?: any;
|
||||
result?: any;
|
||||
error?: string;
|
||||
};
|
||||
@@ -124,6 +125,7 @@ type StoredDeformationJob = {
|
||||
|
||||
const DEFORMATION_JOB_STORAGE_KEY = 'head_ct_morph_deformation_job';
|
||||
const DEFORMATION_RESULT_RENDER_VERSION = 'quick-preview-cutoff-only-v1';
|
||||
const LIBRARY_SLICE_STORAGE_KEY = 'head_ct_morph_library_slice_index';
|
||||
|
||||
const API_BASE = typeof window === 'undefined'
|
||||
? 'http://127.0.0.1:8787'
|
||||
@@ -162,20 +164,56 @@ function readStoredDeformationJob(): StoredDeformationJob | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredLibrarySliceIndexes(): Record<string, number> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(LIBRARY_SLICE_STORAGE_KEY);
|
||||
if (!rawValue) return {};
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredLibrarySliceIndex(itemId: string, fallback: number, count: number) {
|
||||
const value = readStoredLibrarySliceIndexes()[itemId];
|
||||
const numericValue = typeof value === 'number' ? value : fallback;
|
||||
return Math.max(0, Math.min(Math.max(0, count - 1), Math.round(numericValue)));
|
||||
}
|
||||
|
||||
function storeLibrarySliceIndex(itemId: string, index: number) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const current = readStoredLibrarySliceIndexes();
|
||||
current[itemId] = index;
|
||||
window.localStorage.setItem(LIBRARY_SLICE_STORAGE_KEY, JSON.stringify(current));
|
||||
} catch {
|
||||
// 保存失败不影响影像预览使用。
|
||||
}
|
||||
}
|
||||
|
||||
function LibraryDicomPreview({ item }: { item: LibraryItem }) {
|
||||
const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
|
||||
const count = Math.max(1, item.fileCount || 1);
|
||||
const middleIndex = Math.max(0, Math.floor(count / 2));
|
||||
const [sliceIndex, setSliceIndex] = useState(() => readStoredLibrarySliceIndex(item.id, middleIndex, count));
|
||||
const [requestedSliceIndex, setRequestedSliceIndex] = useState(sliceIndex);
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const count = Math.max(1, item.fileCount || 1);
|
||||
|
||||
useEffect(() => {
|
||||
const middleIndex = Math.max(0, Math.floor((item.fileCount || 1) / 2));
|
||||
setSliceIndex(middleIndex);
|
||||
setRequestedSliceIndex(middleIndex);
|
||||
const nextCount = Math.max(1, item.fileCount || 1);
|
||||
const nextMiddleIndex = Math.max(0, Math.floor(nextCount / 2));
|
||||
const storedIndex = readStoredLibrarySliceIndex(item.id, nextMiddleIndex, nextCount);
|
||||
setSliceIndex(storedIndex);
|
||||
setRequestedSliceIndex(storedIndex);
|
||||
}, [item.id, item.fileCount]);
|
||||
|
||||
useEffect(() => {
|
||||
storeLibrarySliceIndex(item.id, Math.min(sliceIndex, count - 1));
|
||||
}, [item.id, sliceIndex, count]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setRequestedSliceIndex(sliceIndex);
|
||||
@@ -246,6 +284,44 @@ function LibraryDicomPreview({ item }: { item: LibraryItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewDicomThumbnail({ item }: { item: LibraryItem }) {
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const count = Math.max(1, item.fileCount || 1);
|
||||
const middleIndex = Math.max(0, Math.floor(count / 2));
|
||||
const index = readStoredLibrarySliceIndex(item.id, middleIndex, count);
|
||||
|
||||
setError(false);
|
||||
fetch(`${API_BASE}/api/library/preview?id=${encodeURIComponent(item.id)}&index=${index}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '预览生成失败');
|
||||
setPreviewImage(`${API_BASE}${data.imageUrl}`);
|
||||
})
|
||||
.catch(error => {
|
||||
if ((error as Error).name !== 'AbortError') setError(true);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [item.id, item.fileCount]);
|
||||
|
||||
return (
|
||||
<div className="w-16 h-12 rounded-xl flex items-center justify-center bg-slate-950 border border-slate-700/50 overflow-hidden relative shadow-inner">
|
||||
{previewImage && !error ? (
|
||||
<img src={previewImage} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<ImageIcon size={18} className="text-white/35" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const restoredDeformationJob = useRef(readStoredDeformationJob()).current;
|
||||
|
||||
@@ -294,6 +370,7 @@ export default function App() {
|
||||
const [backendOnline, setBackendOnline] = useState(false);
|
||||
const [backendMessage, setBackendMessage] = useState('正在连接本地 Python 后端...');
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [processPreviewImage, setProcessPreviewImage] = useState('');
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [deformationJob, setDeformationJob] = useState<BackendJob | null>(restoredDeformationJob?.job || null);
|
||||
const [videoJob, setVideoJob] = useState<BackendJob | null>(null);
|
||||
@@ -376,6 +453,16 @@ export default function App() {
|
||||
link.remove();
|
||||
};
|
||||
|
||||
const downloadProcessPreviewImage = () => {
|
||||
if (!processPreviewImage) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = processPreviewImage;
|
||||
link.download = `four_state_process_preview_${cervicalRotation.toFixed(1)}deg.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
};
|
||||
|
||||
const clearDeformationTask = () => {
|
||||
setDeformationJob(null);
|
||||
setProgress(0);
|
||||
@@ -453,6 +540,13 @@ export default function App() {
|
||||
handlePackageDownload('all', packageOptions);
|
||||
};
|
||||
|
||||
const downloadFourStateDicomZip = () => {
|
||||
handlePackageDownload('all', {
|
||||
dicom: DICOM_PACKAGE_OPTIONS.map(option => option.key),
|
||||
images: [],
|
||||
});
|
||||
};
|
||||
|
||||
const loadLibrary = async () => {
|
||||
const data = await apiRequest('/api/library');
|
||||
const items = data.items || [];
|
||||
@@ -473,6 +567,7 @@ export default function App() {
|
||||
setSelectedLibraryId(items[0]?.id || '');
|
||||
setLibraryInfo(null);
|
||||
setPreviewImage('');
|
||||
setProcessPreviewImage('');
|
||||
setVideoJob(null);
|
||||
setZipJobs({});
|
||||
clearDeformationTask();
|
||||
@@ -728,6 +823,7 @@ export default function App() {
|
||||
setSelectedLibraryId(data.id || items[0]?.id || '');
|
||||
setCurrentPage('workspace');
|
||||
setPreviewImage('');
|
||||
setProcessPreviewImage('');
|
||||
clearDeformationTask();
|
||||
showToast(`已上传 ${data.fileCount || files.length} 张 DICOM`);
|
||||
} catch (error) {
|
||||
@@ -751,6 +847,7 @@ export default function App() {
|
||||
if (selectedLibraryId === id) {
|
||||
setSelectedLibraryId(data.items?.[0]?.id || '');
|
||||
setPreviewImage('');
|
||||
setProcessPreviewImage('');
|
||||
clearDeformationTask();
|
||||
}
|
||||
showToast('影像已删除');
|
||||
@@ -802,6 +899,7 @@ export default function App() {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '预览生成失败');
|
||||
setPreviewImage(data.image);
|
||||
setProcessPreviewImage(data.processImage || '');
|
||||
setBackendOnline(true);
|
||||
setBackendMessage('预览已自动更新');
|
||||
} catch (error) {
|
||||
@@ -942,7 +1040,7 @@ export default function App() {
|
||||
<NavItem icon={LayoutDashboard} label="总体概况" page="overview" />
|
||||
<NavItem icon={Database} label="数据影像库" page="library" />
|
||||
<NavItem icon={ImageIcon} label="影像变换工作站" page="workspace" />
|
||||
<NavItem icon={Users} label="用户管理工作区" page="users" />
|
||||
<NavItem icon={Users} label="系统管理工作区" page="users" />
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 border-t font-medium">
|
||||
@@ -976,9 +1074,9 @@ export default function App() {
|
||||
)}
|
||||
<h3 className="font-bold text-slate-800">
|
||||
{currentPage === 'overview' && '控制台总览'}
|
||||
{currentPage === 'library' && '临床数据存档'}
|
||||
{currentPage === 'library' && '数据影像库'}
|
||||
{currentPage === 'workspace' && '影像变换工作站'}
|
||||
{currentPage === 'users' && '系统访问权限管理'}
|
||||
{currentPage === 'users' && '系统管理工作区'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
@@ -1013,6 +1111,7 @@ export default function App() {
|
||||
onChange={event => {
|
||||
setSelectedLibraryId(event.target.value);
|
||||
setPreviewImage('');
|
||||
setProcessPreviewImage('');
|
||||
clearDeformationTask();
|
||||
}}
|
||||
className="w-full px-3 py-2.5 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 text-xs font-bold text-slate-700"
|
||||
@@ -1210,59 +1309,99 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{[
|
||||
{ key: 'original', label: '原始序列', sub: 'ct_original' },
|
||||
{ key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' },
|
||||
{ key: 'gaussian_smooth', label: '高斯平滑', sub: 'ct_gaussian_smooth' },
|
||||
{ key: 'soft_transition', label: '软过渡重建', sub: 'ct_soft_transition' }
|
||||
].map(t => {
|
||||
const screenshotDir = deformationJob?.result?.previews?.screenshots;
|
||||
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
|
||||
const zipJob = zipJobs[t.key];
|
||||
return (
|
||||
<div key={t.key} className="bg-white p-4 rounded-2xl border flex flex-col hover:border-blue-200 transition-colors shadow-sm group min-h-[285px]">
|
||||
<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 === 'soft_transition' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500 group-hover:bg-slate-200'}`}>{t.label}</span>
|
||||
<span className="text-[8px] font-mono text-slate-300">{t.sub}</span>
|
||||
</div>
|
||||
<div className="flex-1 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" />
|
||||
) : (
|
||||
<div className="text-center text-white/25">
|
||||
<Layers size={34} className="mx-auto mb-3" />
|
||||
<p className="text-[10px] font-mono uppercase">等待 Python 输出</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{deformationJob?.status === 'completed' && deformationJob.result?.outputs?.[t.key] && (
|
||||
<button
|
||||
onClick={() => handlePackageDownload(t.key)}
|
||||
disabled={zipJob?.status === 'running'}
|
||||
className="mt-3 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-[11px] font-black hover:bg-green-600 hover:text-white transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-wait"
|
||||
>
|
||||
<Download size={13} />
|
||||
{zipJob?.status === 'running'
|
||||
? `打包中 ${formatProgress(progressFromJob(zipJob, 0))}%`
|
||||
: '下载本状态 DICOM ZIP'}
|
||||
</button>
|
||||
)}
|
||||
{zipJob?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJob.error || '打包失败'}</p>}
|
||||
<div className="bg-white rounded-2xl border shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-black text-slate-800">四状态过程对比图</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold mt-1">随仰头角度与算法参数自动更新</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isPreviewLoading && <span className="text-[9px] font-bold text-blue-500">同步更新中...</span>}
|
||||
<button
|
||||
onClick={downloadProcessPreviewImage}
|
||||
disabled={!processPreviewImage}
|
||||
className="w-9 h-9 rounded-xl bg-slate-100 text-slate-500 hover:bg-blue-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="下载当前四状态过程对比图"
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px] bg-slate-950 flex items-center justify-center">
|
||||
{processPreviewImage ? (
|
||||
<img src={processPreviewImage} className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center text-slate-500">
|
||||
<Layers size={42} className="mx-auto mb-3 opacity-40" />
|
||||
<p className="text-xs font-bold">{isPreviewLoading ? '正在生成四状态对比图...' : '选择影像库数据后自动生成对比图'}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deformationJob?.result?.previews?.comparison && (
|
||||
<div className="bg-white p-4 rounded-2xl border shadow-sm">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h4 className="font-black text-sm text-slate-700">四状态过程对比图</h4>
|
||||
<span className="text-[9px] font-mono text-slate-400 break-all">{deformationJob.result.previews.comparison}</span>
|
||||
<div className="bg-white rounded-2xl border shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h4 className="font-black text-slate-800">四状态 DICOM 输出结果</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold mt-1">本区域显示已生成的四状态输出截图</p>
|
||||
</div>
|
||||
<img src={fileUrl(deformationJob.result.previews.comparison)} className="w-full rounded-xl bg-slate-950" />
|
||||
<div className="flex items-center gap-3 text-center">
|
||||
<div className="px-3 py-2 rounded-xl bg-slate-50 border border-slate-100 min-w-24">
|
||||
<p className="text-[8px] font-black text-slate-300 uppercase">仰头角度</p>
|
||||
<p className="text-xs font-black text-blue-600 font-mono">
|
||||
{(deformationJob?.params?.angleDegrees ?? cervicalRotation).toFixed(1)}°
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-xl bg-slate-50 border border-slate-100 min-w-28">
|
||||
<p className="text-[8px] font-black text-slate-300 uppercase">软过渡宽度</p>
|
||||
<p className="text-xs font-black text-blue-600 font-mono">
|
||||
{deformationJob?.params?.transitionWidth ?? transitionWidth}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={downloadFourStateDicomZip}
|
||||
disabled={deformationJob?.status !== 'completed' || !deformationJob.result?.outputs || zipJobs.all?.status === 'running'}
|
||||
className="px-5 py-3 bg-green-600 text-white rounded-xl text-[11px] font-black hover:bg-green-700 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
<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">
|
||||
{[
|
||||
{ key: 'original', label: '原始序列', sub: 'ct_original' },
|
||||
{ key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' },
|
||||
{ key: 'gaussian_smooth', label: '高斯平滑', sub: 'ct_gaussian_smooth' },
|
||||
{ key: 'soft_transition', label: '软过渡重建', sub: 'ct_soft_transition' }
|
||||
].map(t => {
|
||||
const screenshotDir = deformationJob?.result?.previews?.screenshots;
|
||||
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
|
||||
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>
|
||||
<span className="text-[8px] font-mono text-slate-300">{t.sub}</span>
|
||||
</div>
|
||||
<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" />
|
||||
) : (
|
||||
<div className="text-center text-white/25">
|
||||
<Layers size={30} className="mx-auto mb-3" />
|
||||
<p className="text-[10px] font-mono uppercase">等待 Python 输出</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{zipJobs.all?.status === 'failed' && <p className="px-5 pb-4 text-[10px] text-red-500 font-bold break-all">{zipJobs.all.error || '四状态 DICOM ZIP 打包失败'}</p>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1293,10 +1432,7 @@ export default function App() {
|
||||
{libraryData.slice(0, 4).map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between p-5 bg-slate-50/50 rounded-3xl border border-transparent hover:border-blue-100 transition-all group">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className={`w-16 h-12 rounded-xl flex items-center justify-center ${item.previewColor} border border-slate-700/50 flex-col gap-1 overflow-hidden relative shadow-inner`}>
|
||||
<div className="w-6 h-6 border-white/20 border-2 rounded-full rotate-12 opacity-50"></div>
|
||||
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none"></div>
|
||||
</div>
|
||||
<OverviewDicomThumbnail item={item} />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-700">{item.patientId}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono">{item.date} · {item.size}MB</p>
|
||||
@@ -1387,6 +1523,7 @@ export default function App() {
|
||||
if (item.status === 'processed') {
|
||||
setSelectedLibraryId(item.id);
|
||||
setPreviewImage('');
|
||||
setProcessPreviewImage('');
|
||||
clearDeformationTask();
|
||||
setCurrentPage('workspace');
|
||||
showToast(`已选择 ${item.patientId} 作为工作站数据源`);
|
||||
|
||||
Reference in New Issue
Block a user