/** * @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; 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 { 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 (
{item.status.toUpperCase()}
{previewImage && !error ? ( ) : (

{error || (isLoading ? '正在生成预览...' : '等待预览')}

)}
Axial DICOM {sliceIndex + 1} / {count}
setSliceIndex(parseInt(event.target.value, 10))} className="w-full h-1 accent-blue-500 cursor-pointer" />
); } 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 (
{previewImage && !error ? ( ) : ( )}
); } export default function App() { const restoredDeformationJob = useRef(readStoredDeformationJob()).current; const downloadedZipJobIds = useRef>(new Set()); // --- Authentication State --- const [isLoggedIn, setIsLoggedIn] = useState(false); const [currentUser, setCurrentUser] = useState(null); const [loginUsername, setLoginUsername] = useState('admin'); const [loginPassword, setLoginPassword] = useState('123456'); const [loginError, setLoginError] = useState(''); // --- App Flow State --- const [currentPage, setCurrentPage] = useState('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([ { 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([]); const [selectedLibraryId, setSelectedLibraryId] = useState(''); const [isUploadingDicom, setIsUploadingDicom] = useState(false); const [libraryInfo, setLibraryInfo] = useState(null); const [isLibraryInfoLoading, setIsLibraryInfoLoading] = useState(false); const [libraryViewerItem, setLibraryViewerItem] = useState(null); const [viewerPlane, setViewerPlane] = useState('coronal'); const [viewerWindow, setViewerWindow] = useState('default'); const [viewerSliceIndex, setViewerSliceIndex] = useState('middle'); const [debouncedViewerSliceIndex, setDebouncedViewerSliceIndex] = useState('middle'); const [viewerPreview, setViewerPreview] = useState(null); const [isViewerLoading, setIsViewerLoading] = useState(false); const [viewerError, setViewerError] = useState(''); const [stlModel, setStlModel] = useState(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(null); const [modelEndPreview, setModelEndPreview] = useState(null); const [isModelMaskLoading, setIsModelMaskLoading] = useState(false); const [modelMaskError, setModelMaskError] = useState(''); const folderUploadInputRef = useRef(null); const zipUploadInputRef = useRef(null); const stlUploadInputRef = useRef(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(restoredDeformationJob?.job || null); const [videoJob, setVideoJob] = useState(null); const [zipJobs, setZipJobs] = useState({}); const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false); const [packageOptions, setPackageOptions] = useState(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(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) => { 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) => { 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 (
头颈CT变形平台

颅颈特定体位CT影像智慧变形平台

头部CT变形平台

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" />
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" />
{loginError &&

{loginError}

}
); } // --- Sidebar Component --- const NavItem = ({ icon: Icon, label, page }: { icon: any, label: string, page: Page }) => ( ); return (
{/* Sidebar */} {/* Main Content */}
{isSidebarCollapsed && ( )}

{currentPage === 'overview' && '控制台总览'} {currentPage === 'library' && '数据影像库'} {currentPage === 'workspace' && '影像变换工作站'} {currentPage === 'users' && '系统管理工作区'}

{/* --- WORKSPACE VIEW --- */} {currentPage === 'workspace' && (
{backendOnline ? : } {backendMessage}
当前 DICOM 数据集
快速预览算法{PREVIEW_ALGORITHM_OPTIONS.find(option => option.key === previewAlgorithm)?.label}
仰头角度{cervicalRotation.toFixed(1)}°
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" />
{previewAlgorithm === 'gaussian_smooth' && (
高斯平滑强度{previewGaussianSigma.toFixed(1)}
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" />
)} {previewAlgorithm === 'soft_transition' && (
软过渡宽度{transitionWidth}
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" />
)}
{deformationJob?.message || 'head_extension_app.py / run_deformation'} {deformationJob?.status === 'completed' ? '100%' : `${progress}%`}
{deformationJob?.error &&

{deformationJob.error}

} {deformationJob?.status === 'completed' && deformationJob.result?.outputs && ( )} {zipJobs.all?.status === 'failed' &&

{zipJobs.all.error || '四状态 ZIP 打包失败'}

}
视频形变方式 {!isVideoSourceReady && 请选择影像库数据源}
最大角度{videoMaxAngle}°
setVideoMaxAngle(parseInt(e.target.value, 10))} className="w-full accent-blue-600" />
时长{videoDuration}s
setVideoDuration(parseInt(e.target.value, 10))} className="w-full accent-blue-600" />
{videoJob?.status === 'completed' && videoJob.result?.video?.path ? ( ) : (

{videoJob?.message || 'generate_head_extension_video.py / generate_video'}

)} {videoJob?.error &&

{videoJob.error}

}

快速 2D 预览

对应 head_extension_app.py 的 preview_deform_2d

{cervicalRotation.toFixed(1)} DEG {isPreviewLoading &&

自动更新中...

}
{previewImage ? ( ) : (

{isPreviewLoading ? '正在自动生成预览...' : '选择影像库数据后自动生成预览'}

)}

四状态过程对比图

随仰头角度与算法参数自动更新

{isPreviewLoading && 同步更新中...}
{processPreviewImage ? ( ) : (

{isPreviewLoading ? '正在生成四状态对比图...' : '选择影像库数据后自动生成对比图'}

)}

四状态 DICOM 输出结果

本区域显示已生成的四状态输出截图

仰头角度

{(deformationJob?.params?.angleDegrees ?? cervicalRotation).toFixed(1)}°

软过渡宽度

{deformationJob?.params?.transitionWidth ?? transitionWidth}

{[ { 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 (
{t.label}
{t.sub}
{zipJobs[t.key]?.status === 'running' && (

打包中 {formatProgress(progressFromJob(zipJobs[t.key], 0))}%

)} {zipJobs[t.key]?.status === 'failed' && (

{zipJobs[t.key].error || '本状态 DICOM ZIP 打包失败'}

)}
{imagePath ? ( ) : (

等待 Python 输出

)}
); })}
{zipJobs.all?.status === 'failed' &&

{zipJobs.all.error || '四状态 DICOM ZIP 打包失败'}

}
)} {/* --- OVERVIEW VIEW --- */} {currentPage === 'overview' && (
{[ { 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) => (

{s.l}

{s.v}

))}

近期活跃影像流

{libraryData.slice(0, 4).map(item => (

{item.patientId}

{item.date} · {item.size}MB

{item.status === 'processed' ? 'COMPLETED' : 'QUEUEING'}
))}
)} {/* --- LIBRARY VIEW (Card Grid) --- */} {currentPage === 'library' && (

影像资产引擎

网页影像库中的 DICOM 3D 序列数据集

{libraryData.map(item => (

病例标识码

{item.patientId}

SIZE

{item.size}MB

{item.version}
{item.date}
{item.fileCount || 0} 张
))} {/* Empty Upload Slot */}

新增影像数据

支持 DICOM 文件夹和 ZIP 压缩包

)} {/* --- USERS VIEW --- */} {currentPage === 'users' && (
{/* My Profile */}
{currentUser?.username[0].toUpperCase()}

{currentUser?.username}

{currentUser?.role} ACCESS
{isChangingOwnPass && (

密钥重置通道

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" />
)} {currentUser?.role === 'admin' && (

演示环境出厂设置

恢复后影像数据库只保留 Ori_Head_CT,并清空当前任务结果。

)} {/* User List Header */}

系统权限生命周期管理

{currentUser?.role === 'admin' && ( )}
{/* Add User Inline Form */} {showAddUser && currentUser?.role === 'admin' && (

创建新的医疗访问帐户

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" />
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" />
)} {/* Improved User List with Gear Actions */}
{users.map(u => (
{u.username[0].toUpperCase()}

{u.username}

{u.role === 'admin' && }

{u.role} · 接入于 {u.createdAt}

{currentUser?.role === 'admin' || u.id === currentUser?.id ? (
{activeUserMenu === u.id && (

操作选单

{currentUser?.role === 'admin' && u.id !== currentUser.id && ( )}
)}
) : (
无权操作
)}
))}
)}
{isPackageDialogOpen && (

四状态 ZIP 内容选择

选择要打包下载的内容

{[ { 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 => (

{group.title}

{group.options.map(option => { const checked = packageOptions[group.key].includes(option.key); return ( ); })}
))}

已选择 {packageOptions.dicom.length + packageOptions.images.length} 项内容

)} {libraryViewerItem && (

DICOM 阅览

{libraryViewerItem.patientId}

平面

{VIEWER_PLANE_OPTIONS.map(option => ( ))}

显示模式

{!isModelSlicingEnabled && (

切片

{(viewerPreview?.index ?? 0) + 1} / {viewerPreview?.count || 1}
setViewerSliceIndex(parseInt(event.target.value, 10))} className="w-full h-1.5 accent-blue-600 cursor-pointer" />
)}

模型切分

{stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 后启用真实 mask'}

{isModelSlicingEnabled && (
起点 {clampedModelStart + 1} 终点 {clampedModelEnd + 1}
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="模型切分起点帧" /> 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="模型切分终点帧" />

两个端点可交叉;显示时分别按起点帧和终点帧切 STL。

)}
当前平面 {VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label}
窗宽窗位 {viewerPreview?.windowLabel || VIEWER_WINDOW_OPTIONS.find(option => option.key === viewerWindow)?.label}
文件数 {libraryViewerItem.fileCount || 0} 张
{isModelSlicingEnabled && stlModel ? (
{[ { label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' }, { label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' }, ].map(item => (
{item.preview?.imageUrl && !modelMaskError ? ( ) : (

{modelMaskError || '等待 STL mask'}

)}

{item.label}

{item.preview ? `${item.preview.index + 1} / ${item.preview.count}` : '-'}

MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无交集'}

))}
) : viewerPreview?.imageUrl && !viewerError ? ( ) : (

{viewerError || '等待影像载入'}

)} {(isViewerLoading || isModelMaskLoading) && (
载入中...
)}
)} {(libraryInfo || isLibraryInfoLoading) && (

DICOM 基本信息

{libraryInfo?.patientId || '正在读取影像信息'}

{isLibraryInfoLoading ? (
正在读取 DICOM 头信息...
) : (
{libraryInfo?.groups.map(group => (

{group.title}

{group.items.map(item => (
{item.label} {item.value || '-'}
))}
))}
)}
)} {/* Toast */} {toastMessage && (
{toastMessage}
)}
); } const Play = (props: any) => ( );