Files
Head_CT_Morph/WebSite/src/App.tsx

2344 lines
111 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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>
);