2026-05-04-03-50-07 完善项目库可视化和项目管理

This commit is contained in:
2026-05-04 03:59:46 +08:00
parent a9b6d2d76a
commit 26d3109f63
11 changed files with 1010 additions and 88 deletions

View File

@@ -3,3 +3,36 @@
基于模型逆向体素化及 DICOM 分割标注系统。
工程流程与分析文档位于 `工程分析/`
## 项目最重要达成目标
本项目的核心目标是:输入 DICOM 影像序列和重建 STL 模型后,将模型反向体素化并对齐到 DICOM 空间,最终生成可被医学影像工具读取的分割 Mask例如 `.nii``.nii.gz`
当前系统已经具备:
- 前后端协调服务。
- 跨浏览器共享登录状态。
- 默认载入 `Head_CT_DICOM``Head_CT_ReConstruct`
- DICOM 切片预览。
- STL 模型预览。
- 分割结果展示与 NIfTI 演示导出。
## 构建运行
进入前端服务目录:
```bash
cd WebSite
npm ci
npm run lint
npm run build
npm run serve -- --host 0.0.0.0 --port 4000
```
访问地址:
```text
http://192.168.3.11:4000/
```
当前版本不需要 Python/conda。后续接入真实医学级 STL 体素化算法时,可新建 `revoxelseg` conda 环境并安装 SimpleITK、nibabel、numpy、trimesh、vtk 等算法依赖。

View File

@@ -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。

View File

@@ -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}"`);

View File

@@ -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>

View File

@@ -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,8 +121,36 @@ 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 maskNII.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>
</>
) : (

View File

@@ -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', {

View File

@@ -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;
}

View File

@@ -0,0 +1,91 @@
# 实现方案
时间戳2026-05-04-03-50-07
## 修改目标
修复概况统计、图表警告和模块高亮问题;完善项目库真实 DICOM/STL/分割结果展示;增加项目创建和重命名能力;补齐 README 构建方案并重新部署。
## 涉及路径
- `WebSite/server.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/src/components/Overview.tsx`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/README.md`
- `README.md`
- `工程分析/测试方案-2026-05-04-03-50-07.md`
- `工程分析/经验记录.md`
## 技术路线
1. 后端项目状态增强。
- 保留默认项目,同时允许新增用户创建项目。
- 新增 `POST /api/projects` 创建项目。
- 新增 `PATCH /api/projects/:projectId` 修改项目名称。
2. 后端 DICOM 预览。
- 新增 `GET /api/projects/:projectId/dicom-preview?slice=0`
- 读取当前 DICOM 文件,解析 Rows、Columns、Pixel Data、Window Center/Width、Rescale Slope/Intercept。
- 返回 `width``height``pixels` base64 灰度数据和 slice 元数据。
3. 后端 STL 文件服务。
- 新增 `GET /api/projects/:projectId/models/:fileName`
- 只允许读取项目对应 STL 列表中的文件,避免任意路径读取。
4. 前端项目库。
- DICOM 视图使用 canvas 绘制后端返回灰度像素。
- 3D 模型视图使用 Three.js `STLLoader` 加载真实 STL。
- 新增 `分割结果` tab展示 mask 预览、标签图例和 NII/NII.GZ 下载。
- 项目列表顶部增加创建按钮和名称输入。
- 项目卡右侧增加编辑图标,可重命名。
- 移除首个 STL 模块默认蓝色高亮,所有模块同等样式。
5. 概况页。
- 已处理项目改为“已导出 Mask 项目”,避免项目总数 1 时已处理 1 的误导。
- 趋势数据改为平稳小范围变化。
- Recharts 容器增加 `min-w-0`、固定高度和加载态,避免宽高 -1 警告。
6. README。
- 补充 Node 构建运行方案。
- 说明当前无需 Python/conda真实体素化阶段再引入 Python 环境建议。
7. 验证与部署。
- `npm run lint`
- `npm run build`
- API smoke test
- 重新部署 `tmux` 会话到 `4000`
## 数据流
DICOM 预览:
前端切片滑块 -> `/api/projects/:id/dicom-preview?slice=n` -> 后端解析 DICOM 像素 -> 前端 canvas 绘制。
STL 预览:
前端选择 STL 模块 -> `/api/projects/:id/models/:fileName` -> `STLLoader` 加载几何体 -> Three.js 渲染。
分割结果:
前端 `分割结果` tab -> 展示 mask 预览 -> 调用已有 `/api/projects/:id/export-mask` 导出 `nii``nii.gz`
项目管理:
创建/重命名 -> 后端写入 `state.json` -> 项目列表刷新。
## 兼容性与回滚方案
- DICOM/STL 原始文件仍不提交 Git。
- 若 DICOM 解析失败,前端显示错误态,不影响其他页面。
- 若 STL 加载失败,前端显示模型加载失败提示,不影响 DICOM 和分割结果。
- 回滚时恢复 `server.ts` 和项目库组件即可回到上一版前后端演示状态。
## 预计文件变更
- 修改后端 API。
- 修改项目库和概况页。
- 修改 API 类型和封装。
- 修改 README。
- 更新工程分析文档和经验记录。
## 人工审核状态
本次用户明确要求无需人工二次确认。
状态:自动确认,继续执行。

View File

@@ -0,0 +1,97 @@
# 测试方案
时间戳2026-05-04-03-50-07
## 测试目标
验证概况统计、图表、真实 DICOM 预览、真实 STL 渲染、分割结果视图、项目创建/重命名、README 和部署均正常。
## 静态检查
- 检查 `Overview.tsx` 图表容器是否有稳定尺寸。
- 检查 `ProjectLibrary.tsx` 是否包含三个 tab`DICOM 影像``3D 模型``分割结果`
- 检查 STL 模块列表不再对首个模块默认蓝色高亮。
- 检查 README 是否包含项目构建和运行方案。
## 构建与类型检查
```bash
cd WebSite
npm run lint
npm run build
```
预期结果:
- TypeScript 检查通过。
- Vite 构建通过。
## API 验证
```bash
curl -s http://127.0.0.1:4000/api/overview
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?slice=0'
curl -I 'http://127.0.0.1:4000/api/projects/head-ct-demo/models/会厌.stl'
curl -s -X POST http://127.0.0.1:4000/api/projects -H 'Content-Type: application/json' -d '{"name":"测试创建项目"}'
curl -s -X PATCH http://127.0.0.1:4000/api/projects/head-ct-demo -H 'Content-Type: application/json' -d '{"name":"头部 CT 模型逆向体素化演示"}'
```
预期结果:
- overview 返回平稳趋势和 `exportedMaskProjects`
- DICOM preview 返回 `width``height``pixels`
- STL 文件返回 200。
- 创建项目返回新项目。
- 重命名项目成功。
## 页面验证
- 总体概况不再显示“已处理项目总数 = 1”的误导文案。
- 控制台不再出现 Recharts 宽高 -1 警告。
- 项目库 DICOM 影像显示真实灰度切片。
- 项目库 3D 模型显示真实 STL。
- 项目库新增分割结果页,可下载 NII/NII.GZ。
- 项目列表可创建项目。
- 项目列表已有项目可点击编辑图标重命名。
- STL 模块列表所有项目同等样式,没有“会厌”特殊蓝色突出。
## 回归风险
- DICOM 解析兼容性有限,当前先针对本项目 Little Endian CT 数据。
- STL 文件较大时首次加载可能需要等待。
- 新建项目默认无 DICOM/STL 数据,作为项目管理演示项。
## 人工审核状态
本次用户明确要求无需人工二次确认。
状态:自动确认,继续执行。
## 执行结果
- `npm run lint` 执行成功。
- `npm run build` 执行成功。
- Vite 仍有大 chunk 警告,当前不影响本次功能。
- `GET /api/overview` 返回:
- `totalProjects: 1`
- `exportedMaskProjects: 0`
- `dicomCount: 300`
- `modelCount: 9`
- 已将“已处理项目”语义改为“已导出 Mask 项目”,避免项目总数为 1 时误导为已处理 1。
- `GET /api/projects/head-ct-demo/dicom-preview?slice=0` 返回:
- `width: 512`
- `height: 512`
- `total: 300`
- `fileName: 1.dcm`
- `pixels` base64 数据。
- `GET /api/projects/head-ct-demo/models/会厌.stl` 返回 `HTTP/1.1 200 OK`
- `POST /api/projects` 创建项目成功。
- `PATCH /api/projects/:id` 重命名项目成功。
- `POST /api/demo/reset` 执行成功,测试创建项目已清理,默认项目恢复。
- headless Chrome 打开页面后未捕获 `width(-1)``height(-1)` 或 Recharts 相关警告。
- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`
- 当前服务由 `tmux` 会话 `revoxelseg-dicom` 托管。
## 本次未创建 Python/conda 环境原因
本次 DICOM 预览、STL 渲染和 NIfTI 演示导出均可由现有 Node/React/Three.js 技术栈完成。为了避免引入不必要的运行环境复杂度,本次不创建 conda 环境README 已说明后续真实体素化算法阶段再引入 Python/conda。

View File

@@ -181,3 +181,93 @@ C. 解决问题方案
D. 后续如何避免问题
多项目并行部署时,除了业务端口外,也检查 Vite HMR 端口;发现冲突时为每个项目分配独立 HMR 端口。
## 2026-05-04-03-50-07 概况统计语义误导
A. 具体问题
项目总数只有 1 时,“已处理项目总数”也显示 1容易让用户误解为系统已经完成真实处理。
B. 产生问题原因
旧统计把 `status === completed` 直接当作已处理项目,而默认演示项目为了表示数据可用被标记为 `completed`
C. 解决问题方案
将概况卡片语义改为“已导出 Mask 项目”,后端按 `exportedMaskCount > 0` 统计;默认项目初始为 0只有实际触发 mask 导出后才计入。
D. 后续如何避免问题
统计卡片必须反映明确业务事件,不要把演示数据可用状态和处理完成状态混在一起。
## 2026-05-04-03-50-07 DICOM 预览解析
A. 具体问题
项目库 DICOM 影像页看不到真实影像,只显示占位图形。
B. 产生问题原因
前端没有读取 DICOM 像素数据,后端也没有提供切片预览 API。
C. 解决问题方案
新增 `GET /api/projects/:projectId/dicom-preview`,后端解析当前 Little Endian DICOM 的 Rows、Columns、Window、Rescale 和 Pixel Data返回 base64 灰度像素,前端用 canvas 绘制真实切片。
D. 后续如何避免问题
医学影像可视化应优先验证真实像素是否进入浏览器。后续扩展更多 DICOM 传输语法时,应把解析器替换为成熟 DICOM 库或 Python 预处理服务。
## 2026-05-04-03-50-07 STL 模型真实渲染
A. 具体问题
项目库 3D 模型页显示的是占位立方体,不是 `Head_CT_ReConstruct` 中的真实 STL。
B. 产生问题原因
前端没有 STLLoader后端也没有安全暴露 STL 文件。
C. 解决问题方案
新增 `GET /api/projects/:projectId/models/:fileName`,仅允许读取项目 STL 列表中的文件;前端使用 Three.js `STLLoader``Bounds``Center` 加载并自动居中显示模型。
D. 后续如何避免问题
三维模型视图必须加载真实模型文件,不能长期使用占位几何体;后端文件服务要限制文件名和项目白名单,避免任意路径读取。
## 2026-05-04-03-50-07 Recharts 容器尺寸警告
A. 具体问题
控制台出现 Recharts 警告:图表宽高为 `-1`,需要检查容器尺寸。
B. 产生问题原因
图表在布局尚未稳定时渲染,`ResponsiveContainer` 从父容器拿到异常宽高。
C. 解决问题方案
为图表外层设置 `min-w-0`、固定高度和 `min-h`,并在 chartData 存在后再渲染 `ResponsiveContainer`,高度使用明确数值 `300`
D. 后续如何避免问题
使用 Recharts 时保证父容器有稳定尺寸;在异步数据未到达前不要提前渲染响应式图表。
## 2026-05-04-03-50-07 Python 环境引入边界
A. 具体问题
用户提出如果需要 Python 可新建 conda 环境,但本次需求可以在现有 Node/React/Three.js 技术栈中完成。
B. 产生问题原因
DICOM/STL/Mask 功能既可以由前端演示链路实现,也可能进入 Python 医学影像算法链路,需要判断当前阶段是否真的需要 Python。
C. 解决问题方案
本次不创建 conda 环境避免增加不必要复杂度README 中说明当前构建方式和后续真实体素化阶段的 conda 环境建议。
D. 后续如何避免问题
只有当需要真实 DICOM 空间解析、STL 体素填充、NIfTI 精确写入或批处理算法时,再引入 Python/conda并把环境文件纳入项目文档。

View File

@@ -0,0 +1,69 @@
# 需求分析
时间戳2026-05-04-03-50-07
## 原始需求摘要
用户要求严格使用代码编纂工作流处理本次修改,并在开始时复述工作流整体流程;本次需求分析、实现方案、测试方案和执行修改均不需要人工二次确认。
具体问题:
1. 当前项目总数为 1已处理项目也是 1统计语义不合理已处理项目趋势折线图过于夸张。
2. 项目库中 DICOM 影像和 3D 模型看不到;如需要 Python可新建 conda 环境,并在 README 写项目构建方案。
3. 项目库中在 `DICOM 影像``3D 模型` 旁边增加 `分割结果`
4. 项目列表增加创建功能,可命名项目;已有项目右侧增加修改符号,可修改项目名称。
5. STL 模块列表中“会厌”不应默认蓝色突出,它和其他模块没有特殊关系。
6. 修复 Recharts 控制台警告:图表宽高为 -1。
7. 复述项目最重要达成目标,并完善系统中其他应提升但未完善的地方。
## 业务目标
- 让系统概况统计更符合业务语义,避免误导。
- 在项目库直接看到真实 DICOM 切片预览和真实 STL 模型预览。
- 让项目库形成 DICOM、模型、分割结果三类核心资产视图。
- 增加基础项目管理能力:创建项目、重命名项目。
- 修复 UI 不合理高亮和控制台警告。
- 补齐 README 中的构建、运行和部署说明。
## 输入与输出
输入:
- `Head_CT_DICOM/` 下的 DICOM 切片。
- `Head_CT_ReConstruct/` 下的 STL 模型。
- 用户创建或重命名项目的名称。
输出:
- 后端 DICOM 切片预览 API。
- 后端 STL 文件静态读取 API。
- 前端 DICOM canvas 预览。
- 前端 STL 模型渲染。
- 项目库新增 `分割结果` 视图。
- 项目创建和重命名 API 及前端入口。
- 修复后的概况统计与图表。
- README 构建方案。
## 影响范围
- `WebSite/server.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/src/components/Overview.tsx`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/README.md`
- `README.md`
- `工程分析/经验记录.md`
## 风险点
- DICOM 文件可能存在不同传输语法。本次优先支持当前数据可见的 Little Endian DICOM并做保守 fallback。
- 浏览器渲染 STL 文件需要加载 `three/examples/jsm/loaders/STLLoader.js`,构建需验证 TypeScript/Vite 兼容。
- 创建项目若不绑定真实数据,会作为空演示项目存在;默认项目仍绑定真实 DICOM/STL。
- 图表容器警告与布局时机有关,需要给容器设置稳定 `min-w-0`、固定高度和加载态。
## 待确认问题
- 本次用户已明确无需二次人工确认,直接执行。
- 本次不创建 Python conda 环境,因为 DICOM 预览和 STL 渲染可以通过 Node/React/Three.js 完成。
- 后续若实现医学级真实体素化,可再引入 Python/conda 处理链。