2026-05-04-02-38-48 记录前端项目代码基线

This commit is contained in:
2026-05-04 02:44:14 +08:00
parent 3a47363a6c
commit 2017348cf1
20 changed files with 6713 additions and 0 deletions

98
WebSite/src/App.tsx Normal file
View File

@@ -0,0 +1,98 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import Login from './components/Login';
import Sidebar from './components/Sidebar';
import Overview from './components/Overview';
import ProjectLibrary from './components/ProjectLibrary';
import ReverseWorkspace from './components/ReverseWorkspace';
import UserManagement from './components/UserManagement';
import { ViewType } from './types';
import { } from 'lucide-react';
export default function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [activeView, setActiveView] = useState<ViewType>(ViewType.OVERVIEW);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Automatically collapse main sidebar when entering Project Library or Workspace
useEffect(() => {
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE) {
setSidebarCollapsed(true);
} else {
setSidebarCollapsed(false);
}
}, [activeView]);
const handleLogin = () => setIsAuthenticated(true);
const handleLogout = () => {
setIsAuthenticated(false);
setActiveView(ViewType.OVERVIEW);
};
if (!isAuthenticated) {
return <Login onLogin={handleLogin} />;
}
return (
<div className="flex h-screen bg-[#f8fafc] overflow-hidden font-sans antialiased text-slate-900">
<Sidebar
activeView={activeView}
setActiveView={setActiveView}
onLogout={handleLogout}
collapsed={sidebarCollapsed}
setCollapsed={setSidebarCollapsed}
/>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Top Navigation */}
<header className="h-16 bg-white border-b border-slate-200 px-8 flex items-center justify-between z-10 shrink-0">
<div className="flex items-center gap-4 text-sm font-medium">
<span className="text-slate-900 font-bold capitalize">
{activeView === ViewType.OVERVIEW && '总体概况'}
{activeView === ViewType.PROJECTS && '项目库'}
{activeView === ViewType.WORKSPACE && '逆向工作区'}
{activeView === ViewType.SYSTEM && '系统管理工作区'}
</span>
</div>
<div className="flex items-center gap-6">
</div>
</header>
{/* Content Area */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-8">
<AnimatePresence mode="wait">
<motion.div
key={activeView}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="h-full"
>
{activeView === ViewType.OVERVIEW && <Overview />}
{activeView === ViewType.PROJECTS && (
<ProjectLibrary
onReverse={() => setActiveView(ViewType.WORKSPACE)}
/>
)}
{activeView === ViewType.WORKSPACE && <ReverseWorkspace />}
{activeView === ViewType.SYSTEM && <UserManagement />}
</motion.div>
</AnimatePresence>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { Layout, Lock, User, CheckCircle2 } from 'lucide-react';
interface LoginProps {
onLogin: () => void;
}
export default function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('123456');
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setTimeout(() => {
onLogin();
setLoading(false);
}, 800);
};
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md"
>
<div className="bg-white rounded-2xl shadow-xl overflow-hidden border border-neutral-200">
<div className="bg-blue-600 p-8 text-white text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-white/20 rounded-2xl mb-4 backdrop-blur-sm">
<Layout size={32} />
</div>
<h1 className="text-xl font-bold leading-tight px-4">DICOM分割标注系统</h1>
<p className="text-blue-100 mt-2 font-medium"></p>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-neutral-700 block mb-1"></label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<User size={18} />
</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-neutral-50 border border-neutral-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all outline-none"
placeholder="请输入账号"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-neutral-700 block mb-1"></label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<Lock size={18} />
</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-neutral-50 border border-neutral-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all outline-none"
placeholder="请输入密码"
/>
</div>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold transition-colors flex items-center justify-center gap-2"
>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
'立即登录'
)}
</button>
</form>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { motion } from 'motion/react';
import {
FolderRoot,
CheckCircle2,
Activity,
Database
} from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const stats = [
{ label: '项目总数', value: '128', icon: FolderRoot, color: 'bg-blue-500', trend: '+12%' },
{ label: '已处理项目总数', value: '15,420', icon: Database, color: 'bg-indigo-500', trend: '+5%' },
];
const chartData = [
{ name: 'Mon', projects: 4, processing: 40 },
{ name: 'Tue', projects: 7, processing: 30 },
{ name: 'Wed', projects: 5, processing: 60 },
{ name: 'Thu', projects: 8, processing: 45 },
{ name: 'Fri', projects: 12, processing: 80 },
{ name: 'Sat', projects: 9, processing: 50 },
{ name: 'Sun', projects: 6, processing: 35 },
];
export default function Overview() {
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="text-slate-500 mt-1"></p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{stats.map((stat, i) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className={stat.color + " p-3 rounded-xl text-white shadow-lg"}>
<stat.icon size={24} />
</div>
<span className={`text-xs font-bold px-2 py-1 rounded-full ${
stat.trend.startsWith('+') ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'
}`}>
{stat.trend}
</span>
</div>
<div className="mt-4">
<p className="text-sm font-medium text-slate-500">{stat.label}</p>
<h3 className="text-3xl font-bold text-slate-800 mt-1">{stat.value}</h3>
</div>
</motion.div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm"
>
<div className="flex items-center justify-between mb-8">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<Activity size={20} className="text-blue-500" />
</h3>
<select className="text-sm border-none bg-slate-50 rounded-lg py-1 px-2 text-slate-600 focus:ring-0">
<option>7</option>
<option>30</option>
</select>
</div>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorProj" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
cursor={{ stroke: '#3b82f6', strokeWidth: 2 }}
/>
<Area type="monotone" dataKey="projects" stroke="#3b82f6" strokeWidth={3} fillOpacity={1} fill="url(#colorProj)" />
</AreaChart>
</ResponsiveContainer>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm"
>
<div className="flex items-center justify-between mb-8">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<CheckCircle2 size={20} className="text-emerald-500" />
</h3>
<select className="text-sm border-none bg-slate-50 rounded-lg py-1 px-2 text-slate-600 focus:ring-0">
<option>7</option>
<option>30</option>
</select>
</div>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorProc" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#818cf8" stopOpacity={0.1}/>
<stop offset="95%" stopColor="#818cf8" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
cursor={{ stroke: '#818cf8', strokeWidth: 2 }}
/>
<Area type="monotone" dataKey="processing" stroke="#818cf8" strokeWidth={3} fillOpacity={1} fill="url(#colorProc)" />
</AreaChart>
</ResponsiveContainer>
</div>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,268 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Plus,
Search,
MoreHorizontal,
Eye,
RotateCw,
FileText,
Box,
Image as ImageIcon,
ChevronRight,
Filter,
Trash2,
Edit2,
FolderRoot,
Download
} from 'lucide-react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage, Gltf, useGLTF, Environment, PerspectiveCamera } from '@react-three/drei';
import { Project } from '../types';
// Mock 3D Model component
function ModelPreview() {
return (
<mesh>
<boxGeometry args={[1.5, 1.5, 1.5]} />
<meshStandardMaterial color="#3b82f6" metalness={0.5} roughness={0.2} />
</mesh>
);
}
export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) {
const [search, setSearch] = useState('');
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [viewMode, setViewMode] = useState<'dicom' | 'model'>('dicom');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [sliceIndex, setSliceIndex] = useState(42);
const [visibleModules, setVisibleModules] = useState<Record<string, boolean>>({
'颅骨整体': true, '牙弓': true, '左颧骨': true, '右颧骨': true, '下颌骨': true, '蝶骨': true, '筛骨': true
});
const subModules = ['颅骨整体', '牙弓', '左颧骨', '右颧骨', '下颌骨', '蝶骨', '筛骨'];
const toggleModule = (name: string) => {
setVisibleModules(prev => ({ ...prev, [name]: !prev[name] }));
};
const toggleAllModules = () => {
const allVisible = Object.values(visibleModules).every(v => v);
const newState = { ...visibleModules };
subModules.forEach(m => newState[m] = !allVisible);
setVisibleModules(newState);
};
const projects: Project[] = [
{ id: '1', name: '大腿骨折复位三维重建', createTime: '2024-03-20', status: 'completed', dicomCount: 156, hasModel: true },
{ id: '2', name: '牙齿正畸扫描数据', createTime: '2024-03-21', status: 'pending', dicomCount: 42, hasModel: true },
{ id: '3', name: '测试项目_胸腔扫描', createTime: '2024-03-22', status: 'processing', dicomCount: 210, hasModel: false },
{ id: '4', name: '脑膜瘤切除规划', createTime: '2024-03-23', status: 'completed', dicomCount: 320, hasModel: true },
];
return (
<div className="h-full flex gap-6 overflow-hidden">
{/* Project Sidebar - Collapsible */}
<div
className={`${
isSidebarCollapsed ? 'w-12' : 'w-72'
} flex flex-col bg-white rounded-2xl border border-slate-100 shadow-sm transition-all duration-300 relative overflow-hidden shrink-0`}
>
<button
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
className="absolute right-1 top-4 z-10 p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 transition-colors"
>
{isSidebarCollapsed ? <ChevronRight size={18} /> : <ChevronRight className="rotate-180" size={18} />}
</button>
{!isSidebarCollapsed && (
<div className="p-4 flex flex-col h-full overflow-hidden">
<h3 className="font-bold text-slate-800 mb-4 px-1"></h3>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
<input
type="text"
placeholder="搜索..."
className="w-full pl-8 pr-4 py-2 bg-slate-50 border-none rounded-lg text-xs focus:ring-1 focus:ring-blue-500 outline-none"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
{projects.map((proj) => (
<button
key={proj.id}
onClick={() => setSelectedProject(proj)}
className={`w-full p-3 rounded-xl transition-all text-left ${
selectedProject?.id === proj.id ? 'bg-blue-600 text-white shadow-md' : 'hover:bg-slate-50'
}`}
>
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
{proj.name}
</p>
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
{proj.createTime}
</p>
</button>
))}
</div>
</div>
)}
{isSidebarCollapsed && (
<div className="flex flex-col items-center py-12 gap-4">
{projects.map(p => (
<div
key={p.id}
onClick={() => setSelectedProject(p)}
className={`w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${
selectedProject?.id === p.id ? 'bg-blue-600 text-white shadow-md' : 'bg-slate-50 text-slate-400'
}`}
>
<FolderRoot size={16} />
</div>
))}
</div>
)}
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
{selectedProject ? (
<>
<div className="flex items-center justify-between">
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => setViewMode('dicom')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
viewMode === 'dicom' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<ImageIcon size={16} /> DICOM
</button>
<button
onClick={() => setViewMode('model')}
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
viewMode === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Box size={16} /> 3D
</button>
</div>
<div className="flex gap-4">
<button
onClick={() => onReverse(selectedProject.id)}
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-blue-700 transition-all shadow-lg"
>
<RotateCw size={18} />
</button>
<button className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all">
<Download size={18} />
</button>
</div>
</div>
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
{viewMode === 'dicom' ? (
<div className="h-full flex gap-8">
{/* Left: DICOM Viewer */}
<div className="flex-1 bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center p-12">
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
<p>SCAN DATE: {selectedProject.createTime}</p>
</div>
<div className="relative w-full h-full flex items-center justify-center">
<div className="w-64 h-64 border-2 border-white/5 rounded-full absolute animate-pulse" />
<div className="w-56 h-56 border border-white/10 rounded-full" />
<p className="absolute text-white/20 text-xs font-mono uppercase tracking-widest">DCM RENDER VIEW | #{sliceIndex}</p>
</div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: 400/40</span>
<span>SLICE: {sliceIndex}/128</span>
</div>
</div>
{/* Right: Vertical Progress Bar */}
<div className="w-16 h-full flex flex-col items-center py-2 bg-slate-50 rounded-2xl">
<span className="text-[10px] text-slate-400 font-bold mb-4">NAV</span>
<input
type="range" min="0" max="128" value={sliceIndex}
onChange={(e) => setSliceIndex(Number(e.target.value))}
className="flex-1 w-1.5 appearance-none bg-slate-200 rounded-full focus:outline-none accent-blue-600 cursor-pointer"
style={{ writingMode: 'bt-lr' as any }}
/>
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex}</span>
</div>
</div>
) : (
<div className="h-full flex gap-8">
{/* Left: 3D Visualization */}
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
<Canvas>
<Stage environment="city" intensity={0.5}>
<ModelPreview />
</Stage>
<OrbitControls />
</Canvas>
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
POLYGONS: 2.1M | VERTS: 1.2M
</div>
</div>
{/* Right: Sub-module List */}
<div className="w-64 h-full flex flex-col overflow-hidden">
<div className="px-1 flex items-center justify-between mb-3 shrink-0">
<p className="text-xs font-bold text-slate-700 uppercase tracking-widest"> ({subModules.length})</p>
<button
onClick={toggleAllModules}
className={`p-1 rounded hover:bg-slate-100 transition-colors ${Object.values(visibleModules).every(v => v) ? 'text-blue-500' : 'text-slate-400'}`}
title={Object.values(visibleModules).every(v => v) ? "全隐藏" : "全显示"}
>
<Eye size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
{subModules.map((m, i) => (
<div
key={m}
className={`p-3 rounded-xl border flex items-center gap-3 group transition-all ${
i === 0 ? 'bg-blue-50 border-blue-100' : 'bg-slate-50 border-transparent hover:border-slate-200'
} ${!visibleModules[m] ? 'opacity-50' : ''}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${i === 0 ? 'bg-blue-600 text-white' : 'bg-white text-slate-400'}`}>
<Box size={14} />
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-bold text-slate-700 truncate">{m}</p>
<p className="text-[9px] text-slate-400">STL | {i * 4 + 2} MB</p>
</div>
<div className="flex items-center gap-2">
<div className={`w-1.5 h-1.5 rounded-full ${i === 4 ? 'bg-amber-400' : 'bg-emerald-500'}`} />
<button
onClick={(e) => {
e.stopPropagation();
toggleModule(m);
}}
className={`p-1 rounded hover:bg-white transition-colors ${visibleModules[m] ? 'text-blue-500 underline decoration-2' : 'text-slate-300'}`}
>
<Eye size={14} />
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
) : (
<div className="flex-1 bg-white rounded-3xl border border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-6">
<FolderRoot size={40} />
</div>
<p className="font-bold"></p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import {
Dices,
Settings2,
Maximize2,
Download,
Layers,
Move,
Rotate3d,
CheckCircle2,
AlertCircle,
FileJson,
Plus,
Play
} from 'lucide-react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage, PerspectiveCamera, Grid } from '@react-three/drei';
import { MaskMapping } from '../types';
function InteractiveModel({ offset }: { offset: [number, number, number] }) {
return (
<mesh position={offset}>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#3b82f6" transparent opacity={0.6} />
<mesh position={[0, 0, 0]}>
<boxGeometry args={[2.05, 2.05, 2.05]} />
<meshBasicMaterial color="#ffffff" wireframe />
</mesh>
</mesh>
);
}
export default function ReverseWorkspace() {
const [slice, setSlice] = useState(50);
const [isRegistering, setIsRegistering] = useState(false);
const [progress, setProgress] = useState(0);
const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
const [mappings, setMappings] = useState<MaskMapping[]>([
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
{ className: '神经根', color: '#52c41a', maskId: 2 },
{ className: '血管', color: '#1890ff', maskId: 3 },
]);
const handleStartRegistration = () => {
setIsRegistering(true);
setProgress(0);
};
useEffect(() => {
if (isRegistering && progress < 100) {
const timer = setTimeout(() => setProgress(p => p + 2), 50);
return () => clearTimeout(timer);
} else if (progress >= 100) {
setIsRegistering(false);
}
}, [isRegistering, progress]);
return (
<div className="h-full flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="text-slate-500 mt-1"> DICOM </p>
</div>
<div className="flex gap-2">
<button
onClick={handleStartRegistration}
disabled={isRegistering}
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
{isRegistering ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : <Dices size={18} />}
{isRegistering ? `正在自动配准 (${progress}%)` : '开始自动配准'}
</button>
<button className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2">
<Download size={18} />
</button>
</div>
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
{/* Left Column: Image Fusion (4/12) */}
<div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Rotate3d size={18} className="text-blue-500" />
</h3>
<span className="text-[10px] font-mono text-slate-400">Layer: {slice}</span>
</div>
<div className="flex-1 bg-black rounded-3xl overflow-hidden relative border border-slate-800 shadow-xl group">
<div className="absolute inset-0 z-0 opacity-40">
<div className="w-full h-full flex items-center justify-center p-12">
<div className="w-full h-full border-2 border-white/5 rounded-full flex items-center justify-center anonymous-dicom-grid" />
</div>
</div>
<div className="absolute inset-0 z-10">
<Canvas>
<PerspectiveCamera makeDefault position={[3, 3, 3]} />
<Stage environment="city" intensity={0.5}>
<InteractiveModel offset={offset} />
</Stage>
<OrbitControls />
</Canvas>
</div>
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
<div className="pointer-events-auto bg-black/60 backdrop-blur-md border border-white/10 p-3 rounded-xl w-48 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-white uppercase opacity-60"></span>
<Settings2 size={10} className="text-blue-400" />
</div>
<input
type="range" min="-5" max="5" step="0.1"
value={offset[0]}
onChange={(e) => setOffset([Number(e.target.value), offset[1], offset[2]])}
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
/>
</div>
</div>
</div>
</div>
{/* Middle Column: Mask Selection (3/12) */}
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
<div className="px-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Layers size={18} className="text-emerald-500" />
Mask
</h3>
</div>
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
<div className="flex-1 overflow-auto space-y-2 pr-1">
{mappings.map((m, i) => (
<button
key={i}
className={`w-full flex flex-col gap-2 p-3 rounded-xl border transition-all text-left group ${
i === 0 ? 'bg-blue-50 border-blue-200' : 'bg-slate-50 border-transparent hover:border-slate-200'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: m.color }} />
<span className="text-xs font-bold text-slate-700">{m.className}</span>
</div>
{i === 0 && <CheckCircle2 size={14} className="text-blue-500" />}
</div>
<div className="flex items-center justify-between text-[10px] text-slate-500 font-mono">
<span>ID: {m.maskId}</span>
<span className="font-bold text-emerald-600">Conf: 98%</span>
</div>
</button>
))}
<button className="w-full py-3 border-2 border-dashed border-slate-100 rounded-xl text-slate-400 flex items-center justify-center hover:bg-slate-50 transition-all">
<Plus size={18} />
</button>
</div>
<div className="p-3 bg-slate-900 rounded-2xl shrink-0">
<div className="flex items-center gap-2 mb-2">
<FileJson size={12} className="text-blue-400" />
<span className="text-[10px] font-bold text-white/50 uppercase tracking-widest">Metadata</span>
</div>
<pre className="text-[9px] text-blue-300/70 font-mono overflow-hidden">
{`{ id: "SM_091", voxel: 124K }`}
</pre>
</div>
</div>
</div>
{/* Right Column: Mask Image Display (5/12) */}
<div className="lg:col-span-5 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Play size={18} className="text-blue-500" />
Mask
</h3>
<div className="flex gap-2">
<button className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1">
<Download size={12} />
NII ()
</button>
<button className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg">
<Download size={12} />
NII.GZ ()
</button>
</div>
</div>
<div className="flex-1 bg-slate-900 rounded-3xl border border-slate-800 shadow-2xl relative overflow-hidden flex items-center justify-center">
{/* The actual Mask result visualization */}
<div className="relative w-72 h-72">
{/* Base DICOM context (faint) */}
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
{/* Mask Layers */}
{mappings.map((m, i) => (
<motion.div
key={i}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 0.8 }}
transition={{ delay: i * 0.2 }}
className="absolute inset-0 border-2"
style={{
borderColor: m.color,
borderRadius: i === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
background: `${m.color}20`,
boxShadow: `inset 0 0 20px ${m.color}40`,
transform: `rotate(${i * 45 + slice}deg) scale(${1 - i * 0.1})`
}}
/>
))}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-full h-0.5 bg-blue-500/20 absolute" />
<div className="h-full w-0.5 bg-blue-500/20 absolute" />
</div>
</div>
<div className="absolute top-4 left-4 z-20 flex gap-2">
<span className="px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase">Inferred Mask</span>
<span className="px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase">Verified</span>
</div>
<div className="absolute bottom-4 right-4">
<button className="p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all">
<Maximize2 size={16} />
</button>
</div>
{/* Legend Overlay */}
<div className="absolute top-4 right-4 flex flex-col gap-1 items-end">
{mappings.map((m, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-[9px] text-white/40 font-mono italic">#{m.maskId}</span>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: m.color }} />
</div>
))}
</div>
</div>
<div className="h-16 shrink-0 bg-white rounded-2xl border border-slate-100 shadow-sm flex items-center justify-between px-6">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest"></span>
<span className="text-xs font-bold text-slate-700"> {mappings.length} </span>
</div>
<div className="w-32 bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div className="bg-blue-600 h-full w-[100%]" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { motion } from 'motion/react';
import {
BarChart3,
FolderRoot,
Box,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
UserCircle
} from 'lucide-react';
import { ViewType } from '../types';
import { cn } from '../lib/utils';
interface SidebarProps {
activeView: ViewType;
setActiveView: (view: ViewType) => void;
onLogout: () => void;
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}
export default function Sidebar({
activeView,
setActiveView,
onLogout,
collapsed,
setCollapsed
}: SidebarProps) {
const menuItems = [
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
{ id: ViewType.WORKSPACE, icon: Box, label: '逆向工作区' },
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
];
return (
<motion.aside
animate={{ width: collapsed ? 80 : 260 }}
className="h-screen bg-slate-900 text-slate-300 flex flex-col relative z-20 transition-all duration-300 shadow-2xl"
>
<div className="p-6 flex items-center gap-3 overflow-hidden">
<div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white shrink-0">
<Box size={24} />
</div>
{!collapsed && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col"
>
<h1 className="text-white font-bold text-sm tracking-wide"></h1>
</motion.div>
)}
</div>
<nav className="flex-1 px-4 py-4 space-y-2">
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => setActiveView(item.id)}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-all group",
activeView === item.id
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
: "hover:bg-slate-800 text-slate-400 hover:text-white"
)}
>
<item.icon size={22} className={cn(
"shrink-0",
activeView === item.id ? "text-white" : "group-hover:scale-110 transition-transform"
)} />
{!collapsed && (
<span className="font-medium whitespace-nowrap">{item.label}</span>
)}
</button>
))}
</nav>
<div className="p-4 border-t border-slate-800">
<div className={cn(
"flex items-center gap-3 p-2 rounded-xl bg-slate-800/50 mb-4",
collapsed ? "justify-center" : "px-3"
)}>
<UserCircle size={24} className="text-blue-400 shrink-0" />
{!collapsed && (
<div className="overflow-hidden">
<p className="text-sm font-semibold text-white truncate">Admin</p>
<p className="text-xs text-slate-500 truncate"></p>
</div>
)}
</div>
<button
onClick={onLogout}
className={cn(
"w-full flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-red-500/10 text-slate-400 hover:text-red-400 transition-all",
collapsed ? "justify-center" : ""
)}
>
<LogOut size={22} className="shrink-0" />
{!collapsed && <span className="font-medium">退</span>}
</button>
</div>
<button
onClick={() => setCollapsed(!collapsed)}
className="absolute -right-3 top-20 w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white shadow-lg hover:scale-110 transition-transform"
>
{collapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
</motion.aside>
);
}

View File

@@ -0,0 +1,132 @@
import { motion } from 'motion/react';
import {
Users,
UserPlus,
Search,
MoreVertical,
Shield,
Calendar,
RotateCcw,
Edit2,
Trash2,
Key
} from 'lucide-react';
const today = new Date().toISOString().split('T')[0];
const users = [
{ id: 1, name: 'Admin', account: 'admin', department: 'admin', date: today },
{ id: 2, name: 'Doctor Li', account: 'doctor_li', department: '肝胆外科', date: today },
];
export default function UserManagement() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="flex gap-3">
<button className="bg-amber-100 text-amber-700 px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-amber-200 transition-all flex items-center gap-2 border border-amber-200">
<RotateCcw size={18} />
</button>
<button className="bg-blue-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2">
<UserPlus size={18} />
</button>
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden flex flex-col">
<div className="p-4 border-b border-slate-100 flex items-center gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="搜索用户名、账号、科室..."
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 transition-all outline-none text-sm"
/>
</div>
<button className="text-slate-500 hover:text-slate-700 p-2"><Shield size={20} /></button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-slate-50 text-slate-500 text-xs font-bold uppercase tracking-wider">
<th className="px-6 py-4"></th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{users.map((user, i) => (
<motion.tr
key={user.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="hover:bg-slate-50/50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{user.name[0]}
</div>
<div>
<p className="font-bold text-slate-800">{user.name}</p>
</div>
</div>
</td>
<td className="px-6 py-4 px-6 text-sm font-mono text-slate-500">
{user.account}
</td>
<td className="px-6 py-4">
<span className="text-sm text-slate-600 font-medium px-2 py-1 bg-slate-100 rounded-lg">
{user.department}
</span>
</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-500 flex items-center gap-2">
<Calendar size={14} />
{user.date}
</p>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-4">
<button className="text-slate-400 hover:text-blue-600 transition-colors flex items-center gap-1 text-xs" title="编辑信息">
<Edit2 size={16} />
</button>
<div className="h-4 w-[1px] bg-slate-200" />
<button className="text-slate-400 hover:text-amber-600 transition-colors" title="修改密码">
<Key size={16} />
</button>
<button className="text-slate-400 hover:text-rose-600 transition-colors" title="删除用户">
<Trash2 size={16} />
</button>
<button className="text-slate-300 hover:text-slate-600 transition-colors">
<MoreVertical size={18} />
</button>
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
<div className="p-4 border-t border-slate-50 flex items-center justify-between text-sm text-slate-500 bg-slate-50/50">
<p> {users.length} </p>
<div className="flex gap-2">
<button className="px-3 py-1 border border-blue-500 rounded bg-blue-500 text-white">1</button>
</div>
</div>
</div>
</div>
);
}

1
WebSite/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

6
WebSite/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
WebSite/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

22
WebSite/src/types.ts Normal file
View File

@@ -0,0 +1,22 @@
export enum ViewType {
OVERVIEW = 'overview',
PROJECTS = 'projects',
WORKSPACE = 'workspace',
SYSTEM = 'system',
}
export interface Project {
id: string;
name: string;
createTime: string;
status: 'pending' | 'completed' | 'processing';
thumbnail?: string;
dicomCount: number;
hasModel: boolean;
}
export interface MaskMapping {
className: string;
color: string;
maskId: number;
}