2026-05-21-11-13-49 独立Docker程序包

This commit is contained in:
Codex
2026-05-21 11:18:50 +08:00
commit 57415a1a0b
337 changed files with 16747 additions and 0 deletions

8
WebSite/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

62
WebSite/README.md Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"name": "模型逆向系统",
"description": "基于模型逆向体素化及DICOM分割标注系统支持DICOM与STL模型配准、可视化、微调及分割影像导出。",
"requestFramePermissions": [],
"majorCapabilities": []
}

5554
WebSite/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
WebSite/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

2965
WebSite/server.ts Normal file

File diff suppressed because it is too large Load Diff

200
WebSite/src/App.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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
View 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
View 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
View File

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

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

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

201
WebSite/src/types.ts Normal file
View 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
View 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
View 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',
},
};
});