2026-05-04-03-21-40 增加前后端协同和NIfTI导出

This commit is contained in:
2026-05-04 03:29:54 +08:00
parent a6f3836460
commit a9b6d2d76a
15 changed files with 1040 additions and 67 deletions

View File

@@ -5,6 +5,7 @@
"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",

350
WebSite/server.ts Normal file
View File

@@ -0,0 +1,350 @@
import express from 'express';
import { createServer as createViteServer } from 'vite';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
type ProjectStatus = 'pending' | 'completed' | 'processing';
interface UserRecord {
id: number;
name: string;
account: string;
password: string;
department: string;
date: string;
}
interface ProjectRecord {
id: string;
name: string;
createTime: string;
status: ProjectStatus;
dicomCount: number;
hasModel: boolean;
dicomPath: string;
modelPath: string;
modelCount: number;
stlFiles: string[];
maskFormats: Array<'nii' | 'nii.gz'>;
}
interface SessionRecord {
authenticated: boolean;
account: string | null;
lastUpdated: string;
}
interface AppState {
users: UserRecord[];
projects: ProjectRecord[];
session: SessionRecord;
updatedAt: string;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const dataDir = path.join(__dirname, 'data');
const exportDir = path.join(__dirname, 'exports');
const statePath = path.join(dataDir, 'state.json');
const dicomDir = path.join(repoRoot, 'Head_CT_DICOM');
const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct');
function today() {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date());
}
function now() {
return new Date().toISOString();
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
function listFiles(dir: string, extension: string) {
if (!fs.existsSync(dir)) {
return [];
}
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
}
function publicUser(user: UserRecord) {
const { password: _password, ...rest } = user;
return rest;
}
function publicSession(state: AppState) {
const user = state.session.account
? state.users.find((candidate) => candidate.account === state.session.account)
: null;
return {
authenticated: state.session.authenticated && Boolean(user),
currentUser: user
? {
id: user.id,
name: user.name,
account: user.account,
department: user.department,
}
: null,
lastUpdated: state.session.lastUpdated,
};
}
function buildDefaultProject(): ProjectRecord {
const stlFiles = listFiles(modelDir, '.stl');
return {
id: 'head-ct-demo',
name: '头部 CT 模型逆向体素化演示',
createTime: today(),
status: 'completed',
dicomCount: listFiles(dicomDir, '.dcm').length,
hasModel: stlFiles.length > 0,
dicomPath: 'Head_CT_DICOM',
modelPath: 'Head_CT_ReConstruct',
modelCount: stlFiles.length,
stlFiles,
maskFormats: ['nii', 'nii.gz'],
};
}
function defaultState(): AppState {
return {
users: [
{ id: 1, name: 'Admin', account: 'admin', password: '123456', department: 'admin', date: today() },
{ id: 2, name: 'Doctor Li', account: 'doctor_li', password: '123456', department: '肝胆外科', date: today() },
],
projects: [buildDefaultProject()],
session: { authenticated: false, account: null, lastUpdated: now() },
updatedAt: now(),
};
}
function normalizeState(state: AppState): AppState {
return {
...state,
projects: [buildDefaultProject()],
};
}
function readState(): AppState {
ensureDir(dataDir);
if (!fs.existsSync(statePath)) {
const initialState = defaultState();
writeState(initialState);
return initialState;
}
try {
const raw = fs.readFileSync(statePath, 'utf8');
return normalizeState(JSON.parse(raw) as AppState);
} catch {
const recoveredState = defaultState();
writeState(recoveredState);
return recoveredState;
}
}
function writeState(state: AppState) {
ensureDir(dataDir);
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
}
function createNiftiMask(project: ProjectRecord, compressed: boolean) {
const width = 64;
const height = 64;
const depth = 64;
const headerSize = 348;
const voxOffset = 352;
const voxelCount = width * height * depth;
const data = Buffer.alloc(voxelCount);
const center = [width / 2, height / 2, depth / 2];
for (let z = 0; z < depth; z += 1) {
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const dx = (x - center[0]) / 18;
const dy = (y - center[1]) / 15;
const dz = (z - center[2]) / 20;
const index = z * width * height + y * width + x;
const radius = dx * dx + dy * dy + dz * dz;
if (radius < 1) {
data[index] = 1;
}
const tumorDx = (x - 42) / 8;
const tumorDy = (y - 30) / 7;
const tumorDz = (z - 34) / 7;
if (tumorDx * tumorDx + tumorDy * tumorDy + tumorDz * tumorDz < 1) {
data[index] = 2;
}
}
}
}
const header = Buffer.alloc(voxOffset);
header.writeInt32LE(headerSize, 0);
header.writeInt16LE(3, 40);
header.writeInt16LE(width, 42);
header.writeInt16LE(height, 44);
header.writeInt16LE(depth, 46);
header.writeInt16LE(1, 48);
header.writeInt16LE(1, 50);
header.writeInt16LE(1, 52);
header.writeInt16LE(1, 54);
header.writeInt16LE(2, 70);
header.writeInt16LE(8, 72);
header.writeFloatLE(1, 76);
header.writeFloatLE(1, 80);
header.writeFloatLE(1, 84);
header.writeFloatLE(1, 88);
header.writeFloatLE(voxOffset, 108);
header.writeFloatLE(1, 112);
header.write('ReVoxelSeg demo mask', 148, 'ascii');
header.write(`Project ${project.id}`, 228, 'ascii');
header.write('n+1\0', 344, 'ascii');
const nifti = Buffer.concat([header, data]);
return compressed ? zlib.gzipSync(nifti) : nifti;
}
async function startServer() {
const app = express();
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
const portArg = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : process.env.PORT;
const port = Number(portArg ?? 4000);
ensureDir(exportDir);
app.use(express.json());
app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'revoxelseg-dicom', time: now() });
});
app.get('/api/session', (_req, res) => {
res.json(publicSession(readState()));
});
app.post('/api/login', (req, res) => {
const { account, password } = req.body as { account?: string; password?: string };
const state = readState();
const user = state.users.find((candidate) => candidate.account === account && candidate.password === password);
if (!user) {
res.status(401).json({ message: '账号或密码错误' });
return;
}
state.session = { authenticated: true, account: user.account, lastUpdated: now() };
writeState(state);
res.json(publicSession(state));
});
app.post('/api/logout', (_req, res) => {
const state = readState();
state.session = { authenticated: false, account: null, lastUpdated: now() };
writeState(state);
res.json(publicSession(state));
});
app.get('/api/users', (_req, res) => {
res.json(readState().users.map(publicUser));
});
app.get('/api/projects', (_req, res) => {
res.json(readState().projects);
});
app.get('/api/projects/:projectId', (req, res) => {
const project = readState().projects.find((candidate) => candidate.id === req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
res.json(project);
});
app.get('/api/overview', (_req, res) => {
const state = readState();
const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0);
const modelCount = state.projects.reduce((sum, project) => sum + project.modelCount, 0);
res.json({
totalProjects: state.projects.length,
processedProjects: state.projects.filter((project) => project.status === 'completed').length,
dicomCount,
modelCount,
chartData: [
{ name: 'Mon', projects: 1, processing: 12 },
{ name: 'Tue', projects: 1, processing: 28 },
{ name: 'Wed', projects: 1, processing: 44 },
{ name: 'Thu', projects: 1, processing: 58 },
{ name: 'Fri', projects: 1, processing: 76 },
{ name: 'Sat', projects: 1, processing: 90 },
{ name: 'Sun', projects: state.projects.length, processing: 100 },
],
});
});
app.post('/api/demo/reset', (_req, res) => {
const state = defaultState();
writeState(state);
res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) });
});
app.post('/api/projects/:projectId/export-mask', (req, res) => {
const state = readState();
const project = state.projects.find((candidate) => candidate.id === req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const format = req.query.format === 'nii' ? 'nii' : 'nii.gz';
const compressed = format === 'nii.gz';
const mask = createNiftiMask(project, compressed);
const filename = `${project.id}-segmentation-mask.${format}`;
const outputPath = path.join(exportDir, filename);
fs.writeFileSync(outputPath, mask);
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(mask);
});
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
} else {
const vite = await createViteServer({
server: { middlewareMode: true, hmr: { port: 24679 } },
appType: 'spa',
});
app.use(vite.middlewares);
}
app.listen(port, host, () => {
console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`);
});
}
startServer().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -17,12 +17,14 @@ import ProjectLibrary from './components/ProjectLibrary';
import ReverseWorkspace from './components/ReverseWorkspace';
import UserManagement from './components/UserManagement';
import { ViewType } from './types';
import { } from 'lucide-react';
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');
// Automatically collapse main sidebar when entering Project Library or Workspace
useEffect(() => {
@@ -33,12 +35,56 @@ export default function App() {
}
}, [activeView]);
const handleLogin = () => setIsAuthenticated(true);
const handleLogout = () => {
useEffect(() => {
let mounted = true;
const syncSession = async () => {
try {
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 handleLogout = async () => {
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} />;
}
@@ -83,10 +129,13 @@ export default function App() {
{activeView === ViewType.OVERVIEW && <Overview />}
{activeView === ViewType.PROJECTS && (
<ProjectLibrary
onReverse={() => setActiveView(ViewType.WORKSPACE)}
onReverse={(projectId) => {
setActiveProjectId(projectId);
setActiveView(ViewType.WORKSPACE);
}}
/>
)}
{activeView === ViewType.WORKSPACE && <ReverseWorkspace />}
{activeView === ViewType.WORKSPACE && <ReverseWorkspace projectId={activeProjectId} />}
{activeView === ViewType.SYSTEM && <UserManagement />}
</motion.div>
</AnimatePresence>
@@ -95,4 +144,3 @@ export default function App() {
</div>
);
}

View File

@@ -1,6 +1,7 @@
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;
@@ -10,14 +11,20 @@ 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 = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setTimeout(() => {
setError('');
try {
await api.login(username, password);
onLogin();
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败');
} finally {
setLoading(false);
}, 800);
}
};
return (
@@ -85,6 +92,9 @@ export default function Login({ onLogin }: LoginProps) {
'立即登录'
)}
</button>
{error && (
<p className="text-sm text-rose-600 text-center font-medium">{error}</p>
)}
</form>
</div>

View File

@@ -1,28 +1,31 @@
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import {
FolderRoot,
CheckCircle2,
Activity,
Database
Database,
Box
} from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const stats = [
{ label: '项目总数', value: '128', icon: FolderRoot, color: 'bg-blue-500', trend: '+12%' },
{ label: '已处理项目总数', value: '15,420', icon: Database, color: 'bg-indigo-500', trend: '+5%' },
];
const chartData = [
{ name: 'Mon', projects: 4, processing: 40 },
{ name: 'Tue', projects: 7, processing: 30 },
{ name: 'Wed', projects: 5, processing: 60 },
{ name: 'Thu', projects: 8, processing: 45 },
{ name: 'Fri', projects: 12, processing: 80 },
{ name: 'Sat', projects: 9, processing: 50 },
{ name: 'Sun', projects: 6, processing: 35 },
];
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: '已处理项目总数', value: String(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">
@@ -32,7 +35,7 @@ export default function Overview() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{stats.map((stat, i) => (
<motion.div
key={stat.label}
@@ -45,9 +48,7 @@ export default function Overview() {
<div className={stat.color + " p-3 rounded-xl text-white shadow-lg"}>
<stat.icon size={24} />
</div>
<span className={`text-xs font-bold px-2 py-1 rounded-full ${
stat.trend.startsWith('+') ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'
}`}>
<span className="text-xs font-bold px-2 py-1 rounded-full bg-emerald-50 text-emerald-600">
{stat.trend}
</span>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Plus,
@@ -19,6 +19,7 @@ import {
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage, Gltf, useGLTF, Environment, PerspectiveCamera } from '@react-three/drei';
import { Project } from '../types';
import { api } from '../lib/api';
// Mock 3D Model component
function ModelPreview() {
@@ -32,15 +33,42 @@ function ModelPreview() {
export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) {
const [search, setSearch] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [viewMode, setViewMode] = useState<'dicom' | 'model'>('dicom');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [sliceIndex, setSliceIndex] = useState(42);
const [visibleModules, setVisibleModules] = useState<Record<string, boolean>>({
'颅骨整体': true, '牙弓': true, '左颧骨': true, '右颧骨': true, '下颌骨': true, '蝶骨': true, '筛骨': true
});
const [visibleModules, setVisibleModules] = useState<Record<string, boolean>>({});
const subModules = ['颅骨整体', '牙弓', '左颧骨', '右颧骨', '下颌骨', '蝶骨', '筛骨'];
useEffect(() => {
api.getProjects()
.then((items) => {
setProjects(items);
setSelectedProject(items[0] ?? null);
})
.finally(() => setLoading(false));
}, []);
const filteredProjects = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return projects;
}
return projects.filter((project) => project.name.toLowerCase().includes(keyword));
}, [projects, search]);
const subModules = selectedProject?.stlFiles?.length
? selectedProject.stlFiles.map((file) => file.replace(/\.stl$/i, ''))
: [];
useEffect(() => {
const next: Record<string, boolean> = {};
subModules.forEach((module) => {
next[module] = visibleModules[module] ?? true;
});
setVisibleModules(next);
}, [selectedProject?.id]);
const toggleModule = (name: string) => {
setVisibleModules(prev => ({ ...prev, [name]: !prev[name] }));
@@ -53,13 +81,6 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
setVisibleModules(newState);
};
const projects: Project[] = [
{ id: '1', name: '大腿骨折复位三维重建', createTime: '2024-03-20', status: 'completed', dicomCount: 156, hasModel: true },
{ id: '2', name: '牙齿正畸扫描数据', createTime: '2024-03-21', status: 'pending', dicomCount: 42, hasModel: true },
{ id: '3', name: '测试项目_胸腔扫描', createTime: '2024-03-22', status: 'processing', dicomCount: 210, hasModel: false },
{ id: '4', name: '脑膜瘤切除规划', createTime: '2024-03-23', status: 'completed', dicomCount: 320, hasModel: true },
];
return (
<div className="h-full flex gap-6 overflow-hidden">
{/* Project Sidebar - Collapsible */}
@@ -89,7 +110,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
{projects.map((proj) => (
{loading && <p className="text-xs text-slate-400 px-2">...</p>}
{filteredProjects.map((proj) => (
<button
key={proj.id}
onClick={() => setSelectedProject(proj)}
@@ -101,7 +123,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{proj.name}
</p>
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
{proj.createTime}
{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
</p>
</button>
))}
@@ -111,7 +133,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{isSidebarCollapsed && (
<div className="flex flex-col items-center py-12 gap-4">
{projects.map(p => (
{filteredProjects.map(p => (
<div
key={p.id}
onClick={() => setSelectedProject(p)}
@@ -170,6 +192,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
<p>SCAN DATE: {selectedProject.createTime}</p>
<p>DICOM PATH: {selectedProject.dicomPath}</p>
</div>
<div className="relative w-full h-full flex items-center justify-center">
<div className="w-64 h-64 border-2 border-white/5 rounded-full absolute animate-pulse" />
@@ -178,14 +201,14 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: 400/40</span>
<span>SLICE: {sliceIndex}/128</span>
<span>SLICE: {sliceIndex}/{selectedProject.dicomCount}</span>
</div>
</div>
{/* Right: Vertical Progress Bar */}
<div className="w-16 h-full flex flex-col items-center py-2 bg-slate-50 rounded-2xl">
<span className="text-[10px] text-slate-400 font-bold mb-4">NAV</span>
<input
type="range" min="0" max="128" value={sliceIndex}
type="range" min="0" max={Math.max(selectedProject.dicomCount, 1)} value={sliceIndex}
onChange={(e) => setSliceIndex(Number(e.target.value))}
className="flex-1 w-1.5 appearance-none bg-slate-200 rounded-full focus:outline-none accent-blue-600 cursor-pointer"
style={{ writingMode: 'bt-lr' as any }}
@@ -204,7 +227,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<OrbitControls />
</Canvas>
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
POLYGONS: 2.1M | VERTS: 1.2M
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0}
</div>
</div>
{/* Right: Sub-module List */}
@@ -232,7 +255,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-bold text-slate-700 truncate">{m}</p>
<p className="text-[9px] text-slate-400">STL | {i * 4 + 2} MB</p>
<p className="text-[9px] text-slate-400">STL | {selectedProject.stlFiles?.[i]}</p>
</div>
<div className="flex items-center gap-2">
<div className={`w-1.5 h-1.5 rounded-full ${i === 4 ? 'bg-amber-400' : 'bg-emerald-500'}`} />

View File

@@ -16,7 +16,8 @@ import {
} from 'lucide-react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage, PerspectiveCamera, Grid } from '@react-three/drei';
import { MaskMapping } from '../types';
import { MaskMapping, Project } from '../types';
import { api, downloadMask } from '../lib/api';
function InteractiveModel({ offset }: { offset: [number, number, number] }) {
return (
@@ -31,11 +32,14 @@ function InteractiveModel({ offset }: { offset: [number, number, number] }) {
);
}
export default function ReverseWorkspace() {
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [slice, setSlice] = useState(50);
const [isRegistering, setIsRegistering] = useState(false);
const [progress, setProgress] = useState(0);
const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
const [project, setProject] = useState<Project | null>(null);
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = useState('准备就绪');
const [mappings, setMappings] = useState<MaskMapping[]>([
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
@@ -48,6 +52,23 @@ export default function ReverseWorkspace() {
setProgress(0);
};
const handleExport = async (format: 'nii' | 'nii.gz') => {
setExporting(true);
setExportMessage(`正在生成 ${format.toUpperCase()} 分割 Mask...`);
try {
await downloadMask(projectId, format);
setExportMessage(`${format.toUpperCase()} 分割 Mask 已生成并开始下载`);
} catch (err) {
setExportMessage(err instanceof Error ? err.message : '导出失败');
} finally {
setExporting(false);
}
};
useEffect(() => {
api.getProject(projectId).then(setProject).catch(() => setProject(null));
}, [projectId]);
useEffect(() => {
if (isRegistering && progress < 100) {
const timer = setTimeout(() => setProgress(p => p + 2), 50);
@@ -62,7 +83,9 @@ export default function ReverseWorkspace() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="text-slate-500 mt-1"> DICOM </p>
<p className="text-slate-500 mt-1">
{project ? `${project.name} · ${project.dicomPath}${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
</p>
</div>
<div className="flex gap-2">
<button
@@ -75,9 +98,13 @@ export default function ReverseWorkspace() {
) : <Dices size={18} />}
{isRegistering ? `正在自动配准 (${progress}%)` : '开始自动配准'}
</button>
<button className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2">
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
<Download size={18} />
{exporting ? '正在导出' : '导出 NII.GZ'}
</button>
</div>
</div>
@@ -170,7 +197,7 @@ export default function ReverseWorkspace() {
<span className="text-[10px] font-bold text-white/50 uppercase tracking-widest">Metadata</span>
</div>
<pre className="text-[9px] text-blue-300/70 font-mono overflow-hidden">
{`{ id: "SM_091", voxel: 124K }`}
{`{ project: "${project?.id ?? projectId}", format: "nii.gz" }`}
</pre>
</div>
</div>
@@ -184,11 +211,19 @@ export default function ReverseWorkspace() {
Mask
</h3>
<div className="flex gap-2">
<button className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1">
<button
onClick={() => handleExport('nii')}
disabled={exporting}
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
>
<Download size={12} />
NII ()
</button>
<button className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg">
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
>
<Download size={12} />
NII.GZ ()
</button>
@@ -250,7 +285,7 @@ export default function ReverseWorkspace() {
<div className="h-16 shrink-0 bg-white rounded-2xl border border-slate-100 shadow-sm flex items-center justify-between px-6">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest"></span>
<span className="text-xs font-bold text-slate-700"> {mappings.length} </span>
<span className="text-xs font-bold text-slate-700">{exportMessage} {mappings.length} </span>
</div>
<div className="w-32 bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div className="bg-blue-600 h-full w-[100%]" />

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import {
Users,
@@ -11,26 +12,51 @@ import {
Trash2,
Key
} from 'lucide-react';
const today = new Date().toISOString().split('T')[0];
const users = [
{ id: 1, name: 'Admin', account: 'admin', department: 'admin', date: today },
{ id: 2, name: 'Doctor Li', account: 'doctor_li', department: '肝胆外科', date: today },
];
import { api } from '../lib/api';
import { UserRecord } from '../types';
export default function UserManagement() {
const [users, setUsers] = useState<UserRecord[]>([]);
const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步');
const [resetting, setResetting] = useState(false);
const refreshUsers = () => {
api.getUsers().then(setUsers).catch(() => setMessage('用户列表同步失败'));
};
useEffect(() => {
refreshUsers();
}, []);
const handleReset = async () => {
setResetting(true);
setMessage('正在恢复演示环境...');
try {
const result = await api.resetDemo();
setUsers(result.users);
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">
<div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="text-slate-500 mt-1"></p>
<p className="text-slate-500 mt-1">{message}</p>
</div>
<div className="flex gap-3">
<button className="bg-amber-100 text-amber-700 px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-amber-200 transition-all flex items-center gap-2 border border-amber-200">
<button
onClick={handleReset}
disabled={resetting}
className="bg-amber-100 text-amber-700 px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-amber-200 transition-all flex items-center gap-2 border border-amber-200 disabled:opacity-50"
>
<RotateCcw size={18} />
{resetting ? '正在恢复' : '恢复演示环境出厂设置'}
</button>
<button className="bg-blue-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2">
<UserPlus size={18} />

67
WebSite/src/lib/api.ts Normal file
View File

@@ -0,0 +1,67 @@
import { OverviewSummary, Project, SessionState, UserRecord } from '../types';
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>;
}
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}`),
getUsers: () => request<UserRecord[]>('/api/users'),
resetDemo: () =>
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {
method: 'POST',
}),
};
export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' = 'nii.gz') {
const response = await fetch(`/api/projects/${projectId}/export-mask?format=${encodeURIComponent(format)}`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`导出失败:${response.status}`);
}
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition') ?? '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? `segmentation-mask.${format}`;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}

View File

@@ -13,6 +13,11 @@ export interface Project {
thumbnail?: string;
dicomCount: number;
hasModel: boolean;
dicomPath?: string;
modelPath?: string;
modelCount?: number;
stlFiles?: string[];
maskFormats?: Array<'nii' | 'nii.gz'>;
}
export interface MaskMapping {
@@ -20,3 +25,29 @@ export interface MaskMapping {
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;
dicomCount: number;
modelCount: number;
chartData: Array<{
name: string;
projects: number;
processing: number;
}>;
}