/**
* @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,
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';
status: 'running' | 'completed' | 'failed';
message: string;
result?: any;
error?: string;
};
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 }[];
}[];
};
const API_BASE = typeof window === 'undefined'
? 'http://127.0.0.1:8787'
: `${window.location.protocol}//${window.location.hostname}:8787`;
function LibraryDicomPreview({ item }: { item: LibraryItem }) {
const [sliceIndex, setSliceIndex] = useState(Math.max(0, Math.floor((item.fileCount || 1) / 2)));
const [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 (
{item.status.toUpperCase()}
{previewImage && !error ? (

) : (
{error || (isLoading ? '正在生成预览...' : '等待预览')}
)}
);
}
export default function App() {
// --- Authentication State ---
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [loginUsername, setLoginUsername] = useState('admin');
const [loginPassword, setLoginPassword] = useState('123456');
const [loginError, setLoginError] = useState('');
// --- App Flow State ---
const [currentPage, setCurrentPage] = useState('workspace');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
useEffect(() => {
// Automatically collapse sidebar when on workspace page to give more space
if (currentPage === 'workspace') {
setIsSidebarCollapsed(true);
} else {
setIsSidebarCollapsed(false);
}
}, [currentPage]);
const [users, setUsers] = useState([
{ id: '1', username: 'admin', password: '123456', role: 'admin', createdAt: '2024-01-01' },
{ id: '2', username: 'doctor1', password: 'password123', role: 'user', createdAt: '2024-05-01' }
]);
const [libraryData, setLibraryData] = useState([]);
const [selectedLibraryId, setSelectedLibraryId] = useState('');
const [isUploadingDicom, setIsUploadingDicom] = useState(false);
const [libraryInfo, setLibraryInfo] = useState(null);
const [isLibraryInfoLoading, setIsLibraryInfoLoading] = useState(false);
const folderUploadInputRef = useRef(null);
const zipUploadInputRef = useRef(null);
// --- Simulation State (Workspace) ---
const [cervicalRotation, setCervicalRotation] = useState(14.5);
const [transitionWidth, setTransitionWidth] = useState(90);
const [isSimulating, setIsSimulating] = useState(false);
const [progress, setProgress] = useState(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(null);
const [videoJob, setVideoJob] = useState(null);
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(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 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 (!deformationJob || deformationJob.status !== 'running') return;
const timer = setInterval(async () => {
try {
const job = await apiRequest(`/api/job?id=${deformationJob.id}`) as BackendJob;
setDeformationJob(job);
setProgress(value => job.status === 'completed' ? 100 : Math.min(value + 8, 95));
if (job.status === 'completed') {
setIsSimulating(false);
showToast('四状态 DICOM 与过程图已生成');
}
if (job.status === 'failed') {
setIsSimulating(false);
showToast(job.error || '形变任务失败');
}
} catch (error) {
setIsSimulating(false);
setDeformationJob(job => job ? { ...job, status: 'failed', error: (error as Error).message } : null);
}
}, 1500);
return () => 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]);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
const user = users.find(u => u.username === loginUsername && u.password === loginPassword);
if (user) {
setIsLoggedIn(true);
setCurrentUser(user);
setLoginError('');
showToast(`欢迎, ${user.username}`);
} else {
setLoginError('用户名或密码错误');
}
};
const handleLogout = () => {
setIsLoggedIn(false);
setCurrentUser(null);
setCurrentPage('workspace');
};
const addUser = () => {
if (!newUsername || !newPassword) return;
if (users.find(u => u.username === newUsername)) {
showToast('用户名已存在');
return;
}
const newUser: User = {
id: Date.now().toString(),
username: newUsername,
password: newPassword,
role: 'user',
createdAt: new Date().toISOString().split('T')[0]
};
setUsers([...users, newUser]);
setNewUsername('');
setNewPassword('');
setShowAddUser(false);
showToast('用户创建成功');
};
const uploadImage = () => {
folderUploadInputRef.current?.click();
};
const uploadZip = () => {
zipUploadInputRef.current?.click();
};
const handleLibraryFilesSelected = async (event: React.ChangeEvent) => {
const selectedFiles: File[] = [];
const fileList = event.currentTarget.files;
if (fileList) {
for (let index = 0; index < fileList.length; index += 1) {
const file = fileList.item(index);
if (file) selectedFiles.push(file);
}
}
const files = selectedFiles.filter(file => {
const name = file.name.toLowerCase();
return name.endsWith('.dcm') || name.endsWith('.zip');
});
event.currentTarget.value = '';
if (!files.length) {
showToast('请选择 .dcm 文件或 .zip 压缩包');
return;
}
setIsUploadingDicom(true);
try {
const formData = new FormData();
const firstRelativePath = (files[0] as any).webkitRelativePath || files[0].name;
const patientId = firstRelativePath.includes('/')
? firstRelativePath.split('/')[0]
: files[0].name.replace(/\.(dcm|zip)$/i, '') || `WEB_${new Date().getTime()}`;
formData.append('patientId', patientId);
files.forEach(file => {
formData.append('files', file, (file as any).webkitRelativePath || file.name);
});
const response = await fetch(`${API_BASE}/api/library/upload`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '上传失败');
const items = await loadLibrary();
setSelectedLibraryId(data.id || items[0]?.id || '');
setCurrentPage('workspace');
setPreviewImage('');
setDeformationJob(null);
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('');
setDeformationJob(null);
}
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
}),
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]);
const handleRunSimulation = async () => {
if (isSimulating) return;
setIsSimulating(true);
setProgress(0);
try {
const job = await apiRequest('/api/deformation', {
method: 'POST',
body: JSON.stringify({
inputDir: selectedInputDir,
angleDegrees: cervicalRotation,
transitionWidth
})
}) as BackendJob;
setDeformationJob(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 (
颅颈特定体位CT影像智慧变形平台
头部CT变形平台
);
}
// --- Sidebar Component ---
const NavItem = ({ icon: Icon, label, page }: { icon: any, label: string, page: Page }) => (
);
return (
{/* Sidebar */}
{/* Main Content */}
{isSidebarCollapsed && (
)}
{currentPage === 'overview' && '控制台总览'}
{currentPage === 'library' && '临床数据存档'}
{currentPage === 'workspace' && '影像变换工作站'}
{currentPage === 'users' && '系统访问权限管理'}
{/* --- WORKSPACE VIEW --- */}
{currentPage === 'workspace' && (
{backendOnline ?
:
}
{backendMessage}
当前 DICOM 数据集
{deformationJob?.message || 'head_extension_app.py / run_deformation'}
{deformationJob?.status === 'completed' ? '100%' : `${progress}%`}
{deformationJob?.error &&
{deformationJob.error}
}
{deformationJob?.status === 'completed' && deformationJob.result?.zip?.path && (
下载四状态 ZIP
)}
{videoJob?.status === 'completed' && videoJob.result?.video?.path ? (
) : (
{videoJob?.message || 'generate_head_extension_video.py / generate_video'}
)}
{videoJob?.error &&
{videoJob.error}
}
快速 2D 预览
对应 head_extension_app.py 的 preview_deform_2d
{cervicalRotation.toFixed(1)} DEG
{isPreviewLoading &&
自动更新中...
}
{previewImage ? (

) : (
{isPreviewLoading ? '正在自动生成预览...' : '选择影像库数据后自动生成预览'}
)}
{[
{ 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 stateZip = deformationJob?.result?.stateZips?.[t.key];
return (
{t.label}
{t.sub}
{imagePath ? (
})
) : (
)}
{stateZip?.path && (
下载本状态 DICOM ZIP
)}
);
})}
{deformationJob?.result?.previews?.comparison && (
四状态过程对比图
{deformationJob.result.previews.comparison}
)}
)}
{/* --- OVERVIEW VIEW --- */}
{currentPage === 'overview' && (
{[
{ l: '已存储影像数', v: libraryData.length, i: Database, c: 'blue' },
{ l: '存储容量占用', v: `${(libraryData.reduce((acc, curr) => acc + curr.size, 0) / 1024).toFixed(2)} GB`, i: ImageIcon, c: 'indigo' },
{ l: '待处理任务', v: libraryData.filter(i => i.status === 'pending').length, i: Activity, c: 'amber' },
{ l: '已完成计算', v: libraryData.filter(i => i.status === 'processed').length, i: Check, c: 'green' }
].map((s, i) => (
))}
近期活跃影像流
{libraryData.slice(0, 4).map(item => (
{item.patientId}
{item.date} · {item.size}MB
{item.status === 'processed' ? 'COMPLETED' : 'QUEUEING'}
))}
)}
{/* --- LIBRARY VIEW (Card Grid) --- */}
{currentPage === 'library' && (
影像资产引擎
网页影像库中的 DICOM 3D 序列数据集
{libraryData.map(item => (
{item.version}
{item.date}
{item.fileCount || 0} 张
))}
{/* Empty Upload Slot */}
新增影像数据
支持 DICOM 文件夹和 ZIP 压缩包
)}
{/* --- USERS VIEW --- */}
{currentPage === 'users' && (
{/* My Profile */}
{currentUser?.username[0].toUpperCase()}
{currentUser?.username}
{currentUser?.role} ACCESS
{isChangingOwnPass && (
)}
{/* User List Header */}
系统权限生命周期管理
{currentUser?.role === 'admin' && (
)}
{/* Add User Inline Form */}
{showAddUser && currentUser?.role === 'admin' && (
创建新的医疗访问帐户
)}
{/* Improved User List with Gear Actions */}
{users.map(u => (
{u.username[0].toUpperCase()}
{u.username}
{u.role === 'admin' &&
}
{u.role} · 接入于 {u.createdAt}
{currentUser?.role === 'admin' || u.id === currentUser?.id ? (
{activeUserMenu === u.id && (
{currentUser?.role === 'admin' && u.id !== currentUser.id && (
)}
)}
) : (
无权操作
)}
))}
)}
{(libraryInfo || isLibraryInfoLoading) && (
DICOM 基本信息
{libraryInfo?.patientId || '正在读取影像信息'}
{isLibraryInfoLoading ? (
正在读取 DICOM 头信息...
) : (
{libraryInfo?.groups.map(group => (
{group.title}
{group.items.map(item => (
{item.label}
{item.value || '-'}
))}
))}
)}
)}
{/* Toast */}
{toastMessage && (
{toastMessage}
)}
);
}
const Play = (props: any) => (
);