2026-05-04-02-38-48 记录前端项目代码基线
This commit is contained in:
9
WebSite/.env.example
Normal file
9
WebSite/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
8
WebSite/.gitignore
vendored
Normal file
8
WebSite/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
20
WebSite/README.md
Normal file
20
WebSite/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/2e2bd558-1bd5-4424-b1b2-07238ed56ff7
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
13
WebSite/index.html
Normal file
13
WebSite/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
WebSite/metadata.json
Normal file
6
WebSite/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "模型逆向系统",
|
||||
"description": "基于模型逆向体素化及DICOM分割标注系统,支持DICOM与STL模型配准、可视化、微调及分割影像导出。",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": []
|
||||
}
|
||||
5421
WebSite/package-lock.json
generated
Normal file
5421
WebSite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
WebSite/package.json
Normal file
41
WebSite/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.184.0",
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3"
|
||||
}
|
||||
}
|
||||
98
WebSite/src/App.tsx
Normal file
98
WebSite/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
90
WebSite/src/components/Login.tsx
Normal file
90
WebSite/src/components/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
WebSite/src/components/Overview.tsx
Normal file
140
WebSite/src/components/Overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
WebSite/src/components/ProjectLibrary.tsx
Normal file
268
WebSite/src/components/ProjectLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
WebSite/src/components/ReverseWorkspace.tsx
Normal file
263
WebSite/src/components/ReverseWorkspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
WebSite/src/components/Sidebar.tsx
Normal file
115
WebSite/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
WebSite/src/components/UserManagement.tsx
Normal file
132
WebSite/src/components/UserManagement.tsx
Normal 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
1
WebSite/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
6
WebSite/src/lib/utils.ts
Normal file
6
WebSite/src/lib/utils.ts
Normal 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
10
WebSite/src/main.tsx
Normal 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
22
WebSite/src/types.ts
Normal 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;
|
||||
}
|
||||
26
WebSite/tsconfig.json
Normal file
26
WebSite/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
24
WebSite/vite.config.ts
Normal file
24
WebSite/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig, loadEnv} from 'vite';
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user