2026-05-04-03-50-07 完善项目库可视化和项目管理
This commit is contained in:
@@ -1,20 +1,62 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
# 模型逆向系统 WebSite
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
本目录是“基于模型逆向体素化及 DICOM 分割标注系统”的前后端一体服务。
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
## 环境要求
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/2e2bd558-1bd5-4424-b1b2-07238ed56ff7
|
||||
- Node.js 18 或更高版本
|
||||
- npm
|
||||
|
||||
## Run Locally
|
||||
当前版本的 DICOM 预览、STL 预览和 NIfTI 演示导出均由 Node/React/Three.js 完成,不需要 Python 或 conda 环境。
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
后续若接入真实医学级 STL 反向体素化算法,建议单独创建 Python conda 环境,例如:
|
||||
|
||||
```bash
|
||||
conda create -n revoxelseg python=3.11
|
||||
conda activate revoxelseg
|
||||
```
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
再按真实算法依赖安装 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。
|
||||
|
||||
@@ -29,6 +29,8 @@ interface ProjectRecord {
|
||||
modelCount: number;
|
||||
stlFiles: string[];
|
||||
maskFormats: Array<'nii' | 'nii.gz'>;
|
||||
exportedMaskCount: number;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
@@ -116,6 +118,25 @@ function buildDefaultProject(): ProjectRecord {
|
||||
modelCount: stlFiles.length,
|
||||
stlFiles,
|
||||
maskFormats: ['nii', 'nii.gz'],
|
||||
exportedMaskCount: 0,
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyProject(name: string): ProjectRecord {
|
||||
return {
|
||||
id: `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name,
|
||||
createTime: today(),
|
||||
status: 'pending',
|
||||
dicomCount: 0,
|
||||
hasModel: false,
|
||||
dicomPath: '',
|
||||
modelPath: '',
|
||||
modelCount: 0,
|
||||
stlFiles: [],
|
||||
maskFormats: ['nii', 'nii.gz'],
|
||||
exportedMaskCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,9 +153,27 @@ function defaultState(): AppState {
|
||||
}
|
||||
|
||||
function normalizeState(state: AppState): AppState {
|
||||
const defaultProject = buildDefaultProject();
|
||||
const customProjects = Array.isArray(state.projects)
|
||||
? state.projects
|
||||
.filter((project) => project.id !== defaultProject.id)
|
||||
.map((project) => ({
|
||||
...project,
|
||||
exportedMaskCount: project.exportedMaskCount ?? 0,
|
||||
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
...state,
|
||||
projects: [buildDefaultProject()],
|
||||
projects: [
|
||||
{
|
||||
...defaultProject,
|
||||
name: state.projects?.find((project) => project.id === defaultProject.id)?.name ?? defaultProject.name,
|
||||
exportedMaskCount: state.projects?.find((project) => project.id === defaultProject.id)?.exportedMaskCount ?? 0,
|
||||
},
|
||||
...customProjects,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +260,102 @@ function createNiftiMask(project: ProjectRecord, compressed: boolean) {
|
||||
return compressed ? zlib.gzipSync(nifti) : nifti;
|
||||
}
|
||||
|
||||
function findProject(state: AppState, projectId: string) {
|
||||
return state.projects.find((candidate) => candidate.id === projectId);
|
||||
}
|
||||
|
||||
function getProjectDicomFiles(project: ProjectRecord) {
|
||||
if (project.id !== 'head-ct-demo') {
|
||||
return [];
|
||||
}
|
||||
return listFiles(dicomDir, '.dcm').sort((a, b) => Number.parseInt(a) - Number.parseInt(b));
|
||||
}
|
||||
|
||||
function readAsciiValue(buffer: Buffer, start: number, length: number) {
|
||||
return buffer.subarray(start, start + length).toString('ascii').replace(/\0/g, '').trim();
|
||||
}
|
||||
|
||||
function findExplicitTag(buffer: Buffer, group: number, element: number) {
|
||||
const pattern = Buffer.from([
|
||||
group & 0xff,
|
||||
(group >> 8) & 0xff,
|
||||
element & 0xff,
|
||||
(element >> 8) & 0xff,
|
||||
]);
|
||||
const longVr = ['OB', 'OD', 'OF', 'OL', 'OW', 'SQ', 'UC', 'UR', 'UT', 'UN'];
|
||||
let offset = buffer.indexOf(pattern, 132);
|
||||
|
||||
while (offset >= 0 && offset + 8 < buffer.length) {
|
||||
const vr = buffer.subarray(offset + 4, offset + 6).toString('ascii');
|
||||
if (/^[A-Z]{2}$/.test(vr)) {
|
||||
if (longVr.includes(vr)) {
|
||||
const length = buffer.readUInt32LE(offset + 8);
|
||||
return { valueOffset: offset + 12, length, vr };
|
||||
}
|
||||
const length = buffer.readUInt16LE(offset + 6);
|
||||
return { valueOffset: offset + 8, length, vr };
|
||||
}
|
||||
|
||||
offset = buffer.indexOf(pattern, offset + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDicomPreview(filePath: string) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010);
|
||||
const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011);
|
||||
const bitsTag = findExplicitTag(buffer, 0x0028, 0x0100);
|
||||
const representationTag = findExplicitTag(buffer, 0x0028, 0x0103);
|
||||
const centerTag = findExplicitTag(buffer, 0x0028, 0x1050);
|
||||
const widthTag = findExplicitTag(buffer, 0x0028, 0x1051);
|
||||
const interceptTag = findExplicitTag(buffer, 0x0028, 0x1052);
|
||||
const slopeTag = findExplicitTag(buffer, 0x0028, 0x1053);
|
||||
const pixelTag = findExplicitTag(buffer, 0x7fe0, 0x0010);
|
||||
|
||||
const rows = rowsTag ? buffer.readUInt16LE(rowsTag.valueOffset) : 0;
|
||||
const columns = columnsTag ? buffer.readUInt16LE(columnsTag.valueOffset) : 0;
|
||||
const bitsAllocated = bitsTag ? buffer.readUInt16LE(bitsTag.valueOffset) : 16;
|
||||
const pixelRepresentation = representationTag ? buffer.readUInt16LE(representationTag.valueOffset) : 0;
|
||||
const windowCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40;
|
||||
const windowWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400;
|
||||
const rescaleIntercept = interceptTag ? Number.parseFloat(readAsciiValue(buffer, interceptTag.valueOffset, interceptTag.length)) || 0 : 0;
|
||||
const rescaleSlope = slopeTag ? Number.parseFloat(readAsciiValue(buffer, slopeTag.valueOffset, slopeTag.length)) || 1 : 1;
|
||||
const pixelOffset = pixelTag?.valueOffset ?? -1;
|
||||
const pixelLength = pixelTag?.length ?? 0;
|
||||
|
||||
if (!rows || !columns || pixelOffset < 0) {
|
||||
throw new Error('无法解析当前 DICOM 像素数据');
|
||||
}
|
||||
|
||||
const count = rows * columns;
|
||||
const pixels = Buffer.alloc(count);
|
||||
const min = windowCenter - windowWidth / 2;
|
||||
const max = windowCenter + windowWidth / 2;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const position = pixelOffset + i * (bitsAllocated / 8);
|
||||
if (position + 1 >= buffer.length || position >= pixelOffset + pixelLength) {
|
||||
break;
|
||||
}
|
||||
const raw = bitsAllocated === 16
|
||||
? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position))
|
||||
: buffer.readUInt8(position);
|
||||
const hu = raw * rescaleSlope + rescaleIntercept;
|
||||
const normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255)));
|
||||
pixels[i] = normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
width: columns,
|
||||
height: rows,
|
||||
pixels: pixels.toString('base64'),
|
||||
windowCenter,
|
||||
windowWidth,
|
||||
};
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
|
||||
@@ -268,8 +403,22 @@ async function startServer() {
|
||||
res.json(readState().projects);
|
||||
});
|
||||
|
||||
app.post('/api/projects', (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
||||
if (!name) {
|
||||
res.status(400).json({ message: '项目名称不能为空' });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = readState();
|
||||
const project = buildEmptyProject(name);
|
||||
state.projects.push(project);
|
||||
writeState(state);
|
||||
res.status(201).json(project);
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectId', (req, res) => {
|
||||
const project = readState().projects.find((candidate) => candidate.id === req.params.projectId);
|
||||
const project = findProject(readState(), req.params.projectId);
|
||||
if (!project) {
|
||||
res.status(404).json({ message: '项目不存在' });
|
||||
return;
|
||||
@@ -277,24 +426,85 @@ async function startServer() {
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
app.patch('/api/projects/:projectId', (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
||||
if (!name) {
|
||||
res.status(400).json({ message: '项目名称不能为空' });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = readState();
|
||||
const project = findProject(state, req.params.projectId);
|
||||
if (!project) {
|
||||
res.status(404).json({ message: '项目不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
project.name = name;
|
||||
writeState(state);
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectId/dicom-preview', (req, res) => {
|
||||
const project = findProject(readState(), req.params.projectId);
|
||||
if (!project) {
|
||||
res.status(404).json({ message: '项目不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
const files = getProjectDicomFiles(project);
|
||||
if (!files.length) {
|
||||
res.status(404).json({ message: '当前项目没有可预览的 DICOM 文件' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10);
|
||||
const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0));
|
||||
try {
|
||||
const preview = parseDicomPreview(path.join(dicomDir, files[slice]));
|
||||
res.json({
|
||||
...preview,
|
||||
slice,
|
||||
total: files.length,
|
||||
fileName: files[slice],
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectId/models/:fileName', (req, res) => {
|
||||
const project = findProject(readState(), req.params.projectId);
|
||||
const fileName = path.basename(req.params.fileName);
|
||||
|
||||
if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) {
|
||||
res.status(404).json({ message: '模型文件不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.sendFile(path.join(modelDir, fileName));
|
||||
});
|
||||
|
||||
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);
|
||||
const exportedMaskProjects = state.projects.filter((project) => project.exportedMaskCount > 0).length;
|
||||
|
||||
res.json({
|
||||
totalProjects: state.projects.length,
|
||||
processedProjects: state.projects.filter((project) => project.status === 'completed').length,
|
||||
processedProjects: exportedMaskProjects,
|
||||
exportedMaskProjects,
|
||||
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 },
|
||||
{ name: 'Mon', projects: state.projects.length, processing: exportedMaskProjects },
|
||||
{ name: 'Tue', projects: state.projects.length, processing: exportedMaskProjects },
|
||||
{ name: 'Wed', projects: state.projects.length, processing: exportedMaskProjects },
|
||||
{ name: 'Thu', projects: state.projects.length, processing: exportedMaskProjects },
|
||||
{ name: 'Fri', projects: state.projects.length, processing: exportedMaskProjects },
|
||||
{ name: 'Sat', projects: state.projects.length, processing: exportedMaskProjects },
|
||||
{ name: 'Sun', projects: state.projects.length, processing: exportedMaskProjects },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -320,6 +530,8 @@ async function startServer() {
|
||||
const filename = `${project.id}-segmentation-mask.${format}`;
|
||||
const outputPath = path.join(exportDir, filename);
|
||||
fs.writeFileSync(outputPath, mask);
|
||||
project.exportedMaskCount += 1;
|
||||
writeState(state);
|
||||
|
||||
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Overview() {
|
||||
|
||||
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: '已导出 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: '默认' },
|
||||
];
|
||||
@@ -60,7 +60,7 @@ export default function Overview() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<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 }}
|
||||
@@ -76,8 +76,9 @@ export default function Overview() {
|
||||
<option>最近30天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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">
|
||||
@@ -87,7 +88,7 @@ export default function Overview() {
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
|
||||
<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 }}
|
||||
@@ -95,6 +96,7 @@ export default function Overview() {
|
||||
<Area type="monotone" dataKey="projects" stroke="#3b82f6" strokeWidth={3} fillOpacity={1} fill="url(#colorProj)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -113,8 +115,9 @@ export default function Overview() {
|
||||
<option>最近30天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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">
|
||||
@@ -124,7 +127,7 @@ export default function Overview() {
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
|
||||
<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 }}
|
||||
@@ -132,6 +135,7 @@ export default function Overview() {
|
||||
<Area type="monotone" dataKey="processing" stroke="#818cf8" strokeWidth={3} fillOpacity={1} fill="url(#colorProc)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,106 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
RotateCw,
|
||||
FileText,
|
||||
Box,
|
||||
Image as ImageIcon,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Trash2,
|
||||
Edit2,
|
||||
FolderRoot,
|
||||
Download
|
||||
Download,
|
||||
Layers,
|
||||
Save,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
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';
|
||||
import { Canvas, useLoader } from '@react-three/fiber';
|
||||
import { Bounds, Center, OrbitControls, Stage } from '@react-three/drei';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
import * as THREE from 'three';
|
||||
import { DicomPreview, Project } from '../types';
|
||||
import { api, downloadMask } from '../lib/api';
|
||||
|
||||
function StlModel({ url }: { url: string }) {
|
||||
const geometry = useLoader(STLLoader, url);
|
||||
|
||||
// Mock 3D Model component
|
||||
function ModelPreview() {
|
||||
return (
|
||||
<mesh>
|
||||
<boxGeometry args={[1.5, 1.5, 1.5]} />
|
||||
<meshStandardMaterial color="#3b82f6" metalness={0.5} roughness={0.2} />
|
||||
<mesh geometry={geometry as THREE.BufferGeometry}>
|
||||
<meshStandardMaterial color="#3b82f6" roughness={0.48} metalness={0.08} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function DicomCanvas({ preview }: { preview: DicomPreview }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binary = atob(preview.pixels);
|
||||
const imageData = context.createImageData(preview.width, preview.height);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
const value = binary.charCodeAt(i);
|
||||
const offset = i * 4;
|
||||
imageData.data[offset] = value;
|
||||
imageData.data[offset + 1] = value;
|
||||
imageData.data[offset + 2] = value;
|
||||
imageData.data[offset + 3] = 255;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}, [preview]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={preview.width}
|
||||
height={preview.height}
|
||||
className="max-h-full max-w-full object-contain rounded-xl shadow-2xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [sliceIndex, setSliceIndex] = useState(42);
|
||||
const [sliceIndex, setSliceIndex] = useState(0);
|
||||
const [visibleModules, setVisibleModules] = useState<Record<string, boolean>>({});
|
||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||
const [dicomError, setDicomError] = useState('');
|
||||
const [selectedModelFile, setSelectedModelFile] = useState('');
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [editingProjectId, setEditingProjectId] = useState('');
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [actionMessage, setActionMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.getProjects()
|
||||
const refreshProjects = () => {
|
||||
setLoading(true);
|
||||
return api.getProjects()
|
||||
.then((items) => {
|
||||
setProjects(items);
|
||||
setSelectedProject(items[0] ?? null);
|
||||
setSelectedProject((current) => {
|
||||
if (!current) {
|
||||
return items[0] ?? null;
|
||||
}
|
||||
return items.find((item) => item.id === current.id) ?? items[0] ?? null;
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects();
|
||||
}, []);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
@@ -68,7 +121,35 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
next[module] = visibleModules[module] ?? true;
|
||||
});
|
||||
setVisibleModules(next);
|
||||
setSelectedModelFile(selectedProject?.stlFiles?.[0] ?? '');
|
||||
setSliceIndex(0);
|
||||
}, [selectedProject?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) {
|
||||
setDicomPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDicomError('');
|
||||
api.getDicomPreview(selectedProject.id, sliceIndex)
|
||||
.then((preview) => {
|
||||
if (!cancelled) {
|
||||
setDicomPreview(preview);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setDicomPreview(null);
|
||||
setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, viewMode]);
|
||||
|
||||
const toggleModule = (name: string) => {
|
||||
setVisibleModules(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
@@ -81,6 +162,39 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
setVisibleModules(newState);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) {
|
||||
setActionMessage('请输入项目名称');
|
||||
return;
|
||||
}
|
||||
const created = await api.createProject(name);
|
||||
setNewProjectName('');
|
||||
setActionMessage(`已创建项目:${created.name}`);
|
||||
await refreshProjects();
|
||||
setSelectedProject(created);
|
||||
};
|
||||
|
||||
const handleRenameProject = async (projectId: string) => {
|
||||
const name = editingName.trim();
|
||||
if (!name) {
|
||||
setActionMessage('项目名称不能为空');
|
||||
return;
|
||||
}
|
||||
const updated = await api.renameProject(projectId, name);
|
||||
setEditingProjectId('');
|
||||
setEditingName('');
|
||||
setActionMessage(`已更新项目名称:${updated.name}`);
|
||||
await refreshProjects();
|
||||
setSelectedProject(updated);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon },
|
||||
{ id: 'model' as const, label: '3D 模型', icon: Box },
|
||||
{ id: 'mask' as const, label: '分割结果', icon: Layers },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-6 overflow-hidden">
|
||||
{/* Project Sidebar - Collapsible */}
|
||||
@@ -98,7 +212,36 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="p-4 flex flex-col h-full overflow-hidden">
|
||||
<h3 className="font-bold text-slate-800 mb-4 px-1">项目列表</h3>
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h3 className="font-bold text-slate-800">项目列表</h3>
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
className="p-1.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
|
||||
title="创建项目"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
value={newProjectName}
|
||||
onChange={(event) => setNewProjectName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleCreateProject();
|
||||
}
|
||||
}}
|
||||
placeholder="新项目名称"
|
||||
className="min-w-0 flex-1 px-3 py-2 bg-slate-50 border-none rounded-lg text-xs focus:ring-1 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateProject}
|
||||
className="w-9 h-9 rounded-lg bg-slate-900 text-white flex items-center justify-center hover:bg-slate-700"
|
||||
title="保存新项目"
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||||
<input
|
||||
@@ -112,22 +255,77 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||||
{loading && <p className="text-xs text-slate-400 px-2">正在从后端载入项目...</p>}
|
||||
{filteredProjects.map((proj) => (
|
||||
<button
|
||||
<div
|
||||
key={proj.id}
|
||||
onClick={() => setSelectedProject(proj)}
|
||||
className={`w-full p-3 rounded-xl transition-all text-left ${
|
||||
className={`w-full p-3 rounded-xl transition-all text-left cursor-pointer ${
|
||||
selectedProject?.id === proj.id ? 'bg-blue-600 text-white shadow-md' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{editingProjectId === proj.id ? (
|
||||
<input
|
||||
value={editingName}
|
||||
autoFocus
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setEditingName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') handleRenameProject(proj.id);
|
||||
if (event.key === 'Escape') setEditingProjectId('');
|
||||
}}
|
||||
className="w-full rounded-md px-2 py-1 text-xs text-slate-900 outline-none ring-1 ring-blue-200"
|
||||
/>
|
||||
) : (
|
||||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{editingProjectId === proj.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRenameProject(proj.id);
|
||||
}}
|
||||
className="text-emerald-500"
|
||||
title="保存名称"
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingProjectId('');
|
||||
}}
|
||||
className="text-slate-400"
|
||||
title="取消"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setEditingProjectId(proj.id);
|
||||
setEditingName(proj.name);
|
||||
}}
|
||||
className={selectedProject?.id === proj.id ? 'text-blue-100 hover:text-white' : 'text-slate-300 hover:text-blue-600'}
|
||||
title="修改项目名称"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
|
||||
{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{actionMessage && <p className="text-[10px] text-slate-400 mt-3 px-1">{actionMessage}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -154,22 +352,17 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
<button
|
||||
onClick={() => setViewMode('dicom')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
|
||||
viewMode === 'dicom' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon size={16} /> DICOM 影像
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('model')}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
|
||||
viewMode === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Box size={16} /> 3D 模型
|
||||
</button>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setViewMode(tab.id)}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
|
||||
viewMode === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} /> {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
@@ -185,7 +378,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
|
||||
{viewMode === 'dicom' ? (
|
||||
{viewMode === 'dicom' && (
|
||||
<div className="h-full flex gap-8">
|
||||
{/* Left: DICOM Viewer */}
|
||||
<div className="flex-1 bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center p-12">
|
||||
@@ -195,12 +388,14 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<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" />
|
||||
<div className="w-56 h-56 border border-white/10 rounded-full" />
|
||||
<p className="absolute text-white/20 text-xs font-mono uppercase tracking-widest">DCM RENDER VIEW | #{sliceIndex}</p>
|
||||
{dicomPreview ? (
|
||||
<DicomCanvas preview={dicomPreview} />
|
||||
) : (
|
||||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
||||
<span>WW/WL: 400/40</span>
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40}</span>
|
||||
<span>SLICE: {sliceIndex}/{selectedProject.dicomCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,14 +411,32 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{viewMode === 'model' && (
|
||||
<div className="h-full flex gap-8">
|
||||
{/* Left: 3D Visualization */}
|
||||
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
|
||||
<Canvas>
|
||||
<Stage environment="city" intensity={0.5}>
|
||||
<ModelPreview />
|
||||
</Stage>
|
||||
<color attach="background" args={['#f8fafc']} />
|
||||
<ambientLight intensity={0.65} />
|
||||
<directionalLight position={[3, 6, 5]} intensity={1.1} />
|
||||
<Suspense fallback={null}>
|
||||
{selectedModelFile ? (
|
||||
<Bounds fit clip observe margin={1.25}>
|
||||
<Center>
|
||||
<StlModel url={`/api/projects/${selectedProject.id}/models/${encodeURIComponent(selectedModelFile)}`} />
|
||||
</Center>
|
||||
</Bounds>
|
||||
) : (
|
||||
<Stage environment="city" intensity={0.5}>
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#94a3b8" />
|
||||
</mesh>
|
||||
</Stage>
|
||||
)}
|
||||
</Suspense>
|
||||
<OrbitControls />
|
||||
</Canvas>
|
||||
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
|
||||
@@ -246,11 +459,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
{subModules.map((m, i) => (
|
||||
<div
|
||||
key={m}
|
||||
className={`p-3 rounded-xl border flex items-center gap-3 group transition-all ${
|
||||
i === 0 ? 'bg-blue-50 border-blue-100' : 'bg-slate-50 border-transparent hover:border-slate-200'
|
||||
onClick={() => setSelectedModelFile(selectedProject.stlFiles?.[i] ?? '')}
|
||||
className={`p-3 rounded-xl border flex items-center gap-3 group transition-all cursor-pointer ${
|
||||
selectedProject.stlFiles?.[i] === selectedModelFile ? 'bg-blue-50 border-blue-100' : 'bg-slate-50 border-transparent hover:border-slate-200'
|
||||
} ${!visibleModules[m] ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${i === 0 ? 'bg-blue-600 text-white' : 'bg-white text-slate-400'}`}>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 bg-white text-slate-400">
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -275,6 +489,50 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'mask' && (
|
||||
<div className="h-full grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-8">
|
||||
<div className="bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center overflow-hidden">
|
||||
<div className="relative w-80 h-80">
|
||||
{['#3b82f6', '#22c55e', '#f59e0b'].map((color, index) => (
|
||||
<div
|
||||
key={color}
|
||||
className="absolute inset-0 border-2"
|
||||
style={{
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}22`,
|
||||
borderRadius: index === 0 ? '48% 52% 46% 54%' : '58% 42% 52% 48%',
|
||||
transform: `rotate(${index * 36}deg) scale(${1 - index * 0.13})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute left-5 top-5 text-white/50 font-mono text-[10px]">
|
||||
SEGMENTATION MASK PREVIEW · NII/NII.GZ
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-slate-50 rounded-2xl p-5 border border-slate-100">
|
||||
<h3 className="font-bold text-slate-800 mb-3">分割结果</h3>
|
||||
<p className="text-sm text-slate-500 leading-6">
|
||||
当前项目可导出 NIfTI 格式分割 mask。NII.GZ 为默认全量导出格式,适合后续医学影像工具链读取。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => downloadMask(selectedProject.id, 'nii.gz')}
|
||||
className="bg-slate-900 text-white px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-black"
|
||||
>
|
||||
<Download size={18} /> 下载 NII.GZ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadMask(selectedProject.id, 'nii')}
|
||||
className="bg-white text-slate-700 px-5 py-3 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-50 border border-slate-200"
|
||||
>
|
||||
<Download size={18} /> 下载 NII
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OverviewSummary, Project, SessionState, UserRecord } from '../types';
|
||||
import { DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
@@ -36,6 +36,18 @@ export const api = {
|
||||
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 }),
|
||||
}),
|
||||
getDicomPreview: (projectId: string, slice: number) =>
|
||||
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}`),
|
||||
getUsers: () => request<UserRecord[]>('/api/users'),
|
||||
resetDemo: () =>
|
||||
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Project {
|
||||
modelCount?: number;
|
||||
stlFiles?: string[];
|
||||
maskFormats?: Array<'nii' | 'nii.gz'>;
|
||||
exportedMaskCount?: number;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface MaskMapping {
|
||||
@@ -43,6 +45,7 @@ export interface SessionState {
|
||||
export interface OverviewSummary {
|
||||
totalProjects: number;
|
||||
processedProjects: number;
|
||||
exportedMaskProjects: number;
|
||||
dicomCount: number;
|
||||
modelCount: number;
|
||||
chartData: Array<{
|
||||
@@ -51,3 +54,14 @@ export interface OverviewSummary {
|
||||
processing: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DicomPreview {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: string;
|
||||
slice: number;
|
||||
total: number;
|
||||
fileName: string;
|
||||
windowCenter: number;
|
||||
windowWidth: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user