2026-05-04-03-21-40 增加前后端协同和NIfTI导出
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,6 +6,10 @@ WebSite/node_modules/
|
||||
dist/
|
||||
WebSite/dist/
|
||||
|
||||
# Runtime backend state and generated exports
|
||||
WebSite/data/
|
||||
WebSite/exports/
|
||||
|
||||
# Local env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -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
350
WebSite/server.ts
Normal 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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}`} />
|
||||
|
||||
@@ -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%]" />
|
||||
|
||||
@@ -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
67
WebSite/src/lib/api.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
105
工程分析/实现方案-2026-05-04-03-21-40.md
Normal file
105
工程分析/实现方案-2026-05-04-03-21-40.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 实现方案
|
||||
|
||||
时间戳:2026-05-04-03-21-40
|
||||
|
||||
## 修改目标
|
||||
|
||||
将当前前端静态演示升级为前后端协调系统:
|
||||
|
||||
- 后端统一管理登录状态、用户列表、项目列表和演示环境。
|
||||
- 项目列表默认载入 `Head_CT_DICOM` 与 `Head_CT_ReConstruct`。
|
||||
- 恢复演示环境出厂设置后恢复默认项目和默认用户。
|
||||
- 逆向工作区通过后端生成并下载 `.nii.gz` 分割 mask。
|
||||
- 继续部署到 `http://192.168.3.11:4000/`。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/package.json`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/Login.tsx`
|
||||
- `WebSite/src/components/Overview.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- `WebSite/data/state.json`
|
||||
- `WebSite/exports/`
|
||||
- `.gitignore`
|
||||
- `工程分析/测试方案-2026-05-04-03-21-40.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 新增 Express 后端 `server.ts`。
|
||||
- 开发环境通过 Vite 中间件服务前端。
|
||||
- API 和页面共用 `4000` 端口,避免跨域和前后端端口不一致。
|
||||
2. 后端状态持久化。
|
||||
- 使用 `WebSite/data/state.json` 保存演示状态。
|
||||
- 默认用户包括 `admin / 123456`。
|
||||
- 当前登录状态由后端统一维护,所有浏览器轮询 `/api/session` 同步。
|
||||
3. 默认项目载入。
|
||||
- 后端扫描 `../Head_CT_DICOM` 的 `.dcm` 数量。
|
||||
- 后端扫描 `../Head_CT_ReConstruct` 的 `.stl` 文件列表。
|
||||
- 构造默认项目 `head-ct-demo`,包含 DICOM/STL 路径、数量、模块名称。
|
||||
4. 恢复演示环境出厂设置。
|
||||
- `POST /api/demo/reset` 重建默认用户、默认项目、登录状态。
|
||||
- 前端系统管理页点击按钮后调用该接口,并刷新用户和项目相关数据。
|
||||
5. 前端 API 化。
|
||||
- `Login` 调用 `/api/login`。
|
||||
- `App` 调用 `/api/session` 并轮询同步登录状态。
|
||||
- `Overview` 调用 `/api/overview`。
|
||||
- `ProjectLibrary` 调用 `/api/projects`。
|
||||
- `UserManagement` 调用 `/api/users` 与 `/api/demo/reset`。
|
||||
- `ReverseWorkspace` 调用 `/api/projects/:id` 与 `/api/projects/:id/export-mask`。
|
||||
6. NIfTI 导出。
|
||||
- 后端生成 NIfTI-1 单文件 `.nii.gz`。
|
||||
- 使用 Node `zlib.gzipSync` 压缩。
|
||||
- 文件保存到 `WebSite/exports/`,同时以 attachment 下载。
|
||||
- NIfTI 内容先采用演示 mask 体素数据,尺寸、标签信息和项目元数据由后端生成。
|
||||
7. 部署。
|
||||
- `npm run build`
|
||||
- `npm run lint`
|
||||
- 使用 `tmux` 运行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
|
||||
## 数据流
|
||||
|
||||
登录:
|
||||
|
||||
前端登录页 -> `POST /api/login` -> 后端验证账号密码 -> 更新共享 session -> 所有浏览器轮询 `/api/session` 后同步状态。
|
||||
|
||||
项目:
|
||||
|
||||
前端项目库 -> `GET /api/projects` -> 后端扫描或读取默认项目状态 -> 展示 DICOM 数量、STL 模块、路径和状态。
|
||||
|
||||
重置:
|
||||
|
||||
系统管理页 -> `POST /api/demo/reset` -> 后端重建默认状态 -> 前端刷新用户和项目。
|
||||
|
||||
导出:
|
||||
|
||||
逆向工作区 -> `POST /api/projects/:id/export-mask` -> 后端生成 `.nii.gz` -> 前端下载。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 保留 Vite/React 前端结构,后端通过同端口中间件接入。
|
||||
- DICOM/STL 仍不进入 Git。
|
||||
- `WebSite/data/state.json` 和 `WebSite/exports/` 作为运行态文件默认不纳入提交。
|
||||
- 若后端服务异常,可回滚 `server.ts` 和前端 API 调用改动,恢复纯前端 Vite 部署。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增 `WebSite/server.ts`
|
||||
- 新增 `WebSite/src/lib/api.ts`
|
||||
- 修改 `WebSite/package.json`
|
||||
- 修改 `WebSite/src/types.ts`
|
||||
- 修改主要页面组件以接入 API。
|
||||
- 修改 `.gitignore` 排除运行态数据和导出文件。
|
||||
- 更新工程分析文档和经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求需求分析、实现方案、测试方案、执行修改均不用人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
132
工程分析/测试方案-2026-05-04-03-21-40.md
Normal file
132
工程分析/测试方案-2026-05-04-03-21-40.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 测试方案
|
||||
|
||||
时间戳:2026-05-04-03-21-40
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证系统已经从纯前端静态演示升级为前后端协调系统,并确认登录同步、默认项目、恢复出厂设置、NIfTI 导出和部署均可用。
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `server.ts` 是否提供 API 和前端页面服务。
|
||||
- 检查 `src/lib/api.ts` 是否统一封装 API 请求。
|
||||
- 检查项目列表、系统管理、逆向工作区是否不再依赖硬编码假数据。
|
||||
- 检查 `.gitignore` 是否排除 `WebSite/data/`、`WebSite/exports/`、DICOM、STL、依赖和构建产物。
|
||||
|
||||
## 构建与类型检查
|
||||
|
||||
在 `WebSite/` 下执行:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- TypeScript 类型检查通过。
|
||||
- Vite 构建通过。
|
||||
|
||||
## API 验证
|
||||
|
||||
启动后端服务后验证:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:4000/api/health
|
||||
curl -s http://127.0.0.1:4000/api/session
|
||||
curl -s http://127.0.0.1:4000/api/projects
|
||||
curl -s http://127.0.0.1:4000/api/users
|
||||
curl -s -X POST http://127.0.0.1:4000/api/demo/reset
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- health 返回 `ok`。
|
||||
- projects 包含默认项目 `head-ct-demo`。
|
||||
- 默认项目 `dicomCount` 为 `300`。
|
||||
- 默认项目 STL 模块包含 `Head_CT_ReConstruct` 下的 `.stl` 文件。
|
||||
- reset 后默认项目仍存在。
|
||||
|
||||
## 登录同步验证
|
||||
|
||||
- 浏览器 A 登录 `admin / 123456`。
|
||||
- 浏览器 B 刷新或等待轮询后读取到已登录状态。
|
||||
- 浏览器 A 或 B 登出后,另一个浏览器轮询后同步到未登录状态。
|
||||
|
||||
命令级验证:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:4000/api/login -H 'Content-Type: application/json' -d '{"account":"admin","password":"123456"}'
|
||||
curl -s http://127.0.0.1:4000/api/session
|
||||
curl -s -X POST http://127.0.0.1:4000/api/logout
|
||||
curl -s http://127.0.0.1:4000/api/session
|
||||
```
|
||||
|
||||
## NIfTI 导出验证
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
curl -L -o /tmp/revoxelseg-mask.nii.gz -X POST http://127.0.0.1:4000/api/projects/head-ct-demo/export-mask
|
||||
file /tmp/revoxelseg-mask.nii.gz
|
||||
gzip -t /tmp/revoxelseg-mask.nii.gz
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- 下载文件存在。
|
||||
- 文件为 gzip 压缩数据。
|
||||
- `gzip -t` 校验通过。
|
||||
- 响应文件名后缀为 `.nii.gz`。
|
||||
|
||||
## 页面验证
|
||||
|
||||
- 打开 `http://192.168.3.11:4000/`。
|
||||
- 登录页可正常登录。
|
||||
- 总体概况显示后端统计数据。
|
||||
- 项目库默认显示头部 CT 演示项目。
|
||||
- 项目详情展示 `Head_CT_DICOM` 和 `Head_CT_ReConstruct` 相关信息。
|
||||
- 系统管理页点击“恢复演示环境出厂设置”后,项目仍恢复为默认 DICOM/STL。
|
||||
- 逆向工作区可导出 `nii.gz` mask。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- 引入后端后部署方式从纯 Vite 改为 Express + Vite 中间件,需要确认 `4000` 服务由 `server.ts` 托管。
|
||||
- 后端共享登录状态是演示同步方案,不等同生产级多用户 session。
|
||||
- NIfTI 文件当前为演示 mask,非医学级真实 STL 体素化结果。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint` 执行成功。
|
||||
- `npm run build` 执行成功。
|
||||
- Vite 仍有大 chunk 警告,当前不影响本次功能。
|
||||
- 已新增 Express 后端 `server.ts`,并通过 `npm run serve -- --host 0.0.0.0 --port 4000` 托管前后端。
|
||||
- `GET /api/health` 返回 `ok: true`。
|
||||
- `GET /api/projects` 返回默认项目 `head-ct-demo`。
|
||||
- 默认项目载入结果:
|
||||
- `dicomCount: 300`
|
||||
- `modelCount: 9`
|
||||
- `dicomPath: Head_CT_DICOM`
|
||||
- `modelPath: Head_CT_ReConstruct`
|
||||
- `POST /api/demo/reset` 执行成功,重置后默认项目仍载入 `Head_CT_DICOM` 与 `Head_CT_ReConstruct`。
|
||||
- `POST /api/login` 使用 `admin / 123456` 登录成功。
|
||||
- `GET /api/session` 登录后返回 `authenticated: true`。
|
||||
- `POST /api/logout` 登出成功。
|
||||
- `GET /api/session` 登出后返回 `authenticated: false`。
|
||||
- `POST /api/projects/head-ct-demo/export-mask?format=nii.gz` 执行成功。
|
||||
- `/tmp/revoxelseg-mask.nii.gz` 通过 `gzip -t` 校验。
|
||||
- 解压后 NIfTI magic 为 `n+1\0`。
|
||||
- `POST /api/projects/head-ct-demo/export-mask?format=nii` 执行成功,生成未压缩 `.nii`。
|
||||
- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`。
|
||||
- 当前部署由 `tmux` 会话 `revoxelseg-dicom` 托管。
|
||||
|
||||
## 剩余说明
|
||||
|
||||
- 当前导出的 NIfTI mask 是可下载、格式有效的演示分割体数据。
|
||||
- 真实医学级 STL 反向体素化仍需后续加入 DICOM 空间解析、STL 坐标配准、网格体素填充和标签体系。
|
||||
72
工程分析/经验记录.md
72
工程分析/经验记录.md
@@ -109,3 +109,75 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
每次调整部署端口前先检查端口占用和现有 `tmux` 会话;部署后同时验证 `127.0.0.1` 与目标内网 IP,并把实际访问地址写入测试方案执行结果。
|
||||
|
||||
## 2026-05-04-03-21-40 从纯前端演示升级为前后端协调系统
|
||||
|
||||
A. 具体问题
|
||||
|
||||
原系统登录、项目列表、用户列表、出厂重置和导出都是前端静态假数据,不同浏览器之间无法共享状态。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
项目初始形态是 Vite/React 前端演示,没有后端 API、共享状态存储和统一部署入口。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
新增 Express 后端 `server.ts`,用同一 `4000` 端口服务 API 和前端页面。后端通过 `WebSite/data/state.json` 维护演示状态,前端通过 `/api/session` 轮询同步登录状态,并将项目、用户、概况、重置和导出功能全部接入 API。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
新增跨浏览器共享功能时,默认先判断状态是否必须由后端维护;涉及多浏览器一致性的功能不要放在组件静态数组或浏览器本地状态中。
|
||||
|
||||
## 2026-05-04-03-21-40 默认医学数据项目载入
|
||||
|
||||
A. 具体问题
|
||||
|
||||
项目列表需要默认载入 `Head_CT_DICOM` 和 `Head_CT_ReConstruct`,恢复演示环境出厂设置后也必须恢复这两个默认数据目录。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
旧项目列表使用硬编码医疗项目示例,和当前仓库中的 DICOM/STL 数据资产没有建立关系。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
后端启动和读取状态时扫描 `Head_CT_DICOM` 的 `.dcm` 数量与 `Head_CT_ReConstruct` 的 `.stl` 文件列表,生成默认项目 `head-ct-demo`。`POST /api/demo/reset` 使用同一逻辑重建默认项目。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
演示数据入口统一放在后端默认项目构建函数中,避免多个页面分别硬编码 DICOM/STL 路径或数量。
|
||||
|
||||
## 2026-05-04-03-21-40 NIfTI 导出闭环
|
||||
|
||||
A. 具体问题
|
||||
|
||||
逆向工作区需要最终生成 `nii` 或 `nii.gz` 格式的分割 mask,而旧界面只有导出按钮,没有后端文件生成能力。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
项目尚未实现真实 STL 到 DICOM 空间的体素化算法,也没有文件导出 API。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
新增 `POST /api/projects/:projectId/export-mask`,后端生成 NIfTI-1 单文件 mask,并按请求输出 `.nii` 或 `.nii.gz`。`.nii.gz` 使用 Node `zlib.gzipSync` 压缩,并通过 `Content-Disposition` 触发下载。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
导出类功能需要先定义后端接口、文件格式和校验命令。后续实现真实体素化时,可替换当前演示 mask 生成函数,但保留 API 和下载流程。
|
||||
|
||||
## 2026-05-04-03-21-40 Vite 中间件 HMR 端口冲突
|
||||
|
||||
A. 具体问题
|
||||
|
||||
Express + Vite 中间件启动时,默认 HMR WebSocket 端口 `24678` 被其他服务占用,日志出现端口冲突。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
同一机器同时运行多个 Vite 服务,默认 HMR 端口可能重复。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
在 `createViteServer` 中指定 `server.hmr.port = 24679`,使本项目后端服务使用独立 HMR 端口。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
多项目并行部署时,除了业务端口外,也检查 Vite HMR 端口;发现冲突时为每个项目分配独立 HMR 端口。
|
||||
|
||||
68
工程分析/需求分析-2026-05-04-03-21-40.md
Normal file
68
工程分析/需求分析-2026-05-04-03-21-40.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 需求分析
|
||||
|
||||
时间戳:2026-05-04-03-21-40
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求严格使用代码编纂工作流处理本次项目修改,并在最开始确认工作流整体流程;但本次需求分析、实现方案、测试方案和执行修改均不需要人工二次确认。
|
||||
|
||||
功能需求:
|
||||
|
||||
- 完善整个代码,使网页变为前后端协调系统。
|
||||
- 不同浏览器的登录信息能够同步。
|
||||
- 完善网页基本功能。
|
||||
- 项目列表默认载入 `Head_CT_DICOM` 和 `Head_CT_ReConstruct`。
|
||||
- 选择“恢复演示环境出厂设置”后,也重新载入默认的 `Head_CT_DICOM` 和 `Head_CT_ReConstruct`。
|
||||
- 最终生成的分割 mask 为 `nii` 或 `nii.gz` 格式。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 从纯前端静态演示升级为带后端 API 的协同系统。
|
||||
- 将登录状态、用户列表、项目列表、演示环境状态统一由后端维护,避免不同浏览器各自持有不一致的假数据。
|
||||
- 以本地默认医学数据目录为演示项目来源,前端项目库默认展示真实目录统计。
|
||||
- 在逆向工作区提供后端生成 `nii.gz` 分割 mask 的下载能力,形成完整演示闭环。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
输入:
|
||||
|
||||
- `Head_CT_DICOM/`:默认 DICOM 序列目录。
|
||||
- `Head_CT_ReConstruct/`:默认 STL 重建模型目录。
|
||||
- 登录账号信息,默认管理员 `admin / 123456`。
|
||||
|
||||
输出:
|
||||
|
||||
- 后端 API。
|
||||
- 前端通过 API 读取和更新登录、用户、项目、导出状态。
|
||||
- 项目库默认项目:头部 CT DICOM 与重建 STL 演示项目。
|
||||
- 恢复出厂设置后恢复默认用户和默认项目。
|
||||
- 逆向工作区导出 `.nii.gz` 分割 mask。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/package.json`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/Login.tsx`
|
||||
- `WebSite/src/components/Overview.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- `WebSite/data/` 运行态数据目录
|
||||
- `WebSite/exports/` 生成导出目录
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 风险点
|
||||
|
||||
- 当前需求的“前后端协调系统”范围较大,本次优先完成可运行的后端 API、共享状态和演示导出闭环,不实现生产级认证权限。
|
||||
- “不同浏览器登录信息同步”在生产系统通常应基于用户独立 session。本演示系统为满足同步需求,会用后端统一维护当前登录状态,并通过前端轮询同步。
|
||||
- 真实 STL 到 DICOM 空间的精确逆向体素化需要医学影像空间信息、STL 坐标配准和体素填充算法。本次先生成符合 NIfTI-1 格式的演示分割 mask,并保留后续替换真实体素化算法的接口。
|
||||
- `4000` 端口当前为前端 Vite 服务;引入后端后需要改为 Express + Vite 中间件统一服务。
|
||||
- DICOM/STL 原始数据仍不应提交到 Git。
|
||||
|
||||
## 待确认问题
|
||||
|
||||
- 本次用户已明确免二次人工确认,因此无需等待确认。
|
||||
- 后续如需真实医学级逆向体素化,需要进一步确认 DICOM 空间坐标、STL 单位、配准矩阵和目标输出标签体系。
|
||||
Reference in New Issue
Block a user