Files
Head_CT_Morph/WebSite/src/App.tsx

1449 lines
68 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect, useRef } from 'react';
import {
Activity,
Check,
Download,
Layers,
ActivitySquare,
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;
result?: any;
error?: string;
};
type ZipJobsByTarget = Record<string, BackendJob>;
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 StoredDeformationJob = {
job: BackendJob;
progress: number;
};
const DEFORMATION_JOB_STORAGE_KEY = 'head_ct_morph_deformation_job';
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, Math.round(job.progress)));
}
return Math.max(0, Math.min(95, fallback));
}
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;
return {
job: parsed.job,
progress: Math.max(0, Math.min(100, parsed.progress || 0)),
};
} catch {
return null;
}
}
function LibraryDicomPreview({ item }: { item: LibraryItem }) {
const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
const [requestedSliceIndex, setRequestedSliceIndex] = useState(sliceIndex);
const [previewImage, setPreviewImage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const count = Math.max(1, item.fileCount || 1);
useEffect(() => {
const middleIndex = Math.max(0, Math.floor((item.fileCount || 1) / 2));
setSliceIndex(middleIndex);
setRequestedSliceIndex(middleIndex);
}, [item.id, item.fileCount]);
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>
);
}
export default function App() {
const restoredDeformationJob = useRef(readStoredDeformationJob()).current;
// --- 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 folderUploadInputRef = useRef<HTMLInputElement | null>(null);
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
// --- Simulation State (Workspace) ---
const [cervicalRotation, setCervicalRotation] = useState(14.5);
const [transitionWidth, setTransitionWidth] = useState(90);
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 [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 [videoMaxAngle, setVideoMaxAngle] = useState(20);
const [videoDuration, setVideoDuration] = useState(6);
// --- 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 selectedDataset = libraryData.find(item => item.id === selectedLibraryId) || libraryData[0];
const selectedInputDir = selectedDataset?.dicomPath || '';
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 = (path?: string, name?: string) => {
if (!path) return;
const link = document.createElement('a');
link.href = fileUrl(path);
if (name) link.download = name;
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) => {
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
})
}) 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 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 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,
})
);
}, [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') {
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('');
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('');
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 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,
showCutoffLine: showPreviewCutoffLine
}),
signal: controller.signal
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '预览生成失败');
setPreviewImage(data.image);
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, 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;
try {
const job = await apiRequest('/api/video', {
method: 'POST',
body: JSON.stringify({
inputDir: selectedInputDir,
maxAngle: videoMaxAngle,
durationSeconds: videoDuration
})
}) as BackendJob;
setVideoJob(job);
setBackendOnline(true);
setBackendMessage('generate_head_extension_video.py 任务已提交');
showToast('视频任务已提交');
} 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-16 h-16 bg-blue-600 rounded-xl flex items-center justify-center mb-4">
<ActivitySquare className="text-white w-9 h-9" />
</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`}>
<ActivitySquare className="text-blue-600 shrink-0" size={28} />
{!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} className={`w-full flex items-center gap-3 px-4 py-3 text-slate-400 hover:text-red-500 text-sm transition-all ${isSidebarCollapsed ? 'justify-center px-0' : ''}`}>
<LogOut size={18} />
{!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>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[10px] font-extrabold text-slate-400 uppercase tracking-tighter">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
::
</div>
</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('');
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">{cervicalRotation.toFixed(1)}°</span></div>
<input type="range" min="0" max="20" 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>
<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={() => handlePackageDownload('all')}
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 打包中 ${progressFromJob(zipJobs.all, 0)}%`
: '下载四状态 ZIP'}
</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 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="30" 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={handleGenerateVideo} disabled={videoJob?.status === 'running' || !selectedInputDir} 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' ? '正在生成视频' : '生成角度变化视频'}
</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="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>
</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="grid grid-cols-2 gap-5">
{[
{ key: 'original', label: '原始序列', sub: 'ct_original' },
{ key: 'hard_boundary', label: '硬边界', sub: 'ct_hard_boundary' },
{ key: 'gaussian_smooth', label: '高斯平滑', sub: 'ct_gaussian_smooth' },
{ key: 'soft_transition', label: '软过渡重建', sub: 'ct_soft_transition' }
].map(t => {
const screenshotDir = deformationJob?.result?.previews?.screenshots;
const imagePath = screenshotDir ? `${screenshotDir}/${t.key}.png` : '';
const zipJob = zipJobs[t.key];
return (
<div key={t.key} className="bg-white p-4 rounded-2xl border flex flex-col hover:border-blue-200 transition-colors shadow-sm group min-h-[285px]">
<div className="flex justify-between items-center mb-3">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${t.key === 'soft_transition' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500 group-hover:bg-slate-200'}`}>{t.label}</span>
<span className="text-[8px] font-mono text-slate-300">{t.sub}</span>
</div>
<div className="flex-1 bg-slate-900 rounded-xl relative flex justify-center items-center overflow-hidden border border-slate-800">
{imagePath ? (
<img src={fileUrl(imagePath)} className="w-full h-full object-contain" />
) : (
<div className="text-center text-white/25">
<Layers size={34} className="mx-auto mb-3" />
<p className="text-[10px] font-mono uppercase"> Python </p>
</div>
)}
</div>
{deformationJob?.status === 'completed' && deformationJob.result?.outputs?.[t.key] && (
<button
onClick={() => handlePackageDownload(t.key)}
disabled={zipJob?.status === 'running'}
className="mt-3 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-[11px] font-black hover:bg-green-600 hover:text-white transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-wait"
>
<Download size={13} />
{zipJob?.status === 'running'
? `打包中 ${progressFromJob(zipJob, 0)}%`
: '下载本状态 DICOM ZIP'}
</button>
)}
{zipJob?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJob.error || '打包失败'}</p>}
</div>
);
})}
</div>
{deformationJob?.result?.previews?.comparison && (
<div className="bg-white p-4 rounded-2xl border shadow-sm">
<div className="flex justify-between items-center mb-3">
<h4 className="font-black text-sm text-slate-700"></h4>
<span className="text-[9px] font-mono text-slate-400 break-all">{deformationJob.result.previews.comparison}</span>
</div>
<img src={fileUrl(deformationJob.result.previews.comparison)} className="w-full rounded-xl bg-slate-950" />
</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">
<div className={`w-16 h-12 rounded-xl flex items-center justify-center ${item.previewColor} border border-slate-700/50 flex-col gap-1 overflow-hidden relative shadow-inner`}>
<div className="w-6 h-6 border-white/20 border-2 rounded-full rotate-12 opacity-50"></div>
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none"></div>
</div>
<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-3 gap-3 mt-2">
<button
onClick={() => {
if (item.status === 'processed') {
setSelectedLibraryId(item.id);
setPreviewImage('');
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={() => showLibraryInfo(item)}
className="py-2.5 bg-slate-900 text-white text-[11px] font-black rounded-xl hover:bg-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>
)}
{/* 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>
{(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>
);