2344 lines
111 KiB
TypeScript
2344 lines
111 KiB
TypeScript
/**
|
||
* @license
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import React, { useState, useEffect, useRef } from 'react';
|
||
import {
|
||
Activity,
|
||
Check,
|
||
Download,
|
||
Layers,
|
||
MonitorPlay,
|
||
Save,
|
||
LayoutDashboard,
|
||
Database,
|
||
Image as ImageIcon,
|
||
Users,
|
||
LogOut,
|
||
UserPlus,
|
||
Key,
|
||
ChevronRight,
|
||
ShieldCheck,
|
||
Search,
|
||
Settings,
|
||
Plus,
|
||
Play as PlayIcon,
|
||
RefreshCw,
|
||
Film,
|
||
FolderOpen,
|
||
Server,
|
||
AlertCircle,
|
||
Info,
|
||
Eye,
|
||
EyeOff,
|
||
X
|
||
} from 'lucide-react';
|
||
|
||
// --- Types ---
|
||
type User = {
|
||
id: string;
|
||
username: string;
|
||
password: string;
|
||
role: 'admin' | 'user';
|
||
createdAt: string;
|
||
};
|
||
|
||
type Page = 'overview' | 'library' | 'workspace' | 'users';
|
||
|
||
type BackendJob = {
|
||
id: string;
|
||
kind: 'deformation' | 'video' | 'zip';
|
||
owner?: string;
|
||
status: 'running' | 'completed' | 'failed';
|
||
message: string;
|
||
progress?: number;
|
||
params?: any;
|
||
result?: any;
|
||
error?: string;
|
||
};
|
||
|
||
type ZipJobsByTarget = Record<string, BackendJob>;
|
||
|
||
type PackageOptions = {
|
||
dicom: string[];
|
||
images: string[];
|
||
};
|
||
|
||
const DICOM_PACKAGE_OPTIONS = [
|
||
{ key: 'original', label: '原始序列 DICOM' },
|
||
{ key: 'hard_boundary', label: '硬边界 DICOM' },
|
||
{ key: 'gaussian_smooth', label: '高斯平滑 DICOM' },
|
||
{ key: 'soft_transition', label: '软过渡重建 DICOM' },
|
||
];
|
||
|
||
const IMAGE_PACKAGE_OPTIONS = [
|
||
{ key: 'comparison', label: '原始图片四状态过程对比图' },
|
||
{ key: 'original', label: '原始序列' },
|
||
{ key: 'hard_boundary', label: '硬边界' },
|
||
{ key: 'gaussian_smooth', label: '高斯平滑' },
|
||
{ key: 'soft_transition', label: '软过渡重建' },
|
||
];
|
||
|
||
const DEFAULT_PACKAGE_OPTIONS: PackageOptions = {
|
||
dicom: DICOM_PACKAGE_OPTIONS.map(option => option.key),
|
||
images: IMAGE_PACKAGE_OPTIONS.map(option => option.key),
|
||
};
|
||
|
||
const VIDEO_SOURCE_OPTIONS = [
|
||
{ key: 'hard_boundary', label: '硬边界', mode: 'hard_boundary' },
|
||
{ key: 'gaussian_smooth', label: '高斯平滑', mode: 'gaussian_smooth' },
|
||
{ key: 'soft_transition', label: '软过渡重建', mode: 'soft_transition' },
|
||
];
|
||
|
||
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;
|
||
date: string;
|
||
version: string;
|
||
status: 'processed' | 'pending';
|
||
size: number;
|
||
previewColor: string;
|
||
dicomPath: string;
|
||
fileCount?: number;
|
||
source?: 'seed' | 'upload';
|
||
};
|
||
|
||
type LibraryInfo = {
|
||
id: string;
|
||
patientId: string;
|
||
fileCount: number;
|
||
groups: {
|
||
title: string;
|
||
items: { label: string; value: string }[];
|
||
}[];
|
||
};
|
||
|
||
type LibraryViewerPreview = {
|
||
imageUrl: string;
|
||
index: number;
|
||
count: number;
|
||
plane: string;
|
||
window: string;
|
||
windowLabel: string;
|
||
patientId: string;
|
||
modelId?: string;
|
||
maskPixels?: number;
|
||
};
|
||
|
||
type StlModel = {
|
||
modelId: string;
|
||
name: string;
|
||
triangleCount: number;
|
||
};
|
||
|
||
type StoredDeformationJob = {
|
||
job: BackendJob;
|
||
progress: number;
|
||
renderVersion?: string;
|
||
};
|
||
|
||
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'
|
||
: `${window.location.protocol}//${window.location.hostname}:8787`;
|
||
|
||
function progressFromJob(job: BackendJob, fallback = 0) {
|
||
if (job.status === 'completed') return 100;
|
||
if (typeof job.progress === 'number') {
|
||
return Math.max(0, Math.min(100, job.progress));
|
||
}
|
||
return Math.max(0, Math.min(95, fallback));
|
||
}
|
||
|
||
function formatProgress(value: number) {
|
||
const bounded = Math.max(0, Math.min(100, value));
|
||
return Number.isInteger(bounded) ? `${bounded}` : bounded.toFixed(1);
|
||
}
|
||
|
||
function readStoredDeformationJob(): StoredDeformationJob | null {
|
||
if (typeof window === 'undefined') return null;
|
||
try {
|
||
const rawValue = window.localStorage.getItem(DEFORMATION_JOB_STORAGE_KEY);
|
||
if (!rawValue) return null;
|
||
const parsed = JSON.parse(rawValue) as StoredDeformationJob;
|
||
if (!parsed?.job?.id) return null;
|
||
if (parsed.renderVersion !== DEFORMATION_RESULT_RENDER_VERSION) {
|
||
window.localStorage.removeItem(DEFORMATION_JOB_STORAGE_KEY);
|
||
return null;
|
||
}
|
||
return {
|
||
job: parsed.job,
|
||
progress: Math.max(0, Math.min(100, parsed.progress || 0)),
|
||
};
|
||
} catch {
|
||
return 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 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('');
|
||
|
||
useEffect(() => {
|
||
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);
|
||
}, 120);
|
||
return () => window.clearTimeout(timer);
|
||
}, [sliceIndex]);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
setIsLoading(true);
|
||
setError('');
|
||
fetch(`${API_BASE}/api/library/preview?id=${encodeURIComponent(item.id)}&index=${requestedSliceIndex}`, {
|
||
signal: controller.signal
|
||
})
|
||
.then(async response => {
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || '预览生成失败');
|
||
setPreviewImage(`${API_BASE}${data.imageUrl}`);
|
||
(data.neighbors || []).forEach((url: string) => {
|
||
fetch(`${API_BASE}${url}`, { signal: controller.signal }).catch(() => {});
|
||
});
|
||
})
|
||
.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, requestedSliceIndex]);
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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;
|
||
const downloadedZipJobIds = useRef<Set<string>>(new Set());
|
||
|
||
// --- Authentication State ---
|
||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||
const [loginUsername, setLoginUsername] = useState('admin');
|
||
const [loginPassword, setLoginPassword] = useState('123456');
|
||
const [loginError, setLoginError] = useState('');
|
||
|
||
// --- App Flow State ---
|
||
const [currentPage, setCurrentPage] = useState<Page>('workspace');
|
||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||
|
||
useEffect(() => {
|
||
// Automatically collapse sidebar when on workspace page to give more space
|
||
if (currentPage === 'workspace') {
|
||
setIsSidebarCollapsed(true);
|
||
} else {
|
||
setIsSidebarCollapsed(false);
|
||
}
|
||
}, [currentPage]);
|
||
|
||
const [users, setUsers] = useState<User[]>([
|
||
{ id: '1', username: 'admin', password: '123456', role: 'admin', createdAt: '2024-01-01' },
|
||
{ id: '2', username: 'doctor1', password: 'password123', role: 'user', createdAt: '2024-05-01' }
|
||
]);
|
||
|
||
const [libraryData, setLibraryData] = useState<LibraryItem[]>([]);
|
||
const [selectedLibraryId, setSelectedLibraryId] = useState('');
|
||
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 [debouncedViewerSliceIndex, setDebouncedViewerSliceIndex] = useState<number | 'middle'>('middle');
|
||
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
|
||
const [isViewerLoading, setIsViewerLoading] = useState(false);
|
||
const [viewerError, setViewerError] = useState('');
|
||
const [stlModel, setStlModel] = useState<StlModel | null>(null);
|
||
const [isUploadingStl, setIsUploadingStl] = useState(false);
|
||
const [isModelSlicingEnabled, setIsModelSlicingEnabled] = useState(false);
|
||
const [modelClipStart, setModelClipStart] = useState(0);
|
||
const [modelClipEnd, setModelClipEnd] = useState(0);
|
||
const [modelStartPreview, setModelStartPreview] = useState<LibraryViewerPreview | null>(null);
|
||
const [modelEndPreview, setModelEndPreview] = useState<LibraryViewerPreview | null>(null);
|
||
const [isModelMaskLoading, setIsModelMaskLoading] = useState(false);
|
||
const [modelMaskError, setModelMaskError] = useState('');
|
||
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
const stlUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
// --- Simulation State (Workspace) ---
|
||
const [cervicalRotation, setCervicalRotation] = useState(14.5);
|
||
const [transitionWidth, setTransitionWidth] = useState(90);
|
||
const [previewGaussianSigma, setPreviewGaussianSigma] = useState(3);
|
||
const [previewAlgorithm, setPreviewAlgorithm] = useState('soft_transition');
|
||
const [showPreviewCutoffLine, setShowPreviewCutoffLine] = useState(true);
|
||
const [isSimulating, setIsSimulating] = useState(restoredDeformationJob?.job.status === 'running');
|
||
const [progress, setProgress] = useState(restoredDeformationJob ? progressFromJob(restoredDeformationJob.job, restoredDeformationJob.progress) : 0);
|
||
const [toastMessage, setToastMessage] = useState("");
|
||
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);
|
||
const [zipJobs, setZipJobs] = useState<ZipJobsByTarget>({});
|
||
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||
const [packageOptions, setPackageOptions] = useState<PackageOptions>(DEFAULT_PACKAGE_OPTIONS);
|
||
const [videoMaxAngle, setVideoMaxAngle] = useState(20);
|
||
const [videoDuration, setVideoDuration] = useState(6);
|
||
const [videoSource, setVideoSource] = useState('hard_boundary');
|
||
const [showVideoArrow, setShowVideoArrow] = useState(true);
|
||
|
||
// --- User Management Shared State ---
|
||
const [newUsername, setNewUsername] = useState('');
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [isChangingOwnPass, setIsChangingOwnPass] = useState(false);
|
||
const [pwChangeInput, setPwChangeInput] = useState('');
|
||
const [showAddUser, setShowAddUser] = useState(false);
|
||
const [activeUserMenu, setActiveUserMenu] = useState<string | null>(null);
|
||
const [isResettingDemo, setIsResettingDemo] = useState(false);
|
||
|
||
const selectedDataset = libraryData.find(item => item.id === selectedLibraryId) || libraryData[0];
|
||
const selectedInputDir = selectedDataset?.dicomPath || '';
|
||
const selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0];
|
||
const videoSourceInputDir = selectedInputDir;
|
||
const isVideoSourceReady = Boolean(videoSourceInputDir);
|
||
const viewerFrameCount = Math.max(1, viewerPreview?.count || modelStartPreview?.count || modelEndPreview?.count || libraryViewerItem?.fileCount || 1);
|
||
const clampedModelStart = Math.max(0, Math.min(viewerFrameCount - 1, modelClipStart));
|
||
const clampedModelEnd = Math.max(0, Math.min(viewerFrameCount - 1, modelClipEnd));
|
||
|
||
useEffect(() => {
|
||
if (!activeUserMenu) return;
|
||
|
||
const closeUserMenuOnOutsideClick = (event: PointerEvent) => {
|
||
const target = event.target as HTMLElement | null;
|
||
if (!target?.closest('[data-user-menu-root="true"]')) {
|
||
setActiveUserMenu(null);
|
||
}
|
||
};
|
||
|
||
document.addEventListener('pointerdown', closeUserMenuOnOutsideClick);
|
||
return () => document.removeEventListener('pointerdown', closeUserMenuOnOutsideClick);
|
||
}, [activeUserMenu]);
|
||
|
||
const showToast = (message: string) => {
|
||
setToastMessage(message);
|
||
setTimeout(() => setToastMessage(""), 3000);
|
||
};
|
||
|
||
const apiRequest = async (path: string, options?: RequestInit) => {
|
||
const response = await fetch(`${API_BASE}${path}`, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(options?.headers || {})
|
||
}
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || '本地 Python 后端请求失败');
|
||
}
|
||
return data;
|
||
};
|
||
|
||
const fileUrl = (path?: string) => path ? `${API_BASE}/api/file?path=${encodeURIComponent(path)}` : '';
|
||
|
||
const triggerDownload = async (path?: string, name?: string) => {
|
||
if (!path) return;
|
||
const directUrl = fileUrl(path);
|
||
try {
|
||
const response = await fetch(directUrl);
|
||
if (!response.ok) throw new Error('下载文件读取失败');
|
||
const blob = await response.blob();
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = objectUrl;
|
||
link.download = name || path.split('/').pop() || 'download';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||
return;
|
||
} catch {
|
||
showToast('浏览器安全策略阻止直接下载时,将尝试备用下载方式');
|
||
}
|
||
|
||
const link = document.createElement('a');
|
||
link.href = directUrl;
|
||
if (name) link.download = name;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
};
|
||
|
||
const downloadPreviewImage = () => {
|
||
if (!previewImage) return;
|
||
const link = document.createElement('a');
|
||
link.href = previewImage;
|
||
link.download = `quick_2d_preview_${previewAlgorithm}_${cervicalRotation.toFixed(1)}deg.png`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
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);
|
||
setIsSimulating(false);
|
||
setZipJobs({});
|
||
if (typeof window !== 'undefined') {
|
||
window.localStorage.removeItem(DEFORMATION_JOB_STORAGE_KEY);
|
||
}
|
||
};
|
||
|
||
const applyDeformationJob = (job: BackendJob) => {
|
||
setDeformationJob(job);
|
||
setProgress(current => progressFromJob(job, current));
|
||
setIsSimulating(job.status === 'running');
|
||
};
|
||
|
||
const handlePackageDownload = async (target: string, selectedPackageOptions?: PackageOptions) => {
|
||
if (!deformationJob?.id || zipJobs[target]?.status === 'running') return;
|
||
try {
|
||
const job = await apiRequest('/api/deformation/package', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
username: currentUser?.username || 'anonymous',
|
||
jobId: deformationJob.id,
|
||
target,
|
||
packageOptions: selectedPackageOptions
|
||
})
|
||
}) as BackendJob;
|
||
setZipJobs(current => ({ ...current, [target]: job }));
|
||
showToast(target === 'all' ? '四状态 ZIP 开始打包' : '本状态 ZIP 开始打包');
|
||
} catch (error) {
|
||
setZipJobs(current => ({
|
||
...current,
|
||
[target]: {
|
||
id: `failed_${Date.now()}`,
|
||
kind: 'zip',
|
||
status: 'failed',
|
||
message: '打包失败。',
|
||
error: (error as Error).message,
|
||
}
|
||
}));
|
||
showToast((error as Error).message);
|
||
}
|
||
};
|
||
|
||
const togglePackageOption = (group: keyof PackageOptions, key: string) => {
|
||
setPackageOptions(current => {
|
||
const exists = current[group].includes(key);
|
||
return {
|
||
...current,
|
||
[group]: exists
|
||
? current[group].filter(value => value !== key)
|
||
: [...current[group], key],
|
||
};
|
||
});
|
||
};
|
||
|
||
const setPackageGroupSelection = (group: keyof PackageOptions, keys: string[]) => {
|
||
setPackageOptions(current => ({ ...current, [group]: keys }));
|
||
};
|
||
|
||
const invertPackageGroupSelection = (group: keyof PackageOptions, allKeys: string[]) => {
|
||
setPackageOptions(current => ({
|
||
...current,
|
||
[group]: allKeys.filter(key => !current[group].includes(key)),
|
||
}));
|
||
};
|
||
|
||
const confirmPackageDownload = () => {
|
||
if (!packageOptions.dicom.length && !packageOptions.images.length) {
|
||
showToast('请至少选择一个打包内容');
|
||
return;
|
||
}
|
||
setIsPackageDialogOpen(false);
|
||
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 || [];
|
||
setLibraryData(items);
|
||
setSelectedLibraryId(current => items.some((item: LibraryItem) => item.id === current) ? current : items[0]?.id || '');
|
||
return items;
|
||
};
|
||
|
||
const resetDemoEnvironment = async () => {
|
||
if (isResettingDemo) return;
|
||
if (!confirm('确定恢复演示环境出厂设置吗?影像数据库将只保留 Ori_Head_CT,并清空当前任务结果。')) return;
|
||
|
||
setIsResettingDemo(true);
|
||
try {
|
||
const data = await apiRequest('/api/demo/reset', { method: 'POST' }) as { items?: LibraryItem[] };
|
||
const items = data.items?.length ? data.items : await loadLibrary();
|
||
setLibraryData(items);
|
||
setSelectedLibraryId(items[0]?.id || '');
|
||
setLibraryInfo(null);
|
||
setPreviewImage('');
|
||
setProcessPreviewImage('');
|
||
setVideoJob(null);
|
||
setZipJobs({});
|
||
clearDeformationTask();
|
||
setBackendOnline(true);
|
||
setBackendMessage('演示环境已恢复出厂设置');
|
||
showToast('已恢复演示环境出厂设置');
|
||
} catch (error) {
|
||
showToast((error as Error).message);
|
||
} finally {
|
||
setIsResettingDemo(false);
|
||
}
|
||
};
|
||
|
||
const refreshBackendDefaults = async () => {
|
||
try {
|
||
const data = await apiRequest('/api/defaults');
|
||
await loadLibrary();
|
||
setBackendOnline(true);
|
||
setBackendMessage('本地 Python 后端已连接');
|
||
} catch (error) {
|
||
setBackendOnline(false);
|
||
setBackendMessage('未连接:请先运行 python web_backend.py');
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
refreshBackendDefaults();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isLoggedIn || !currentUser?.username) return;
|
||
|
||
let isActive = true;
|
||
const restoreUserDeformationJob = async () => {
|
||
try {
|
||
const data = await apiRequest(
|
||
`/api/user/job?username=${encodeURIComponent(currentUser.username)}&kind=deformation`
|
||
) as { job?: BackendJob | null };
|
||
if (!isActive || !data.job) return;
|
||
applyDeformationJob(data.job);
|
||
if (data.job.status === 'running') {
|
||
showToast('已恢复当前账号的四状态任务进度');
|
||
}
|
||
} catch {
|
||
// 账号任务恢复失败不影响正常使用工作站。
|
||
}
|
||
};
|
||
|
||
restoreUserDeformationJob();
|
||
return () => {
|
||
isActive = false;
|
||
};
|
||
}, [isLoggedIn, currentUser?.username]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
if (!deformationJob) {
|
||
window.localStorage.removeItem(DEFORMATION_JOB_STORAGE_KEY);
|
||
return;
|
||
}
|
||
|
||
window.localStorage.setItem(
|
||
DEFORMATION_JOB_STORAGE_KEY,
|
||
JSON.stringify({
|
||
job: deformationJob,
|
||
progress: deformationJob.status === 'completed' ? 100 : progress,
|
||
renderVersion: DEFORMATION_RESULT_RENDER_VERSION,
|
||
})
|
||
);
|
||
}, [deformationJob, progress]);
|
||
|
||
useEffect(() => {
|
||
if (!deformationJob || deformationJob.status !== 'running') return;
|
||
|
||
let isActive = true;
|
||
const pollDeformationJob = async () => {
|
||
try {
|
||
const job = await apiRequest(`/api/job?id=${deformationJob.id}`) as BackendJob;
|
||
if (!isActive) return;
|
||
setDeformationJob(job);
|
||
setProgress(value => progressFromJob(job, Math.min(value + 8, 95)));
|
||
if (job.status === 'completed') {
|
||
setIsSimulating(false);
|
||
showToast('四状态 DICOM 与过程图已生成');
|
||
}
|
||
if (job.status === 'failed') {
|
||
setIsSimulating(false);
|
||
showToast(job.error || '形变任务失败');
|
||
}
|
||
} catch (error) {
|
||
if (!isActive) return;
|
||
setIsSimulating(false);
|
||
setDeformationJob(job => job ? { ...job, status: 'failed', error: (error as Error).message } : null);
|
||
}
|
||
};
|
||
|
||
pollDeformationJob();
|
||
const timer = setInterval(pollDeformationJob, 1500);
|
||
return () => {
|
||
isActive = false;
|
||
clearInterval(timer);
|
||
};
|
||
}, [deformationJob?.id, deformationJob?.status]);
|
||
|
||
useEffect(() => {
|
||
if (!videoJob || videoJob.status !== 'running') return;
|
||
const timer = setInterval(async () => {
|
||
try {
|
||
const job = await apiRequest(`/api/job?id=${videoJob.id}`) as BackendJob;
|
||
setVideoJob(job);
|
||
if (job.status === 'completed') showToast('仰头变化视频已生成');
|
||
if (job.status === 'failed') showToast(job.error || '视频生成失败');
|
||
} catch (error) {
|
||
setVideoJob(job => job ? { ...job, status: 'failed', error: (error as Error).message } : null);
|
||
}
|
||
}, 1500);
|
||
return () => clearInterval(timer);
|
||
}, [videoJob?.id, videoJob?.status]);
|
||
|
||
useEffect(() => {
|
||
const runningEntries = (Object.entries(zipJobs) as [string, BackendJob][])
|
||
.filter(([, job]) => job.status === 'running');
|
||
if (!runningEntries.length) return;
|
||
|
||
let isActive = true;
|
||
const pollZipJobs = async () => {
|
||
await Promise.all(runningEntries.map(async ([target, currentJob]) => {
|
||
try {
|
||
const job = await apiRequest(`/api/job?id=${currentJob.id}`) as BackendJob;
|
||
if (!isActive) return;
|
||
const zipProgressStep = target === 'all' ? 0.4 : 2;
|
||
const displayJob = job.status === 'running'
|
||
? {
|
||
...job,
|
||
progress: Math.min(
|
||
95,
|
||
Math.max(progressFromJob(currentJob, 10) + zipProgressStep, progressFromJob(job, 10))
|
||
)
|
||
}
|
||
: job;
|
||
setZipJobs(current => ({ ...current, [target]: displayJob }));
|
||
if (job.status === 'completed') {
|
||
if (!downloadedZipJobIds.current.has(job.id)) {
|
||
downloadedZipJobIds.current.add(job.id);
|
||
await triggerDownload(job.result?.file?.path, job.result?.file?.name);
|
||
showToast('ZIP 打包完成,已开始下载');
|
||
}
|
||
}
|
||
if (job.status === 'failed') {
|
||
showToast(job.error || 'ZIP 打包失败');
|
||
}
|
||
} catch (error) {
|
||
if (!isActive) return;
|
||
setZipJobs(current => ({
|
||
...current,
|
||
[target]: { ...currentJob, status: 'failed', error: (error as Error).message }
|
||
}));
|
||
}
|
||
}));
|
||
};
|
||
|
||
pollZipJobs();
|
||
const timer = setInterval(pollZipJobs, 1500);
|
||
return () => {
|
||
isActive = false;
|
||
clearInterval(timer);
|
||
};
|
||
}, [zipJobs]);
|
||
|
||
const handleLogin = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const user = users.find(u => u.username === loginUsername && u.password === loginPassword);
|
||
if (user) {
|
||
setIsLoggedIn(true);
|
||
setCurrentUser(user);
|
||
setLoginError('');
|
||
showToast(`欢迎, ${user.username}`);
|
||
} else {
|
||
setLoginError('用户名或密码错误');
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
setIsLoggedIn(false);
|
||
setCurrentUser(null);
|
||
setCurrentPage('workspace');
|
||
};
|
||
|
||
const addUser = () => {
|
||
if (!newUsername || !newPassword) return;
|
||
if (users.find(u => u.username === newUsername)) {
|
||
showToast('用户名已存在');
|
||
return;
|
||
}
|
||
const newUser: User = {
|
||
id: Date.now().toString(),
|
||
username: newUsername,
|
||
password: newPassword,
|
||
role: 'user',
|
||
createdAt: new Date().toISOString().split('T')[0]
|
||
};
|
||
setUsers([...users, newUser]);
|
||
setNewUsername('');
|
||
setNewPassword('');
|
||
setShowAddUser(false);
|
||
showToast('用户创建成功');
|
||
};
|
||
|
||
const uploadImage = () => {
|
||
folderUploadInputRef.current?.click();
|
||
};
|
||
|
||
const uploadZip = () => {
|
||
zipUploadInputRef.current?.click();
|
||
};
|
||
|
||
const handleLibraryFilesSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const selectedFiles: File[] = [];
|
||
const fileList = event.currentTarget.files;
|
||
if (fileList) {
|
||
for (let index = 0; index < fileList.length; index += 1) {
|
||
const file = fileList.item(index);
|
||
if (file) selectedFiles.push(file);
|
||
}
|
||
}
|
||
const files = selectedFiles.filter(file => {
|
||
const name = file.name.toLowerCase();
|
||
return name.endsWith('.dcm') || name.endsWith('.zip');
|
||
});
|
||
event.currentTarget.value = '';
|
||
if (!files.length) {
|
||
showToast('请选择 .dcm 文件或 .zip 压缩包');
|
||
return;
|
||
}
|
||
|
||
setIsUploadingDicom(true);
|
||
try {
|
||
const formData = new FormData();
|
||
const firstRelativePath = (files[0] as any).webkitRelativePath || files[0].name;
|
||
const patientId = firstRelativePath.includes('/')
|
||
? firstRelativePath.split('/')[0]
|
||
: files[0].name.replace(/\.(dcm|zip)$/i, '') || `WEB_${new Date().getTime()}`;
|
||
formData.append('patientId', patientId);
|
||
files.forEach(file => {
|
||
formData.append('files', file, (file as any).webkitRelativePath || file.name);
|
||
});
|
||
|
||
const response = await fetch(`${API_BASE}/api/library/upload`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || '上传失败');
|
||
|
||
const items = await loadLibrary();
|
||
setSelectedLibraryId(data.id || items[0]?.id || '');
|
||
setCurrentPage('workspace');
|
||
setPreviewImage('');
|
||
setProcessPreviewImage('');
|
||
clearDeformationTask();
|
||
showToast(`已上传 ${data.fileCount || files.length} 张 DICOM`);
|
||
} catch (error) {
|
||
const message = (error as Error).message === 'Failed to fetch'
|
||
? `无法连接 Python 后端:${API_BASE}`
|
||
: (error as Error).message;
|
||
showToast(message);
|
||
} finally {
|
||
setIsUploadingDicom(false);
|
||
}
|
||
};
|
||
|
||
const deleteImage = async (id: string) => {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/library?id=${encodeURIComponent(id)}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || '删除失败');
|
||
setLibraryData(data.items || []);
|
||
if (selectedLibraryId === id) {
|
||
setSelectedLibraryId(data.items?.[0]?.id || '');
|
||
setPreviewImage('');
|
||
setProcessPreviewImage('');
|
||
clearDeformationTask();
|
||
}
|
||
showToast('影像已删除');
|
||
} catch (error) {
|
||
showToast((error as Error).message);
|
||
}
|
||
};
|
||
|
||
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 openLibraryViewer = (item: LibraryItem) => {
|
||
setLibraryViewerItem(item);
|
||
setViewerPlane('coronal');
|
||
setViewerWindow('default');
|
||
setViewerSliceIndex('middle');
|
||
setDebouncedViewerSliceIndex('middle');
|
||
setViewerPreview(null);
|
||
setViewerError('');
|
||
setIsModelSlicingEnabled(false);
|
||
setModelClipStart(0);
|
||
setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1));
|
||
setModelStartPreview(null);
|
||
setModelEndPreview(null);
|
||
setModelMaskError('');
|
||
};
|
||
|
||
const closeLibraryViewer = () => {
|
||
setLibraryViewerItem(null);
|
||
setViewerPreview(null);
|
||
setViewerError('');
|
||
setIsViewerLoading(false);
|
||
setIsModelSlicingEnabled(false);
|
||
setModelStartPreview(null);
|
||
setModelEndPreview(null);
|
||
setModelMaskError('');
|
||
};
|
||
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(() => {
|
||
setDebouncedViewerSliceIndex(viewerSliceIndex);
|
||
}, typeof viewerSliceIndex === 'number' ? 180 : 0);
|
||
return () => window.clearTimeout(timer);
|
||
}, [viewerSliceIndex]);
|
||
|
||
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(debouncedViewerSliceIndex))}&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, debouncedViewerSliceIndex, viewerWindow]);
|
||
|
||
useEffect(() => {
|
||
if (!libraryViewerItem || !viewerPreview?.count) return;
|
||
setModelClipStart(current => Math.max(0, Math.min(viewerPreview.count - 1, current)));
|
||
setModelClipEnd(current => {
|
||
if (current <= 0) return viewerPreview.count - 1;
|
||
return Math.max(0, Math.min(viewerPreview.count - 1, current));
|
||
});
|
||
}, [libraryViewerItem?.id, viewerPreview?.count]);
|
||
|
||
useEffect(() => {
|
||
if (!libraryViewerItem || !isModelSlicingEnabled || !stlModel) return;
|
||
|
||
const controller = new AbortController();
|
||
const makeUrl = (index: number) => (
|
||
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${index}&window=${encodeURIComponent(viewerWindow)}&modelId=${encodeURIComponent(stlModel.modelId)}`
|
||
);
|
||
|
||
setIsModelMaskLoading(true);
|
||
setModelMaskError('');
|
||
Promise.all([
|
||
fetch(makeUrl(clampedModelStart), { signal: controller.signal }).then(async response => {
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || '起点帧 mask 生成失败');
|
||
return data as LibraryViewerPreview;
|
||
}),
|
||
fetch(makeUrl(clampedModelEnd), { signal: controller.signal }).then(async response => {
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || '终点帧 mask 生成失败');
|
||
return data as LibraryViewerPreview;
|
||
}),
|
||
])
|
||
.then(([startPreview, endPreview]) => {
|
||
setModelStartPreview(startPreview);
|
||
setModelEndPreview(endPreview);
|
||
})
|
||
.catch(error => {
|
||
if ((error as Error).name !== 'AbortError') setModelMaskError((error as Error).message);
|
||
})
|
||
.finally(() => {
|
||
if (!controller.signal.aborted) setIsModelMaskLoading(false);
|
||
});
|
||
|
||
return () => controller.abort();
|
||
}, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, viewerPlane, viewerWindow, clampedModelStart, clampedModelEnd]);
|
||
|
||
const uploadStlModel = () => {
|
||
stlUploadInputRef.current?.click();
|
||
};
|
||
|
||
const handleStlSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = '';
|
||
if (!file) return;
|
||
setIsUploadingStl(true);
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/model/upload`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/sla',
|
||
'x-file-name': encodeURIComponent(file.name),
|
||
},
|
||
body: await file.arrayBuffer(),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || 'STL 上传失败');
|
||
setStlModel(data);
|
||
setIsModelSlicingEnabled(true);
|
||
showToast(`已载入 STL:${data.triangleCount || 0} 个三角面`);
|
||
} catch (error) {
|
||
showToast((error as Error).message);
|
||
} finally {
|
||
setIsUploadingStl(false);
|
||
}
|
||
};
|
||
|
||
const changePassword = (userId: string, newPass: string) => {
|
||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||
setPwChangeInput('');
|
||
setIsChangingOwnPass(false);
|
||
showToast('密码更新成功');
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (currentPage !== 'workspace' || !selectedInputDir) return;
|
||
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(async () => {
|
||
setIsPreviewLoading(true);
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/preview`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
inputDir: selectedInputDir,
|
||
angleDegrees: cervicalRotation,
|
||
transitionWidth,
|
||
gaussianSigma: previewGaussianSigma,
|
||
mode: previewAlgorithm,
|
||
showCutoffLine: showPreviewCutoffLine
|
||
}),
|
||
signal: controller.signal
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.error || '预览生成失败');
|
||
setPreviewImage(data.image);
|
||
setProcessPreviewImage(data.processImage || '');
|
||
setBackendOnline(true);
|
||
setBackendMessage('预览已自动更新');
|
||
} catch (error) {
|
||
if ((error as Error).name !== 'AbortError') {
|
||
setBackendOnline(false);
|
||
setBackendMessage((error as Error).message);
|
||
}
|
||
} finally {
|
||
if (!controller.signal.aborted) setIsPreviewLoading(false);
|
||
}
|
||
}, 300);
|
||
|
||
return () => {
|
||
controller.abort();
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [currentPage, selectedInputDir, cervicalRotation, transitionWidth, previewGaussianSigma, previewAlgorithm, showPreviewCutoffLine]);
|
||
|
||
const handleRunSimulation = async () => {
|
||
if (isSimulating) return;
|
||
setIsSimulating(true);
|
||
setProgress(0);
|
||
try {
|
||
const job = await apiRequest('/api/deformation', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
username: currentUser?.username || 'anonymous',
|
||
inputDir: selectedInputDir,
|
||
angleDegrees: cervicalRotation,
|
||
transitionWidth
|
||
})
|
||
}) as BackendJob;
|
||
applyDeformationJob(job);
|
||
setBackendOnline(true);
|
||
setBackendMessage('run_deformation 任务已提交');
|
||
showToast('形变任务已提交');
|
||
} catch (error) {
|
||
setIsSimulating(false);
|
||
setBackendOnline(false);
|
||
showToast((error as Error).message);
|
||
}
|
||
};
|
||
|
||
const handleGenerateVideo = async () => {
|
||
if (videoJob?.status === 'running') return;
|
||
if (!videoSourceInputDir) {
|
||
showToast('请选择影像库数据源');
|
||
return;
|
||
}
|
||
try {
|
||
const job = await apiRequest('/api/video', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
inputDir: videoSourceInputDir,
|
||
maxAngle: videoMaxAngle,
|
||
durationSeconds: videoDuration,
|
||
showArrow: showVideoArrow,
|
||
mode: selectedVideoSource.mode,
|
||
})
|
||
}) as BackendJob;
|
||
setVideoJob(job);
|
||
setBackendOnline(true);
|
||
setBackendMessage(`generate_head_extension_video.py / ${selectedVideoSource.label} 任务已提交`);
|
||
showToast(`${selectedVideoSource.label} 视频任务已提交`);
|
||
} catch (error) {
|
||
setBackendOnline(false);
|
||
showToast((error as Error).message);
|
||
}
|
||
};
|
||
|
||
// --- Login Screen ---
|
||
if (!isLoggedIn) {
|
||
return (
|
||
<div className="w-screen h-screen bg-[#f1f5f9] flex items-center justify-center">
|
||
<div className="w-[450px] bg-white p-10 rounded-2xl shadow-xl border border-slate-200">
|
||
<div className="flex flex-col items-center mb-8">
|
||
<div className="w-20 h-20 rounded-2xl flex items-center justify-center mb-4 overflow-hidden">
|
||
<img src="/logo_square.png" alt="头颈CT变形平台" className="w-full h-full object-contain" />
|
||
</div>
|
||
<h1 className="text-xl font-bold text-slate-800 text-center">颅颈特定体位CT影像智慧变形平台</h1>
|
||
<p className="text-slate-400 text-sm mt-2">头部CT变形平台</p>
|
||
</div>
|
||
<form onSubmit={handleLogin} className="space-y-4">
|
||
<div className="space-y-1">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase ml-1">用户名</label>
|
||
<input
|
||
type="text" placeholder="admin" value={loginUsername}
|
||
onChange={e => setLoginUsername(e.target.value)}
|
||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase ml-1">密码</label>
|
||
<input
|
||
type="password" placeholder="123456" value={loginPassword}
|
||
onChange={e => setLoginPassword(e.target.value)}
|
||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||
/>
|
||
</div>
|
||
{loginError && <p className="text-red-500 text-xs text-center font-medium">{loginError}</p>}
|
||
<button type="submit" className="w-full py-3.5 bg-blue-600 text-white rounded-lg font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20 active:scale-95">登 录</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- Sidebar Component ---
|
||
const NavItem = ({ icon: Icon, label, page }: { icon: any, label: string, page: Page }) => (
|
||
<button
|
||
onClick={() => setCurrentPage(page)}
|
||
title={isSidebarCollapsed ? label : ""}
|
||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group ${
|
||
currentPage === page ? 'bg-blue-600 text-white shadow-lg' : 'text-slate-600 hover:bg-slate-100'
|
||
} ${isSidebarCollapsed ? 'justify-center px-0' : ''}`}
|
||
>
|
||
<Icon size={20} className={isSidebarCollapsed ? 'shrink-0' : ''} />
|
||
{!isSidebarCollapsed && <span className="flex-1 text-left text-sm font-medium">{label}</span>}
|
||
{isSidebarCollapsed && (
|
||
<div className="absolute left-16 bg-slate-800 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||
{label}
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
|
||
return (
|
||
<div className="w-screen h-screen bg-[#f8fafc] text-[#1e293b] flex overflow-hidden">
|
||
|
||
{/* Sidebar */}
|
||
<aside className={`${isSidebarCollapsed ? 'w-20' : 'w-72'} bg-white border-r p-6 flex flex-col shrink-0 transition-all duration-300 ease-in-out`}>
|
||
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center' : 'gap-3'} mb-10 transition-all`}>
|
||
<img src="/logo_square.png" alt="头颈CT变形平台" className="w-9 h-9 object-contain shrink-0" />
|
||
{!isSidebarCollapsed && <h2 className="text-base font-bold tracking-tight whitespace-nowrap overflow-hidden">头颈CT变形平台</h2>}
|
||
</div>
|
||
|
||
<nav className="flex-1 space-y-2">
|
||
<NavItem icon={LayoutDashboard} label="总体概况" page="overview" />
|
||
<NavItem icon={Database} label="数据影像库" page="library" />
|
||
<NavItem icon={ImageIcon} label="影像变换工作站" page="workspace" />
|
||
<NavItem icon={Users} label="系统管理工作区" page="users" />
|
||
</nav>
|
||
|
||
<div className="mt-auto pt-6 border-t font-medium">
|
||
<div className={`flex items-center ${isSidebarCollapsed ? 'justify-center flex-col gap-2' : 'gap-3 p-3 bg-slate-50 rounded-xl mb-3'} transition-all`}>
|
||
<div className="w-8 h-8 rounded-full bg-blue-600 border-2 border-white shadow-md flex items-center justify-center text-white text-xs shrink-0">{currentUser?.username[0].toUpperCase()}</div>
|
||
{!isSidebarCollapsed && (
|
||
<div className="flex-1 truncate">
|
||
<p className="text-xs font-bold leading-none text-slate-800">{currentUser?.username}</p>
|
||
<p className="text-[9px] text-slate-400 mt-1 uppercase tracking-widest">{currentUser?.role}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={handleLogout}
|
||
title="退出登录"
|
||
aria-label="退出登录"
|
||
className={`w-full flex items-center gap-3 text-sm transition-all ${
|
||
isSidebarCollapsed
|
||
? 'justify-center w-10 h-10 mx-auto rounded-2xl bg-slate-50 text-slate-500 hover:bg-red-50 hover:text-red-500'
|
||
: 'px-4 py-3 text-slate-400 hover:text-red-500'
|
||
}`}
|
||
>
|
||
<LogOut size={isSidebarCollapsed ? 20 : 18} className="shrink-0" />
|
||
{!isSidebarCollapsed && <span>退出登录</span>}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Main Content */}
|
||
<main className="flex-1 flex flex-col overflow-hidden bg-slate-50">
|
||
<header className="h-16 bg-white border-b px-8 flex items-center justify-between shadow-sm z-10 shrink-0">
|
||
<div className="flex items-center gap-4">
|
||
{isSidebarCollapsed && (
|
||
<button
|
||
onClick={() => setIsSidebarCollapsed(false)}
|
||
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 transition-colors"
|
||
>
|
||
<ChevronRight size={18} />
|
||
</button>
|
||
)}
|
||
<h3 className="font-bold text-slate-800">
|
||
{currentPage === 'overview' && '控制台总览'}
|
||
{currentPage === 'library' && '数据影像库'}
|
||
{currentPage === 'workspace' && '影像变换工作站'}
|
||
{currentPage === 'users' && '系统管理工作区'}
|
||
</h3>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex-1 p-8 overflow-y-auto">
|
||
|
||
{/* --- WORKSPACE VIEW --- */}
|
||
{currentPage === 'workspace' && (
|
||
<div className="min-h-full flex gap-6 animate-in fade-in zoom-in-95 duration-300">
|
||
<div className="w-[380px] space-y-4 shrink-0">
|
||
<div className={`flex items-center gap-3 text-xs font-bold px-4 py-3 rounded-2xl border ${backendOnline ? 'bg-green-50 text-green-700 border-green-100' : 'bg-amber-50 text-amber-700 border-amber-100'}`}>
|
||
{backendOnline ? <Server size={16} /> : <AlertCircle size={16} />}
|
||
<span>{backendMessage}</span>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border shadow-sm space-y-4">
|
||
<div className="space-y-3">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">影像库数据源</label>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between text-[10px] font-bold text-slate-500">
|
||
<span className="flex items-center gap-2"><Database size={12} /> 当前 DICOM 数据集</span>
|
||
<button onClick={() => setCurrentPage('library')} className="text-blue-600 hover:text-blue-800">切换</button>
|
||
</div>
|
||
<select
|
||
value={selectedDataset?.id || ''}
|
||
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"
|
||
>
|
||
{libraryData.map(item => (
|
||
<option key={item.id} value={item.id}>{item.patientId} · {item.fileCount || 0} DICOM</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border shadow-sm space-y-5">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block">仰头数据处理及结果输出</label>
|
||
|
||
<div className="space-y-4 pt-2">
|
||
<div>
|
||
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span>快速预览算法</span><span className="text-blue-600 font-mono">{PREVIEW_ALGORITHM_OPTIONS.find(option => option.key === previewAlgorithm)?.label}</span></div>
|
||
<select
|
||
value={previewAlgorithm}
|
||
onChange={event => setPreviewAlgorithm(event.target.value)}
|
||
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"
|
||
>
|
||
{PREVIEW_ALGORITHM_OPTIONS.map(option => (
|
||
<option key={option.key} value={option.key}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span>仰头角度</span><span className="text-blue-600 font-mono">{cervicalRotation.toFixed(1)}°</span></div>
|
||
<input type="range" min="0" max="45" step="0.5" value={cervicalRotation} onChange={e => setCervicalRotation(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" />
|
||
</div>
|
||
{previewAlgorithm === 'gaussian_smooth' && (
|
||
<div>
|
||
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span>高斯平滑强度</span><span className="text-blue-600 font-mono">{previewGaussianSigma.toFixed(1)}</span></div>
|
||
<input type="range" min="1" max="12" step="0.5" value={previewGaussianSigma} onChange={e => setPreviewGaussianSigma(parseFloat(e.target.value))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" />
|
||
</div>
|
||
)}
|
||
{previewAlgorithm === 'soft_transition' && (
|
||
<div>
|
||
<div className="flex justify-between text-xs font-bold mb-2 text-slate-600"><span>软过渡宽度</span><span className="text-blue-600 font-mono">{transitionWidth}</span></div>
|
||
<input type="range" min="50" max="160" step="10" value={transitionWidth} onChange={e => setTransitionWidth(parseInt(e.target.value, 10))} className="w-full h-1.5 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-blue-600 opacity-80 hover:opacity-100 transition-opacity" />
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => setShowPreviewCutoffLine(value => !value)}
|
||
className={`w-full py-2.5 rounded-xl text-xs font-black transition-all flex items-center justify-center gap-2 border ${
|
||
showPreviewCutoffLine
|
||
? 'bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100'
|
||
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100'
|
||
}`}
|
||
>
|
||
{showPreviewCutoffLine ? <Eye size={14} /> : <EyeOff size={14} />}
|
||
{showPreviewCutoffLine ? '隐藏预览分界线' : '显示预览分界线'}
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
<button onClick={handleRunSimulation} disabled={isSimulating || !selectedInputDir} className="w-full py-3 bg-blue-600 text-white rounded-xl text-xs font-bold hover:bg-blue-700 transition-all flex items-center justify-center gap-2 active:scale-95 disabled:opacity-50">
|
||
<PlayIcon className="fill-white" size={14} /> {isSimulating ? '生成中' : '四状态输出'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="pt-3 border-t">
|
||
<div className="flex justify-between text-[10px] text-slate-400 font-bold uppercase mb-2">
|
||
<span>{deformationJob?.message || 'head_extension_app.py / run_deformation'}</span>
|
||
<span>{deformationJob?.status === 'completed' ? '100%' : `${progress}%`}</span>
|
||
</div>
|
||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||
<div className={`h-full ${deformationJob?.status === 'failed' ? 'bg-red-500' : 'bg-blue-600'} transition-all`} style={{ width: `${deformationJob?.status === 'completed' ? 100 : progress}%` }} />
|
||
</div>
|
||
{deformationJob?.error && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{deformationJob.error}</p>}
|
||
{deformationJob?.status === 'completed' && deformationJob.result?.outputs && (
|
||
<button
|
||
onClick={() => {
|
||
setPackageOptions(DEFAULT_PACKAGE_OPTIONS);
|
||
setIsPackageDialogOpen(true);
|
||
}}
|
||
disabled={zipJobs.all?.status === 'running'}
|
||
className="mt-3 w-full py-3 bg-green-600 text-white rounded-xl text-xs font-bold hover:bg-green-700 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-wait"
|
||
>
|
||
<Download size={14} />
|
||
{zipJobs.all?.status === 'running'
|
||
? `四状态 ZIP 打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
|
||
: '下载结果'}
|
||
</button>
|
||
)}
|
||
{zipJobs.all?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJobs.all.error || '四状态 ZIP 打包失败'}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border shadow-sm space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">视频生成</label>
|
||
<Film size={16} className="text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500">
|
||
<span>视频形变方式</span>
|
||
{!isVideoSourceReady && <span className="text-amber-500">请选择影像库数据源</span>}
|
||
</div>
|
||
<select
|
||
value={videoSource}
|
||
onChange={event => {
|
||
setVideoSource(event.target.value);
|
||
setVideoJob(null);
|
||
}}
|
||
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"
|
||
>
|
||
{VIDEO_SOURCE_OPTIONS.map(option => (
|
||
<option key={option.key} value={option.key}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span>最大角度</span><span>{videoMaxAngle}°</span></div>
|
||
<input type="range" min="5" max="45" step="1" value={videoMaxAngle} onChange={e => setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" />
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between text-[10px] font-bold mb-2 text-slate-500"><span>时长</span><span>{videoDuration}s</span></div>
|
||
<input type="range" min="3" max="12" step="1" value={videoDuration} onChange={e => setVideoDuration(parseInt(e.target.value, 10))} className="w-full accent-blue-600" />
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowVideoArrow(value => !value)}
|
||
className={`w-full py-2.5 rounded-xl text-xs font-black transition-all flex items-center justify-center gap-2 border ${
|
||
showVideoArrow
|
||
? 'bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100'
|
||
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100'
|
||
}`}
|
||
>
|
||
{showVideoArrow ? <Eye size={14} /> : <EyeOff size={14} />}
|
||
{showVideoArrow ? '隐藏视频黄色箭头' : '显示视频黄色箭头'}
|
||
</button>
|
||
<button onClick={handleGenerateVideo} disabled={videoJob?.status === 'running' || !isVideoSourceReady} className="w-full py-3 bg-slate-100 text-slate-700 rounded-xl text-xs font-bold hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center gap-2 disabled:opacity-50">
|
||
<RefreshCw size={14} className={videoJob?.status === 'running' ? 'animate-spin' : ''} /> {videoJob?.status === 'running' ? '正在生成视频' : `生成${selectedVideoSource.label}视频`}
|
||
</button>
|
||
{videoJob?.status === 'completed' && videoJob.result?.video?.path ? (
|
||
<div className="space-y-3">
|
||
<div className="bg-slate-950 rounded-xl overflow-hidden border border-slate-800">
|
||
<video
|
||
src={fileUrl(videoJob.result.video.path)}
|
||
controls
|
||
preload="metadata"
|
||
className="w-full aspect-video bg-black"
|
||
/>
|
||
</div>
|
||
<a
|
||
href={fileUrl(videoJob.result.video.path)}
|
||
download={videoJob.result.video.name}
|
||
className="w-full py-3 bg-green-600 text-white rounded-xl text-xs font-bold hover:bg-green-700 transition-all flex items-center justify-center gap-2"
|
||
>
|
||
<Download size={14} /> 下载视频 MP4
|
||
</a>
|
||
</div>
|
||
) : (
|
||
<p className="text-[10px] text-slate-400 font-medium break-all">{videoJob?.message || 'generate_head_extension_video.py / generate_video'}</p>
|
||
)}
|
||
{videoJob?.error && <p className="text-[10px] text-red-500 font-bold break-all">{videoJob.error}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 space-y-6 pb-8">
|
||
<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">快速 2D 预览</h4>
|
||
<p className="text-[10px] text-slate-400 font-bold mt-1">对应 head_extension_app.py 的 preview_deform_2d</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<div className="text-right">
|
||
<span className="text-[10px] font-mono text-slate-400">{cervicalRotation.toFixed(1)} DEG</span>
|
||
{isPreviewLoading && <p className="text-[9px] font-bold text-blue-500 mt-1">自动更新中...</p>}
|
||
</div>
|
||
<button
|
||
onClick={downloadPreviewImage}
|
||
disabled={!previewImage}
|
||
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="下载当前快速 2D 预览图"
|
||
>
|
||
<Download size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="h-[360px] bg-slate-950 flex items-center justify-center">
|
||
{previewImage ? (
|
||
<img src={previewImage} className="max-w-full max-h-full object-contain" />
|
||
) : (
|
||
<div className="text-center text-slate-500">
|
||
<ImageIcon size={42} className="mx-auto mb-3 opacity-40" />
|
||
<p className="text-xs font-bold">{isPreviewLoading ? '正在自动生成预览...' : '选择影像库数据后自动生成预览'}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
<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))}%`
|
||
: '下载结果'}
|
||
</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">
|
||
<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" />
|
||
) : (
|
||
<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>
|
||
)}
|
||
|
||
{/* --- OVERVIEW VIEW --- */}
|
||
{currentPage === 'overview' && (
|
||
<div className="flex flex-col gap-10 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||
<div className="grid grid-cols-4 gap-6">
|
||
{[
|
||
{ l: '已存储影像数', v: libraryData.length, i: Database, c: 'blue' },
|
||
{ l: '存储容量占用', v: `${(libraryData.reduce((acc, curr) => acc + curr.size, 0) / 1024).toFixed(2)} GB`, i: ImageIcon, c: 'indigo' },
|
||
{ l: '待处理任务', v: libraryData.filter(i => i.status === 'pending').length, i: Activity, c: 'amber' },
|
||
{ l: '已完成计算', v: libraryData.filter(i => i.status === 'processed').length, i: Check, c: 'green' }
|
||
].map((s, i) => (
|
||
<div key={i} className="bg-white p-7 rounded-[2.5rem] border flex items-center gap-5 shadow-sm hover:shadow-md transition-shadow">
|
||
<div className={`w-14 h-14 bg-${s.c}-50 rounded-2xl flex items-center justify-center text-${s.c}-600 shrink-0`}><s.i size={28} /></div>
|
||
<div><p className="text-[11px] text-slate-400 font-bold uppercase mb-1">{s.l}</p><p className="text-2xl font-black text-slate-800">{s.v}</p></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="bg-white p-10 rounded-[3rem] border shadow-sm">
|
||
<div className="flex justify-between items-center mb-8">
|
||
<h4 className="font-bold text-slate-800 text-lg">近期活跃影像流</h4>
|
||
<button onClick={() => setCurrentPage('library')} className="text-xs font-bold text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-xl transition-all">查看全部影像库</button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{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">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className={`text-[9px] font-black px-3 py-1 rounded-full uppercase tracking-widest ${item.status === 'processed' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
|
||
{item.status === 'processed' ? 'COMPLETED' : 'QUEUEING'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* --- LIBRARY VIEW (Card Grid) --- */}
|
||
{currentPage === 'library' && (
|
||
<div className="space-y-10 animate-in fade-in duration-500">
|
||
<input
|
||
ref={folderUploadInputRef}
|
||
type="file"
|
||
multiple
|
||
accept=".dcm,application/dicom"
|
||
className="hidden"
|
||
onChange={handleLibraryFilesSelected}
|
||
{...({ webkitdirectory: '', directory: '' } as any)}
|
||
/>
|
||
<input
|
||
ref={zipUploadInputRef}
|
||
type="file"
|
||
accept=".zip,application/zip,application/x-zip-compressed"
|
||
className="hidden"
|
||
onChange={handleLibraryFilesSelected}
|
||
/>
|
||
<div className="flex justify-between items-center">
|
||
<div>
|
||
<h2 className="text-2xl font-black text-slate-800">影像资产引擎</h2>
|
||
<p className="text-xs font-medium text-slate-400 mt-1">网页影像库中的 DICOM 3D 序列数据集</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={uploadImage}
|
||
disabled={isUploadingDicom}
|
||
className="px-6 py-3.5 bg-blue-600 text-white rounded-2xl text-sm font-bold hover:bg-black transition-all flex items-center gap-3 shadow-xl shadow-blue-500/20 active:scale-95 disabled:opacity-50"
|
||
>
|
||
<Plus size={18} /> {isUploadingDicom ? '上传中...' : '上传文件夹'}
|
||
</button>
|
||
<button
|
||
onClick={uploadZip}
|
||
disabled={isUploadingDicom}
|
||
className="px-6 py-3.5 bg-slate-800 text-white rounded-2xl text-sm font-bold hover:bg-blue-600 transition-all flex items-center gap-3 shadow-xl shadow-slate-500/10 active:scale-95 disabled:opacity-50"
|
||
>
|
||
<Download size={18} /> 上传压缩包
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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">
|
||
<LibraryDicomPreview item={item} />
|
||
|
||
<div className="p-6 flex flex-col gap-4">
|
||
<div className="flex justify-between items-start">
|
||
<div>
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-tighter mb-0.5">病例标识码</p>
|
||
<h5 className="font-black text-slate-800 text-base font-mono">{item.patientId}</h5>
|
||
</div>
|
||
<div className="bg-slate-50 px-2 py-1 rounded-lg text-center">
|
||
<p className="text-[8px] font-bold text-slate-300">SIZE</p>
|
||
<p className="text-[10px] font-bold text-slate-500">{item.size}MB</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 text-[10px] text-slate-400 font-bold">
|
||
<span className="flex items-center gap-1"><MonitorPlay size={10} /> {item.version}</span>
|
||
<div className="w-1 h-1 rounded-full bg-slate-200"></div>
|
||
<span className="flex items-center gap-1"><Activity size={10} /> {item.date}</span>
|
||
<div className="w-1 h-1 rounded-full bg-slate-200"></div>
|
||
<span>{item.fileCount || 0} 张</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 gap-2 mt-2">
|
||
<button
|
||
onClick={() => {
|
||
if (item.status === 'processed') {
|
||
setSelectedLibraryId(item.id);
|
||
setPreviewImage('');
|
||
setProcessPreviewImage('');
|
||
clearDeformationTask();
|
||
setCurrentPage('workspace');
|
||
showToast(`已选择 ${item.patientId} 作为工作站数据源`);
|
||
} else {
|
||
showToast('该影像尚在处理队列中,无法载入');
|
||
}
|
||
}}
|
||
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-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>
|
||
<button
|
||
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"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Empty Upload Slot */}
|
||
<div
|
||
className="bg-slate-50/50 rounded-[2.5rem] border-2 border-dashed border-slate-200 flex flex-col items-center justify-center p-8 text-slate-300 hover:bg-white hover:border-blue-400 hover:text-blue-600 transition-all group min-h-[380px]"
|
||
>
|
||
<p className="text-xs font-black uppercase tracking-widest">新增影像数据</p>
|
||
<p className="text-[10px] mt-2 opacity-50">支持 DICOM 文件夹和 ZIP 压缩包</p>
|
||
<div className="grid grid-cols-2 gap-3 w-full mt-6">
|
||
<button
|
||
onClick={uploadImage}
|
||
disabled={isUploadingDicom}
|
||
className="py-3 bg-blue-600 text-white rounded-2xl text-[11px] font-black hover:bg-black transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
||
>
|
||
<FolderOpen size={14} /> 文件夹
|
||
</button>
|
||
<button
|
||
onClick={uploadZip}
|
||
disabled={isUploadingDicom}
|
||
className="py-3 bg-slate-800 text-white rounded-2xl text-[11px] font-black hover:bg-blue-600 transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
||
>
|
||
<Download size={14} /> ZIP
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* --- USERS VIEW --- */}
|
||
{currentPage === 'users' && (
|
||
<div className="max-w-4xl space-y-8 animate-in fade-in duration-500">
|
||
|
||
{/* My Profile */}
|
||
<div className="bg-white p-10 rounded-[2.5rem] border shadow-sm flex items-center justify-between">
|
||
<div className="flex items-center gap-8">
|
||
<div className="w-20 h-20 bg-blue-600 rounded-3xl flex items-center justify-center text-white text-3xl font-black shadow-xl shadow-blue-500/20 ring-4 ring-blue-50 transition-transform hover:rotate-6">{currentUser?.username[0].toUpperCase()}</div>
|
||
<div>
|
||
<h4 className="text-2xl font-black text-slate-800">{currentUser?.username}</h4>
|
||
<div className="flex gap-2 mt-2">
|
||
<span className="px-3 py-1 bg-blue-50 text-blue-600 text-[10px] font-black rounded-lg uppercase tracking-widest border border-blue-100">{currentUser?.role} ACCESS</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button onClick={() => setIsChangingOwnPass(!isChangingOwnPass)} className="flex items-center gap-3 text-xs font-black text-slate-400 hover:text-blue-600 transition-all bg-slate-50 px-6 py-3 rounded-2xl"><Key size={14} /> 修改系统密码</button>
|
||
</div>
|
||
|
||
{isChangingOwnPass && (
|
||
<div className="bg-white p-8 rounded-[2.5rem] border border-blue-200 shadow-xl shadow-blue-500/5 animate-in fade-in slide-in-from-top-2 duration-300">
|
||
<p className="text-[10px] font-black text-slate-400 mb-5 uppercase tracking-widest">密钥重置通道</p>
|
||
<div className="flex gap-4">
|
||
<input type="password" placeholder="输入新访问密码..." value={pwChangeInput} onChange={e => setPwChangeInput(e.target.value)} className="flex-1 px-5 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm" />
|
||
<button onClick={() => changePassword(currentUser!.id, pwChangeInput)} className="px-10 py-3.5 bg-blue-600 text-white rounded-2xl text-xs font-black hover:bg-black transition-all shadow-lg shadow-blue-500/10 active:scale-95">确认身份并保存</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{currentUser?.role === 'admin' && (
|
||
<div className="bg-white p-8 rounded-[2.5rem] border border-red-100 shadow-sm flex items-center justify-between gap-6">
|
||
<div>
|
||
<h3 className="font-black text-slate-800">演示环境出厂设置</h3>
|
||
<p className="text-xs text-slate-400 font-bold mt-2">恢复后影像数据库只保留 Ori_Head_CT,并清空当前任务结果。</p>
|
||
</div>
|
||
<button
|
||
onClick={resetDemoEnvironment}
|
||
disabled={isResettingDemo}
|
||
className="px-6 py-3.5 bg-red-50 text-red-600 rounded-2xl text-xs font-black hover:bg-red-600 hover:text-white transition-all flex items-center gap-3 disabled:opacity-60 disabled:cursor-wait"
|
||
>
|
||
<RefreshCw size={15} className={isResettingDemo ? 'animate-spin' : ''} />
|
||
{isResettingDemo ? '正在恢复' : '恢复演示环境出厂设置'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* User List Header */}
|
||
<div className="flex items-center justify-between px-4">
|
||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-[0.2em]">系统权限生命周期管理</h3>
|
||
{currentUser?.role === 'admin' && (
|
||
<button
|
||
onClick={() => setShowAddUser(!showAddUser)}
|
||
className="w-10 h-10 rounded-full bg-slate-800 text-white flex items-center justify-center hover:bg-blue-600 transition-all shadow-xl hover:scale-110 active:scale-90"
|
||
>
|
||
<Plus size={20} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Add User Inline Form */}
|
||
{showAddUser && currentUser?.role === 'admin' && (
|
||
<div className="bg-white p-10 rounded-[3rem] border-4 border-dashed border-blue-100 animate-in fade-in zoom-in-95 duration-300 flex flex-col gap-6">
|
||
<h3 className="font-black flex items-center gap-3 text-base text-slate-800"><UserPlus size={24} className="text-blue-600" /> 创建新的医疗访问帐户</h3>
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div className="space-y-1">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase ml-2">账户登录名</label>
|
||
<input type="text" placeholder="e.g. neuro_surgery_01" value={newUsername} onChange={e => setNewUsername(e.target.value)} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-blue-500 font-medium" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-[10px] font-bold text-slate-400 uppercase ml-2">初始访问密码</label>
|
||
<input type="password" placeholder="••••••••" value={newPassword} onChange={e => setNewPassword(e.target.value)} className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-blue-500 font-mono" />
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end gap-3 mt-2">
|
||
<button onClick={() => setShowAddUser(false)} className="px-8 py-3.5 text-slate-400 font-bold text-xs hover:text-slate-600">取消创建</button>
|
||
<button onClick={addUser} className="px-10 py-3.5 bg-blue-600 text-white rounded-2xl text-xs font-black hover:bg-black transition-all shadow-xl shadow-blue-500/20 active:scale-95">完成账号部署</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Improved User List with Gear Actions */}
|
||
<div className="grid grid-cols-1 gap-4 pb-12">
|
||
{users.map(u => (
|
||
<div key={u.id} className="bg-white p-5 px-8 rounded-[2rem] border border-slate-100 flex items-center justify-between relative group hover:border-blue-200 transition-all shadow-sm">
|
||
<div className="flex items-center gap-6">
|
||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center font-black text-lg shadow-sm border ${u.role === 'admin' ? 'bg-amber-50 text-amber-600 border-amber-100' : 'bg-slate-50 text-slate-600 border-slate-100'}`}>
|
||
{u.username[0].toUpperCase()}
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-3">
|
||
<p className="text-base font-black text-slate-700">{u.username}</p>
|
||
{u.role === 'admin' && <ShieldCheck size={14} className="text-amber-500" />}
|
||
</div>
|
||
<p className="text-[10px] text-slate-400 font-bold tracking-tighter uppercase mt-1">{u.role} · 接入于 {u.createdAt}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4">
|
||
{currentUser?.role === 'admin' || u.id === currentUser?.id ? (
|
||
<div className="relative" data-user-menu-root="true">
|
||
<button
|
||
onClick={() => setActiveUserMenu(activeUserMenu === u.id ? null : u.id)}
|
||
className={`p-3 rounded-2xl transition-all ${activeUserMenu === u.id ? 'bg-blue-600 text-white shadow-lg' : 'text-slate-300 hover:text-blue-600 hover:bg-blue-50'}`}
|
||
>
|
||
<Settings size={22} className={activeUserMenu === u.id ? 'animate-spin-slow' : ''} />
|
||
</button>
|
||
|
||
{activeUserMenu === u.id && (
|
||
<div className="absolute right-0 mt-3 w-56 bg-white rounded-[1.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.15)] border border-slate-100 p-2.5 z-20 animate-in fade-in slide-in-from-top-2 duration-300">
|
||
<div className="px-4 py-2 border-b border-slate-50 mb-1">
|
||
<p className="text-[9px] font-black text-slate-300 uppercase tracking-widest">操作选单</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const pass = prompt(`正在为 ${u.username} 重置密码,请输入新密码:`);
|
||
if(pass) changePassword(u.id, pass);
|
||
setActiveUserMenu(null);
|
||
}}
|
||
className="w-full text-left px-4 py-3 text-xs font-bold text-slate-600 hover:bg-blue-50 hover:text-blue-600 rounded-xl flex items-center gap-3 transition-all"
|
||
>
|
||
<Key size={14} /> 修改访问密码
|
||
</button>
|
||
{currentUser?.role === 'admin' && u.id !== currentUser.id && (
|
||
<button
|
||
onClick={() => {
|
||
if(confirm(`警告: 确定永久删除账号 [${u.username}] 及其关联部署吗?`)) {
|
||
setUsers(users.filter(usr => usr.id !== u.id));
|
||
showToast('用户权限已注销');
|
||
}
|
||
setActiveUserMenu(null);
|
||
}}
|
||
className="w-full text-left px-4 py-3 text-xs font-bold text-red-500 hover:bg-red-50 rounded-xl flex items-center gap-3 mt-1 transition-all"
|
||
>
|
||
<LogOut size={14} /> 注销此帐户
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="px-4 py-2 bg-slate-50 rounded-xl text-[10px] font-bold text-slate-300">无权操作</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</main>
|
||
|
||
{isPackageDialogOpen && (
|
||
<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-3xl 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]">四状态 ZIP 内容选择</p>
|
||
<h3 className="text-xl font-black text-slate-800 mt-1">选择要打包下载的内容</h3>
|
||
</div>
|
||
<button
|
||
onClick={() => setIsPackageDialogOpen(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>
|
||
|
||
<div className="p-7 space-y-6">
|
||
{[
|
||
{
|
||
key: 'dicom' as keyof PackageOptions,
|
||
title: 'DICOM 类',
|
||
options: DICOM_PACKAGE_OPTIONS,
|
||
allKeys: DICOM_PACKAGE_OPTIONS.map(option => option.key),
|
||
},
|
||
{
|
||
key: 'images' as keyof PackageOptions,
|
||
title: '图片类',
|
||
options: IMAGE_PACKAGE_OPTIONS,
|
||
allKeys: IMAGE_PACKAGE_OPTIONS.map(option => option.key),
|
||
},
|
||
].map(group => (
|
||
<div key={group.key} className="border border-slate-100 rounded-2xl p-5 bg-slate-50/50">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h4 className="text-sm font-black text-slate-800">{group.title}</h4>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setPackageGroupSelection(group.key, group.allKeys)}
|
||
className="px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-[10px] font-black text-slate-500 hover:text-blue-600 hover:border-blue-200 transition-all"
|
||
>
|
||
全选
|
||
</button>
|
||
<button
|
||
onClick={() => invertPackageGroupSelection(group.key, group.allKeys)}
|
||
className="px-3 py-1.5 rounded-lg bg-white border border-slate-200 text-[10px] font-black text-slate-500 hover:text-blue-600 hover:border-blue-200 transition-all"
|
||
>
|
||
反选
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{group.options.map(option => {
|
||
const checked = packageOptions[group.key].includes(option.key);
|
||
return (
|
||
<button
|
||
key={`${group.key}-${option.key}`}
|
||
onClick={() => togglePackageOption(group.key, option.key)}
|
||
className={`flex items-center gap-3 p-3 rounded-xl border text-left transition-all ${
|
||
checked
|
||
? 'bg-blue-50 border-blue-200 text-blue-700'
|
||
: 'bg-white border-slate-100 text-slate-500 hover:border-slate-200'
|
||
}`}
|
||
>
|
||
<span className={`w-5 h-5 rounded-md border flex items-center justify-center shrink-0 ${
|
||
checked ? 'bg-blue-600 border-blue-600 text-white' : 'border-slate-200'
|
||
}`}>
|
||
{checked && <Check size={13} />}
|
||
</span>
|
||
<span className="text-xs font-black">{option.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="flex items-center justify-between pt-2">
|
||
<p className="text-[10px] font-bold text-slate-400">
|
||
已选择 {packageOptions.dicom.length + packageOptions.images.length} 项内容
|
||
</p>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => setIsPackageDialogOpen(false)}
|
||
className="px-5 py-3 rounded-xl bg-slate-100 text-slate-500 text-xs font-black hover:bg-slate-200 transition-all"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={confirmPackageDownload}
|
||
className="px-5 py-3 rounded-xl bg-green-600 text-white text-xs font-black hover:bg-green-700 transition-all flex items-center gap-2"
|
||
>
|
||
<Download size={14} /> 开始打包下载
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<input
|
||
ref={stlUploadInputRef}
|
||
type="file"
|
||
accept=".stl,model/stl,application/sla"
|
||
className="hidden"
|
||
onChange={handleStlSelected}
|
||
/>
|
||
|
||
<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');
|
||
setDebouncedViewerSliceIndex('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>
|
||
|
||
{!isModelSlicingEnabled && (
|
||
<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 border border-slate-100 bg-white p-4 space-y-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">模型切分</p>
|
||
<p className="text-[10px] font-bold text-slate-400 mt-1 truncate">
|
||
{stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 后启用真实 mask'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setIsModelSlicingEnabled(value => !value)}
|
||
disabled={!stlModel}
|
||
className={`px-3 py-2 rounded-xl text-[10px] font-black transition-all disabled:opacity-40 ${
|
||
isModelSlicingEnabled
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
{isModelSlicingEnabled ? '已启用' : '启用'}
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={uploadStlModel}
|
||
disabled={isUploadingStl}
|
||
className="w-full py-2.5 rounded-xl bg-slate-900 text-white text-xs font-black hover:bg-blue-600 transition-all disabled:opacity-50"
|
||
>
|
||
{isUploadingStl ? '上传解析中...' : '上传 STL 模型'}
|
||
</button>
|
||
|
||
{isModelSlicingEnabled && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between text-[10px] font-mono font-black">
|
||
<span className="text-blue-600">起点 {clampedModelStart + 1}</span>
|
||
<span className="text-orange-600">终点 {clampedModelEnd + 1}</span>
|
||
</div>
|
||
<div className="relative h-10 px-1">
|
||
<div className="absolute left-1 right-1 top-4 h-2 rounded-full bg-slate-200"></div>
|
||
<div
|
||
className="absolute top-4 h-2 rounded-full bg-blue-600"
|
||
style={{
|
||
left: `calc(${Math.min(clampedModelStart, clampedModelEnd) / Math.max(1, viewerFrameCount - 1) * 100}% + 4px)`,
|
||
right: `calc(${100 - Math.max(clampedModelStart, clampedModelEnd) / Math.max(1, viewerFrameCount - 1) * 100}% + 4px)`,
|
||
}}
|
||
></div>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={viewerFrameCount - 1}
|
||
value={clampedModelStart}
|
||
onChange={event => setModelClipStart(parseInt(event.target.value, 10))}
|
||
className="dual-range-input dual-range-input-start absolute inset-x-0 top-0 z-20 w-full h-10 bg-transparent cursor-pointer"
|
||
aria-label="模型切分起点帧"
|
||
/>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={viewerFrameCount - 1}
|
||
value={clampedModelEnd}
|
||
onChange={event => setModelClipEnd(parseInt(event.target.value, 10))}
|
||
className="dual-range-input dual-range-input-end absolute inset-x-0 top-0 z-30 w-full h-10 bg-transparent cursor-pointer"
|
||
aria-label="模型切分终点帧"
|
||
/>
|
||
</div>
|
||
<p className="text-[10px] font-bold text-slate-400">
|
||
两个端点可交叉;显示时分别按起点帧和终点帧切 STL。
|
||
</p>
|
||
</div>
|
||
)}
|
||
</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">
|
||
{isModelSlicingEnabled && stlModel ? (
|
||
<div className="w-full h-full grid grid-cols-1 xl:grid-cols-2 gap-0">
|
||
{[
|
||
{ label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' },
|
||
{ label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' },
|
||
].map(item => (
|
||
<div key={item.label} className="relative min-h-[330px] flex items-center justify-center border-slate-800 xl:border-l first:border-l-0">
|
||
{item.preview?.imageUrl && !modelMaskError ? (
|
||
<img src={`${API_BASE}${item.preview.imageUrl}`} className="w-full h-full object-contain" />
|
||
) : (
|
||
<div className="text-center text-white/35">
|
||
<ImageIcon size={38} className="mx-auto mb-3" />
|
||
<p className="text-xs font-bold">{modelMaskError || '等待 STL mask'}</p>
|
||
</div>
|
||
)}
|
||
<div className="absolute left-4 top-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
||
<p className={`text-[10px] font-black ${item.color}`}>{item.label}</p>
|
||
<p className="text-[10px] font-mono text-white/70 mt-0.5">
|
||
{item.preview ? `${item.preview.index + 1} / ${item.preview.count}` : '-'}
|
||
</p>
|
||
</div>
|
||
<div className="absolute right-4 bottom-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
||
<p className="text-[10px] font-black text-white/70">
|
||
MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无交集'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : 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 || isModelMaskLoading) && (
|
||
<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">
|
||
<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 */}
|
||
{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">
|
||
<Check className="text-green-400" size={18} />
|
||
<span className="text-sm font-bold">{toastMessage}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const Play = (props: any) => (
|
||
<svg viewBox="0 0 24 24" width={props.size} height={props.size} {...props}>
|
||
<path d="M5 3l14 9-14 9V3z" />
|
||
</svg>
|
||
);
|