2026-05-21-11-13-49 独立Docker程序包
This commit is contained in:
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
|
||||
62
WebSite/README.md
Normal file
62
WebSite/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 模型逆向系统 WebSite
|
||||
|
||||
本目录是“基于模型逆向体素化及 DICOM 分割标注系统”的前后端一体服务。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 18 或更高版本
|
||||
- npm
|
||||
|
||||
当前版本的 DICOM 预览、STL 预览和 NIfTI 演示导出均由 Node/React/Three.js 完成,不需要 Python 或 conda 环境。
|
||||
|
||||
后续若接入真实医学级 STL 反向体素化算法,建议单独创建 Python conda 环境,例如:
|
||||
|
||||
```bash
|
||||
conda create -n revoxelseg python=3.11
|
||||
conda activate revoxelseg
|
||||
```
|
||||
|
||||
再按真实算法依赖安装 SimpleITK、nibabel、numpy、trimesh、vtk 等包。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
```
|
||||
|
||||
## 开发运行
|
||||
|
||||
前后端统一由 Express + Vite 中间件托管:
|
||||
|
||||
```bash
|
||||
npm run serve -- --host 0.0.0.0 --port 4000
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://192.168.3.11:4000/
|
||||
```
|
||||
|
||||
## 构建检查
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认演示项目读取仓库根目录:
|
||||
|
||||
- `Head_CT_DICOM/`:DICOM 序列。
|
||||
- `Head_CT_ReConstruct/`:STL 重建模型。
|
||||
|
||||
这些医学影像和模型数据默认不提交到 Git。
|
||||
|
||||
## 运行态目录
|
||||
|
||||
- `WebSite/data/`:后端共享状态。
|
||||
- `WebSite/exports/`:生成的 NIfTI 导出文件。
|
||||
|
||||
这些目录是运行态产物,默认不提交到 Git。
|
||||
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" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>模型逆向系统</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": []
|
||||
}
|
||||
5554
WebSite/package-lock.json
generated
Normal file
5554
WebSite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
WebSite/package.json
Normal file
46
WebSite/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"serve": "tsx server.ts",
|
||||
"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",
|
||||
"adm-zip": "^0.5.17",
|
||||
"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",
|
||||
"multer": "^2.1.1",
|
||||
"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/adm-zip": "^0.5.8",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
BIN
WebSite/public/logo.png
Normal file
BIN
WebSite/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
2965
WebSite/server.ts
Normal file
2965
WebSite/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
200
WebSite/src/App.tsx
Normal file
200
WebSite/src/App.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } 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 { api } from './lib/api';
|
||||
|
||||
export default function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [sessionLoading, setSessionLoading] = useState(true);
|
||||
const [activeView, setActiveView] = useState<ViewType>(ViewType.OVERVIEW);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
|
||||
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
||||
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const bootSessionResetRef = useRef(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]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const syncSession = async () => {
|
||||
try {
|
||||
if (!bootSessionResetRef.current) {
|
||||
bootSessionResetRef.current = true;
|
||||
const session = await api.logout();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setIsAuthenticated(session.authenticated);
|
||||
setActiveView(ViewType.OVERVIEW);
|
||||
return;
|
||||
}
|
||||
const session = await api.getSession();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setIsAuthenticated(session.authenticated);
|
||||
if (!session.authenticated) {
|
||||
setActiveView(ViewType.OVERVIEW);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
syncSession();
|
||||
const interval = window.setInterval(syncSession, 2500);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
const requestActiveView = (nextView: ViewType) => {
|
||||
if (nextView === activeView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaveWorkspace = activeView === ViewType.WORKSPACE && nextView !== ViewType.WORKSPACE;
|
||||
const switchView = () => {
|
||||
if (leaveWorkspace && nextView === ViewType.PROJECTS) {
|
||||
setProjectLibraryInitialView('mask');
|
||||
}
|
||||
setActiveView(nextView);
|
||||
};
|
||||
|
||||
if (!leaveWorkspace || !workspaceLeaveGuardRef.current) {
|
||||
switchView();
|
||||
return;
|
||||
}
|
||||
|
||||
workspaceLeaveGuardRef.current()
|
||||
.then((canLeave) => {
|
||||
if (canLeave) {
|
||||
switchView();
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (activeView === ViewType.WORKSPACE && workspaceLeaveGuardRef.current) {
|
||||
const canLeave = await workspaceLeaveGuardRef.current();
|
||||
if (!canLeave) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await api.logout();
|
||||
setIsAuthenticated(false);
|
||||
setActiveView(ViewType.OVERVIEW);
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center text-slate-500 font-medium">
|
||||
正在同步登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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={requestActiveView}
|
||||
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
|
||||
initialViewMode={projectLibraryInitialView}
|
||||
onReverse={(projectId) => {
|
||||
setActiveProjectId(projectId);
|
||||
setActiveView(ViewType.WORKSPACE);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === ViewType.WORKSPACE && (
|
||||
<ReverseWorkspace
|
||||
projectId={activeProjectId}
|
||||
onLeaveGuardChange={(handler) => {
|
||||
workspaceLeaveGuardRef.current = handler;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === ViewType.SYSTEM && <UserManagement />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
WebSite/src/components/Login.tsx
Normal file
104
WebSite/src/components/Login.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Lock, User } from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
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 [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.login(username, password);
|
||||
onLogin();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-slate-950 p-8 text-white text-center">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="模型逆向系统"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="px-2 text-3xl font-bold leading-tight">基于模型逆向体素化及DICOM分割标注系统</h1>
|
||||
<p className="mt-3 text-lg font-semibold text-slate-300">模型逆向系统</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>
|
||||
{error && (
|
||||
<p className="text-sm text-rose-600 text-center font-medium">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
WebSite/src/components/Overview.tsx
Normal file
145
WebSite/src/components/Overview.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
FolderRoot,
|
||||
CheckCircle2,
|
||||
Activity,
|
||||
Database,
|
||||
Box
|
||||
} from 'lucide-react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { api } from '../lib/api';
|
||||
import { OverviewSummary } from '../types';
|
||||
|
||||
export default function Overview() {
|
||||
const [summary, setSummary] = useState<OverviewSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getOverview().then(setSummary).catch(() => setSummary(null));
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{ label: '项目总数', value: String(summary?.totalProjects ?? '-'), icon: FolderRoot, color: 'bg-blue-500', trend: '同步' },
|
||||
{ label: '已导出 Mask 项目', value: String(summary?.exportedMaskProjects ?? summary?.processedProjects ?? '-'), icon: CheckCircle2, color: 'bg-emerald-500', trend: '结果' },
|
||||
{ label: 'DICOM 切片数', value: String(summary?.dicomCount ?? '-'), icon: Database, color: 'bg-indigo-500', trend: '默认' },
|
||||
{ label: 'STL 模型数', value: String(summary?.modelCount ?? '-'), icon: Box, color: 'bg-cyan-500', trend: '默认' },
|
||||
];
|
||||
const chartData = summary?.chartData ?? [];
|
||||
|
||||
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-4 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 bg-emerald-50 text-emerald-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 min-w-0">
|
||||
<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 min-w-0 min-h-[300px]">
|
||||
{chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={300} debounce={50}>
|
||||
<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 allowDecimals={false} domain={[0, 'dataMax + 1']} 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 min-w-0 min-h-[300px]">
|
||||
{chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={300} debounce={50}>
|
||||
<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 allowDecimals={false} domain={[0, 'dataMax + 1']} 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>
|
||||
);
|
||||
}
|
||||
2239
WebSite/src/components/ProjectLibrary.tsx
Normal file
2239
WebSite/src/components/ProjectLibrary.tsx
Normal file
File diff suppressed because it is too large
Load Diff
3785
WebSite/src/components/ReverseWorkspace.tsx
Normal file
3785
WebSite/src/components/ReverseWorkspace.tsx
Normal file
File diff suppressed because it is too large
Load Diff
119
WebSite/src/components/Sidebar.tsx
Normal file
119
WebSite/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
BarChart3,
|
||||
FolderRoot,
|
||||
Workflow,
|
||||
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: Workflow, 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 rounded-xl flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="模型逆向系统"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
447
WebSite/src/components/UserManagement.tsx
Normal file
447
WebSite/src/components/UserManagement.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Search,
|
||||
Shield,
|
||||
Calendar,
|
||||
RotateCcw,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Key,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
import { SessionState, UserRecord } from '../types';
|
||||
|
||||
type UserFormMode = 'create' | 'edit' | 'password';
|
||||
|
||||
interface UserFormState {
|
||||
id?: number;
|
||||
name: string;
|
||||
account: string;
|
||||
department: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const emptyUserForm: UserFormState = {
|
||||
name: '',
|
||||
account: '',
|
||||
department: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步');
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [formMode, setFormMode] = useState<UserFormMode | null>(null);
|
||||
const [form, setForm] = useState<UserFormState>(emptyUserForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const refreshUsers = async () => {
|
||||
try {
|
||||
const [items, currentSession] = await Promise.all([api.getUsers(), api.getSession()]);
|
||||
setUsers(items);
|
||||
setSession(currentSession);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '用户列表同步失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUsers();
|
||||
}, []);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return users;
|
||||
}
|
||||
return users.filter((user) => (
|
||||
user.name.toLowerCase().includes(keyword)
|
||||
|| user.account.toLowerCase().includes(keyword)
|
||||
|| user.department.toLowerCase().includes(keyword)
|
||||
));
|
||||
}, [users, search]);
|
||||
|
||||
const currentAccount = session?.currentUser?.account ?? '';
|
||||
|
||||
const openCreateForm = () => {
|
||||
setForm(emptyUserForm);
|
||||
setFormMode('create');
|
||||
setMessage('正在新增系统用户');
|
||||
};
|
||||
|
||||
const openEditForm = (user: UserRecord) => {
|
||||
setForm({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
account: user.account,
|
||||
department: user.department,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
setFormMode('edit');
|
||||
setMessage(`正在编辑用户:${user.name}`);
|
||||
};
|
||||
|
||||
const openPasswordForm = (user: UserRecord) => {
|
||||
setForm({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
account: user.account,
|
||||
department: user.department,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
setFormMode('password');
|
||||
setMessage(`正在修改密码:${user.name}`);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormMode(null);
|
||||
setForm(emptyUserForm);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleSaveUser = async () => {
|
||||
if (!formMode) {
|
||||
return;
|
||||
}
|
||||
const name = form.name.trim();
|
||||
const account = form.account.trim();
|
||||
const department = form.department.trim();
|
||||
const password = form.password.trim();
|
||||
const confirmPassword = form.confirmPassword.trim();
|
||||
|
||||
if (!name || !account || !department) {
|
||||
setMessage('姓名、账号、科室不能为空');
|
||||
return;
|
||||
}
|
||||
if ((formMode === 'create' || formMode === 'password') && (!password || !confirmPassword)) {
|
||||
setMessage('请输入两遍密码');
|
||||
return;
|
||||
}
|
||||
if ((formMode === 'create' || formMode === 'password') && password !== confirmPassword) {
|
||||
setMessage('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
if ((formMode === 'create' || formMode === 'edit') && users.some((user) => user.id !== form.id && user.account === account)) {
|
||||
setMessage('账号已存在,请更换账号');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (formMode === 'create') {
|
||||
await api.createUser({ name, account, department, password });
|
||||
setMessage(`已添加用户:${name}`);
|
||||
} else if (form.id) {
|
||||
await api.updateUser(form.id, {
|
||||
name,
|
||||
account,
|
||||
department,
|
||||
...(formMode === 'password' ? { password } : {}),
|
||||
});
|
||||
setMessage(formMode === 'password' ? `已更新 ${name} 的密码` : `已更新用户:${name}`);
|
||||
}
|
||||
closeForm();
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '用户保存失败';
|
||||
setMessage(message === '账号已存在' ? '账号已存在,请更换账号' : message);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: UserRecord) => {
|
||||
if (user.account === currentAccount) {
|
||||
setMessage('不能删除当前登录用户');
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`确认删除用户 ${user.name}?该操作不可恢复。`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteUser(user.id);
|
||||
setMessage(`已删除用户:${user.name}`);
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '删除用户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
setMessage('正在恢复演示环境...');
|
||||
try {
|
||||
const result = await api.resetDemo();
|
||||
setUsers(result.users);
|
||||
setSession(await api.getSession());
|
||||
setMessage('演示环境已恢复:默认用户、Head_CT_DICOM 与 Head_CT_ReConstruct 项目已重新载入');
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : '恢复失败');
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">系统管理工作区</h2>
|
||||
<p className="mt-1 text-slate-500">{message}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
className="flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-100 px-5 py-2.5 text-sm font-semibold text-amber-700 transition-all hover:bg-amber-200 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
{resetting ? '正在恢复' : '恢复演示环境出厂设置'}
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateForm}
|
||||
className="flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-blue-700"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
添加用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-4 border-b border-slate-100 p-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名、账号、科室..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 py-2 pl-10 pr-4 text-sm outline-none transition-all focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs font-bold text-slate-500">
|
||||
<Shield size={16} />
|
||||
当前账号:{currentAccount || '未同步'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 text-xs font-bold uppercase tracking-wider text-slate-500">
|
||||
<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">
|
||||
{filteredUsers.map((user, index) => {
|
||||
const isCurrentUser = user.account === currentAccount;
|
||||
return (
|
||||
<motion.tr
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
className="transition-colors hover:bg-slate-50/50"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 font-bold text-blue-600">
|
||||
{user.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800">{user.name}</p>
|
||||
{isCurrentUser && <p className="text-[10px] font-bold text-blue-600">当前登录</p>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-mono text-sm text-slate-500">{user.account}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="rounded-lg bg-slate-100 px-2 py-1 text-sm font-medium text-slate-600">
|
||||
{user.department}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Calendar size={14} />
|
||||
{user.date}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => openEditForm(user)}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 transition-colors hover:text-blue-600"
|
||||
title="编辑信息"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
编辑
|
||||
</button>
|
||||
<div className="h-4 w-[1px] bg-slate-200" />
|
||||
<button
|
||||
onClick={() => openPasswordForm(user)}
|
||||
className="text-slate-400 transition-colors hover:text-amber-600"
|
||||
title="修改密码"
|
||||
>
|
||||
<Key size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={isCurrentUser}
|
||||
className="text-slate-400 transition-colors hover:text-rose-600 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
title={isCurrentUser ? '不能删除当前登录用户' : '删除用户'}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-50 bg-slate-50/50 p-4 text-sm text-slate-500">
|
||||
<p>共 {filteredUsers.length} / {users.length} 条数据</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
用户管理操作实时写入后端状态
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formMode && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h3 className="font-bold text-slate-900">
|
||||
{formMode === 'create' && '添加用户'}
|
||||
{formMode === 'edit' && '编辑用户'}
|
||||
{formMode === 'password' && '修改密码'}
|
||||
</h3>
|
||||
<button onClick={closeForm} className="text-slate-400 hover:text-slate-700" title="关闭">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{formMode !== 'password' ? (
|
||||
<>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-bold text-slate-500">姓名</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="请输入用户姓名"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-bold text-slate-500">账号</span>
|
||||
<input
|
||||
value={form.account}
|
||||
onChange={(event) => setForm((current) => ({ ...current, account: event.target.value }))}
|
||||
placeholder="请输入登录账号"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-bold text-slate-500">科室</span>
|
||||
<input
|
||||
value={form.department}
|
||||
onChange={(event) => setForm((current) => ({ ...current, department: event.target.value }))}
|
||||
placeholder="请输入所属科室"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
{formMode === 'create' && (
|
||||
<>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-bold text-slate-500">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
|
||||
placeholder="请输入初始密码"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-bold text-slate-500">确认密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) => setForm((current) => ({ ...current, confirmPassword: event.target.value }))}
|
||||
placeholder="请再次输入初始密码"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<p className="text-xs font-bold text-slate-400">当前修改对象</p>
|
||||
<div className="mt-2 grid gap-2 text-sm font-semibold text-slate-700">
|
||||
<span>{form.name}</span>
|
||||
<span className="font-mono text-slate-500">{form.account}</span>
|
||||
<span className="text-slate-500">{form.department}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-bold text-slate-500">新密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
|
||||
placeholder="请输入新密码"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-500/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-bold text-slate-500">确认新密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) => setForm((current) => ({ ...current, confirmPassword: event.target.value }))}
|
||||
placeholder="请再次输入新密码"
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-500/20"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button onClick={closeForm} className="rounded-xl px-4 py-2 text-sm font-bold text-slate-600 hover:bg-slate-100">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveUser}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
WebSite/src/index.css
Normal file
242
WebSite/src/index.css
Normal file
@@ -0,0 +1,242 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
.dicom-range-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dicom-range-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dicom-range-input::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dicom-range-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
height: 20px;
|
||||
margin-top: -6px;
|
||||
pointer-events: auto;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.dicom-range-input::-moz-range-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dicom-range-input::-moz-range-thumb {
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
height: 14px;
|
||||
pointer-events: auto;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.dicom-range-input:active::-webkit-slider-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dicom-range-input:active::-moz-range-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-slice-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 22px;
|
||||
margin-top: -7px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-moz-range-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-moz-range-thumb {
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.mapping-slice-input:active::-webkit-slider-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-input:active::-moz-range-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
direction: rtl;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin: 0 auto;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
height: 22px;
|
||||
margin-left: -7px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input::-moz-range-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input::-moz-range-thumb {
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input:active::-webkit-slider-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input:active::-moz-range-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 30px;
|
||||
direction: rtl;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin: 0 auto;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 20px;
|
||||
margin-left: -7px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input::-moz-range-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input::-moz-range-thumb {
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input:active::-webkit-slider-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input:active::-moz-range-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
226
WebSite/src/lib/api.ts
Normal file
226
WebSite/src/lib/api.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
|
||||
|
||||
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
|
||||
export type SegmentationExportMode = 'combined' | 'separate';
|
||||
export type ProjectAssetImportKind = 'dicom' | 'stl';
|
||||
export type { SegmentationExportScope } from '../types';
|
||||
|
||||
export interface ProjectAssetImportProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `请求失败:${response.status}`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
}
|
||||
} catch {
|
||||
// Keep the status-based message when the response is not JSON.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function parseXhrError(xhr: XMLHttpRequest) {
|
||||
let message = `请求失败:${xhr.status}`;
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
}
|
||||
} catch {
|
||||
if (xhr.responseText) {
|
||||
message = xhr.responseText.slice(0, 240);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function uploadProjectAssetFiles(
|
||||
projectId: string,
|
||||
kind: ProjectAssetImportKind,
|
||||
files: File[],
|
||||
onProgress?: (progress: ProjectAssetImportProgress) => void,
|
||||
) {
|
||||
return new Promise<Project>((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('kind', kind);
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file, file.name);
|
||||
});
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `/api/projects/${projectId}/import-assets`);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
const total = event.lengthComputable ? event.total : files.reduce((sum, file) => sum + file.size, 0);
|
||||
const loaded = event.lengthComputable ? event.loaded : Math.min(total, files.reduce((sum, file) => sum + file.size, 0));
|
||||
const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||
onProgress?.({ loaded, total, percent });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText) as Project);
|
||||
} catch {
|
||||
reject(new Error('导入响应解析失败'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
reject(new Error(parseXhrError(xhr)));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络连接中断,导入失败'));
|
||||
xhr.onabort = () => reject(new Error('导入已取消'));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSession: () => request<SessionState>('/api/session'),
|
||||
login: (account: string, password: string) =>
|
||||
request<SessionState>('/api/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ account, password }),
|
||||
}),
|
||||
logout: () => request<SessionState>('/api/logout', { method: 'POST' }),
|
||||
getOverview: () => request<OverviewSummary>('/api/overview'),
|
||||
getProjects: () => request<Project[]>('/api/projects'),
|
||||
getProject: (projectId: string) => request<Project>(`/api/projects/${projectId}`),
|
||||
createProject: (name: string) =>
|
||||
request<Project>('/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
renameProject: (projectId: string, name: string) =>
|
||||
request<Project>(`/api/projects/${projectId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
deleteProject: (projectId: string) =>
|
||||
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
updateProjectModuleStyles: (projectId: string, moduleStyles: Record<string, ModuleStyle>) =>
|
||||
request<Project>(`/api/projects/${projectId}/module-styles`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ moduleStyles }),
|
||||
}),
|
||||
updateProjectModelPoses: (projectId: string, modelPoses: SavedModelPose[]) =>
|
||||
request<Project>(`/api/projects/${projectId}/model-poses`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ modelPoses }),
|
||||
}),
|
||||
importProjectAssets: (
|
||||
projectId: string,
|
||||
kind: ProjectAssetImportKind,
|
||||
files: File[],
|
||||
onProgress?: (progress: ProjectAssetImportProgress) => void,
|
||||
) => uploadProjectAssetFiles(projectId, kind, files, onProgress),
|
||||
saveProjectSegmentationResult: (
|
||||
projectId: string,
|
||||
payload: {
|
||||
name?: string;
|
||||
pose: ModelPose;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
sliceStart?: number;
|
||||
sliceEnd?: number;
|
||||
mappingSlice?: number;
|
||||
displayLevel?: SegmentationDisplayLevel;
|
||||
dicomOpacityLevel?: SegmentationDicomOpacityLevel;
|
||||
showBounds?: boolean;
|
||||
cutEnabled?: boolean;
|
||||
},
|
||||
) =>
|
||||
request<Project>(`/api/projects/${projectId}/segmentation-results`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
|
||||
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
|
||||
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
|
||||
request<DicomFusionVolume>(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`),
|
||||
getDicomInfo: (projectId: string) => request<DicomInfo>(`/api/projects/${projectId}/dicom-info`),
|
||||
getUsers: () => request<UserRecord[]>('/api/users'),
|
||||
createUser: (payload: { name: string; account: string; department: string; password: string }) =>
|
||||
request<UserRecord>('/api/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateUser: (userId: number, payload: { name: string; account: string; department: string; password?: string }) =>
|
||||
request<UserRecord>(`/api/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteUser: (userId: number) =>
|
||||
request<{ ok: boolean; deletedId: number }>(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
resetDemo: () =>
|
||||
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
function triggerFileDownload(url: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function appendPose(params: URLSearchParams, pose?: ModelPose) {
|
||||
if (pose) {
|
||||
params.set('pose', JSON.stringify(pose));
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz', pose?: ModelPose, segmentationScope: SegmentationExportScope = 'visible') {
|
||||
const params = new URLSearchParams({ format });
|
||||
appendPose(params, pose);
|
||||
params.set('segmentationScope', segmentationScope);
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
|
||||
const params = new URLSearchParams({ target, format });
|
||||
if (target === 'segmentation' || target === 'pose') {
|
||||
appendPose(params, options.pose);
|
||||
}
|
||||
if (target === 'segmentation') {
|
||||
params.set('segmentationScope', options.segmentationScope ?? 'visible');
|
||||
params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined');
|
||||
}
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
targets: targets.join(','),
|
||||
format,
|
||||
segmentationScope: options.segmentationScope ?? 'visible',
|
||||
segmentationExportMode: options.segmentationExportMode ?? 'combined',
|
||||
});
|
||||
appendPose(params, options.pose);
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadDicomArchive(projectId: string) {
|
||||
triggerFileDownload(`/api/projects/${projectId}/dicom-archive`);
|
||||
}
|
||||
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>,
|
||||
);
|
||||
201
WebSite/src/types.ts
Normal file
201
WebSite/src/types.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
dicomPath?: string;
|
||||
modelPath?: string;
|
||||
modelCount?: number;
|
||||
stlFiles?: string[];
|
||||
maskFormats?: Array<'nii' | 'nii.gz'>;
|
||||
exportedMaskCount?: number;
|
||||
isDefault?: boolean;
|
||||
moduleStyles?: Record<string, ModuleStyle>;
|
||||
modelPoses?: SavedModelPose[];
|
||||
segmentationResults?: SegmentationResult[];
|
||||
}
|
||||
|
||||
export interface ModuleStyle {
|
||||
visible: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
partId: number;
|
||||
}
|
||||
|
||||
export interface ModelPose {
|
||||
rotateX: number;
|
||||
rotateY: number;
|
||||
rotateZ: number;
|
||||
translateX: number;
|
||||
translateY: number;
|
||||
translateZ: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface SavedModelPose {
|
||||
id: string;
|
||||
name: string;
|
||||
pose: ModelPose;
|
||||
}
|
||||
|
||||
export type SegmentationExportScope = 'all' | 'visible';
|
||||
export type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface SegmentationResult {
|
||||
id: string;
|
||||
schemaVersion?: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
pose: ModelPose;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
sliceStart?: number;
|
||||
sliceEnd?: number;
|
||||
mappingSlice?: number;
|
||||
displayLevel?: SegmentationDisplayLevel;
|
||||
dicomOpacityLevel?: SegmentationDicomOpacityLevel;
|
||||
showBounds?: boolean;
|
||||
cutEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface MaskMapping {
|
||||
className: string;
|
||||
color: string;
|
||||
maskId: number;
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
account: string;
|
||||
department: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
authenticated: boolean;
|
||||
currentUser: Omit<UserRecord, 'date'> | null;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface OverviewSummary {
|
||||
totalProjects: number;
|
||||
processedProjects: number;
|
||||
exportedMaskProjects: number;
|
||||
dicomCount: number;
|
||||
modelCount: number;
|
||||
chartData: Array<{
|
||||
name: string;
|
||||
projects: number;
|
||||
processing: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DicomPreview {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: string;
|
||||
plane: 'axial' | 'sagittal' | 'coronal';
|
||||
mode: 'default' | 'bone' | 'soft' | 'contrast';
|
||||
slice: number;
|
||||
total: number;
|
||||
fileName: string;
|
||||
windowCenter: number;
|
||||
windowWidth: number;
|
||||
spacing?: {
|
||||
row: number;
|
||||
column: number;
|
||||
slice: number;
|
||||
displayX?: number;
|
||||
displayY?: number;
|
||||
};
|
||||
physicalSize?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DicomFusionVolume {
|
||||
width: number;
|
||||
height: number;
|
||||
start: number;
|
||||
end: number;
|
||||
total: number;
|
||||
indices: number[];
|
||||
frames: string[];
|
||||
mode: DicomPreview['mode'];
|
||||
spacing: {
|
||||
row: number;
|
||||
column: number;
|
||||
slice: number;
|
||||
};
|
||||
physicalSize: {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
unit: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DicomInfo {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
dicomPath: string;
|
||||
};
|
||||
patient: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
study: {
|
||||
date: string;
|
||||
description: string;
|
||||
modality: string;
|
||||
manufacturer: string;
|
||||
};
|
||||
series: {
|
||||
description: string;
|
||||
files: number;
|
||||
firstFile: string;
|
||||
lastFile: string;
|
||||
};
|
||||
image: {
|
||||
rows: number;
|
||||
columns: number;
|
||||
bitsAllocated: number;
|
||||
pixelRepresentation: number;
|
||||
windowCenter: number;
|
||||
windowWidth: number;
|
||||
rescaleIntercept: number;
|
||||
rescaleSlope: number;
|
||||
};
|
||||
spacing: {
|
||||
row: number | null;
|
||||
column: number | null;
|
||||
slice: number | null;
|
||||
sliceSource: string;
|
||||
sliceThickness: number | null;
|
||||
spacingBetweenSlices: number | null;
|
||||
};
|
||||
physicalSize: {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
depth: number | null;
|
||||
unit: string;
|
||||
};
|
||||
position: {
|
||||
firstImagePosition: number[] | null;
|
||||
lastImagePosition: number[] | null;
|
||||
};
|
||||
}
|
||||
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