add DICOM slice previews to image library
This commit is contained in:
@@ -70,6 +70,77 @@ const API_BASE = typeof window === 'undefined'
|
||||
? 'http://127.0.0.1:8787'
|
||||
: `${window.location.protocol}//${window.location.hostname}:8787`;
|
||||
|
||||
function LibraryDicomPreview({ item }: { item: LibraryItem }) {
|
||||
const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const count = Math.max(1, item.fileCount || 1);
|
||||
|
||||
useEffect(() => {
|
||||
setSliceIndex(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
|
||||
}, [item.id, item.fileCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
fetch(`${API_BASE}/api/library/preview?id=${encodeURIComponent(item.id)}&index=${sliceIndex}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '预览生成失败');
|
||||
setPreviewImage(data.image);
|
||||
})
|
||||
.catch(error => {
|
||||
if ((error as Error).name !== 'AbortError') setError((error as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [item.id, sliceIndex]);
|
||||
|
||||
return (
|
||||
<div className="h-52 bg-slate-950 relative flex items-center justify-center border-b border-slate-100 shadow-inner overflow-hidden">
|
||||
<div className="absolute top-3 left-4 flex gap-1.5 z-10">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white/25"></div>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white/25"></div>
|
||||
</div>
|
||||
<div className="absolute top-3 right-4 z-10">
|
||||
<span className={`text-[8px] font-black px-2 py-0.5 rounded border ${item.status === 'processed' ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'bg-amber-500/20 text-amber-400 border-amber-500/30'}`}>
|
||||
{item.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{previewImage && !error ? (
|
||||
<img src={previewImage} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center text-white/35">
|
||||
<ImageIcon size={34} className="mx-auto mb-2" />
|
||||
<p className="text-[10px] font-bold">{error || (isLoading ? '正在生成预览...' : '等待预览')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/45 to-transparent">
|
||||
<div className="flex items-center justify-between text-[8px] font-mono text-white/65 mb-2 uppercase tracking-[0.18em]">
|
||||
<span>Axial DICOM</span>
|
||||
<span>{sliceIndex + 1} / {count}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={count - 1}
|
||||
value={Math.min(sliceIndex, count - 1)}
|
||||
onChange={event => setSliceIndex(parseInt(event.target.value, 10))}
|
||||
className="w-full h-1 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// --- Authentication State ---
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
@@ -798,28 +869,7 @@ export default function App() {
|
||||
<div className="grid grid-cols-4 gap-8">
|
||||
{libraryData.map(item => (
|
||||
<div key={item.id} className="bg-white rounded-[2.5rem] border border-slate-200 overflow-hidden flex flex-col group hover:border-blue-400 hover:shadow-2xl hover:shadow-blue-500/10 transition-all duration-300">
|
||||
{/* Simulated Preview Box */}
|
||||
<div className={`h-44 ${item.previewColor} relative flex items-center justify-center p-6 border-b border-slate-100 shadow-inner group-hover:scale-[1.02] transition-transform`}>
|
||||
<div className="absolute top-3 left-4 flex gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white/20"></div>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white/20"></div>
|
||||
</div>
|
||||
{/* Abstract DICOM Scan Visualization */}
|
||||
<div className="w-full h-full border border-white/5 rounded-xl flex items-center justify-center overflow-hidden">
|
||||
<div className="w-24 h-24 border-2 border-dashed border-white/10 rounded-full animate-spin-slow opacity-30"></div>
|
||||
<div className="absolute w-20 h-28 border-2 border-white/20 rounded-[40%] rotate-45 blur-[1px]"></div>
|
||||
<div className="absolute w-28 h-20 border-2 border-white/20 rounded-[40%] -rotate-45 blur-[1px]"></div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"></div>
|
||||
<div className="absolute bottom-3 left-4 text-[7px] font-mono text-white/40 uppercase tracking-[0.2em] leading-none">
|
||||
Axial View :: ID_{item.id.toUpperCase()}
|
||||
</div>
|
||||
<div className="absolute top-3 right-4">
|
||||
<span className={`text-[8px] font-black px-2 py-0.5 rounded border ${item.status === 'processed' ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'bg-amber-500/20 text-amber-400 border-amber-500/30'}`}>
|
||||
{item.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<LibraryDicomPreview item={item} />
|
||||
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
|
||||
Reference in New Issue
Block a user