diff --git a/WebSite/server.ts b/WebSite/server.ts index d4b68fe..286f254 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -356,6 +356,46 @@ function parseDicomPreview(filePath: string) { }; } +function parseDicomPixels(filePath: string) { + const preview = parseDicomPreview(filePath); + return { + ...preview, + pixelBuffer: Buffer.from(preview.pixels, 'base64'), + }; +} + +function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal', slice: number) { + const first = parseDicomPixels(path.join(dicomDir, files[0])); + const maxSlice = plane === 'sagittal' ? first.width - 1 : first.height - 1; + const clampedSlice = Math.max(0, Math.min(maxSlice, slice)); + const outputWidth = files.length; + const outputHeight = plane === 'sagittal' ? first.height : first.width; + const pixels = Buffer.alloc(outputWidth * outputHeight); + + files.forEach((fileName, z) => { + const frame = parseDicomPixels(path.join(dicomDir, fileName)); + + for (let row = 0; row < outputHeight; row += 1) { + const sourceIndex = plane === 'sagittal' + ? row * frame.width + clampedSlice + : clampedSlice * frame.width + row; + const targetIndex = row * outputWidth + z; + pixels[targetIndex] = frame.pixelBuffer[sourceIndex] ?? 0; + } + }); + + return { + width: outputWidth, + height: outputHeight, + pixels: pixels.toString('base64'), + windowCenter: first.windowCenter, + windowWidth: first.windowWidth, + slice: clampedSlice, + total: maxSlice + 1, + fileName: `${plane}-${clampedSlice}`, + }; +} + async function startServer() { const app = express(); const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; @@ -445,6 +485,19 @@ async function startServer() { res.json(project); }); + app.delete('/api/projects/:projectId', (req, res) => { + const state = readState(); + const index = state.projects.findIndex((project) => project.id === req.params.projectId); + if (index < 0) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const [deleted] = state.projects.splice(index, 1); + writeState(state); + res.json({ ok: true, deletedId: deleted.id }); + }); + app.get('/api/projects/:projectId/dicom-preview', (req, res) => { const project = findProject(readState(), req.params.projectId); if (!project) { @@ -458,15 +511,26 @@ async function startServer() { return; } + const requestedPlane = String(req.query.plane ?? 'axial'); + const plane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial'; 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])); + if (plane === 'axial') { + const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0)); + const preview = parseDicomPreview(path.join(dicomDir, files[slice])); + res.json({ + ...preview, + plane, + slice, + total: files.length, + fileName: files[slice], + }); + return; + } + res.json({ - ...preview, - slice, - total: files.length, - fileName: files[slice], + ...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0), + plane, }); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' }); diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 94ddbdc..8f23e43 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -11,8 +11,9 @@ import { FolderRoot, Download, Layers, - Save, - X + X, + Trash2, + Upload } from 'lucide-react'; import { Canvas, useLoader } from '@react-three/fiber'; import { Bounds, Center, OrbitControls, Stage } from '@react-three/drei'; @@ -21,12 +22,29 @@ import * as THREE from 'three'; import { DicomPreview, Project } from '../types'; import { api, downloadMask } from '../lib/api'; -function StlModel({ url }: { url: string }) { +type Plane = 'axial' | 'sagittal' | 'coronal'; + +interface ModuleStyle { + visible: boolean; + color: string; + opacity: number; +} + +const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; + +function StlModel({ url, color, opacity }: { url: string; color: string; opacity: number }) { const geometry = useLoader(STLLoader, url); return ( - + ); } @@ -75,11 +93,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>('dicom'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [sliceIndex, setSliceIndex] = useState(0); - const [visibleModules, setVisibleModules] = useState>({}); + const [plane, setPlane] = useState('axial'); + const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); const [dicomError, setDicomError] = useState(''); - const [selectedModelFile, setSelectedModelFile] = useState(''); const [newProjectName, setNewProjectName] = useState(''); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [projectToDelete, setProjectToDelete] = useState(null); const [editingProjectId, setEditingProjectId] = useState(''); const [editingName, setEditingName] = useState(''); const [actionMessage, setActionMessage] = useState(''); @@ -111,17 +131,24 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri return projects.filter((project) => project.name.toLowerCase().includes(keyword)); }, [projects, search]); - const subModules = selectedProject?.stlFiles?.length - ? selectedProject.stlFiles.map((file) => file.replace(/\.stl$/i, '')) - : []; + const stlFiles = selectedProject?.stlFiles ?? []; + const planeOptions: Array<{ id: Plane; label: string }> = [ + { id: 'axial', label: '横断面' }, + { id: 'sagittal', label: '矢状面' }, + { id: 'coronal', label: '冠状面' }, + ]; + const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false); useEffect(() => { - const next: Record = {}; - subModules.forEach((module) => { - next[module] = visibleModules[module] ?? true; + const next: Record = {}; + stlFiles.forEach((fileName, index) => { + next[fileName] = moduleStyles[fileName] ?? { + visible: true, + color: defaultModuleColors[index % defaultModuleColors.length], + opacity: 0.72, + }; }); - setVisibleModules(next); - setSelectedModelFile(selectedProject?.stlFiles?.[0] ?? ''); + setModuleStyles(next); setSliceIndex(0); }, [selectedProject?.id]); @@ -133,7 +160,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri let cancelled = false; setDicomError(''); - api.getDicomPreview(selectedProject.id, sliceIndex) + api.getDicomPreview(selectedProject.id, sliceIndex, plane) .then((preview) => { if (!cancelled) { setDicomPreview(preview); @@ -149,17 +176,34 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri return () => { cancelled = true; }; - }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, viewMode]); + }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, viewMode]); - const toggleModule = (name: string) => { - setVisibleModules(prev => ({ ...prev, [name]: !prev[name] })); + const updateModuleStyle = (fileName: string, partial: Partial) => { + setModuleStyles(prev => ({ + ...prev, + [fileName]: { + visible: true, + color: '#3b82f6', + opacity: 0.72, + ...(prev[fileName] ?? {}), + ...partial, + }, + })); }; const toggleAllModules = () => { - const allVisible = Object.values(visibleModules).every(v => v); - const newState = { ...visibleModules }; - subModules.forEach(m => newState[m] = !allVisible); - setVisibleModules(newState); + const nextVisible = !allModulesVisible; + setModuleStyles(prev => { + const next = { ...prev }; + stlFiles.forEach((fileName, index) => { + next[fileName] = { + visible: nextVisible, + color: next[fileName]?.color ?? defaultModuleColors[index % defaultModuleColors.length], + opacity: next[fileName]?.opacity ?? 0.72, + }; + }); + return next; + }); }; const handleCreateProject = async () => { @@ -170,6 +214,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri } const created = await api.createProject(name); setNewProjectName(''); + setIsCreateModalOpen(false); setActionMessage(`已创建项目:${created.name}`); await refreshProjects(); setSelectedProject(created); @@ -189,6 +234,28 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri setSelectedProject(updated); }; + const handleEditBlur = (project: Project) => { + if (editingProjectId !== project.id) { + return; + } + if (editingName.trim() && editingName.trim() !== project.name) { + handleRenameProject(project.id); + } else { + setEditingProjectId(''); + setEditingName(''); + } + }; + + const handleDeleteProject = async () => { + if (!projectToDelete) { + return; + } + await api.deleteProject(projectToDelete.id); + setActionMessage(`已删除项目:${projectToDelete.name}`); + setProjectToDelete(null); + await refreshProjects(); + }; + const tabs = [ { id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon }, { id: 'model' as const, label: '3D 模型', icon: Box }, @@ -215,33 +282,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri

项目列表

-
- 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" - /> - -
event.stopPropagation()} + onBlur={() => handleEditBlur(proj)} onChange={(event) => setEditingName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') handleRenameProject(proj.id); @@ -282,30 +330,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri

)}
- {editingProjectId === proj.id ? ( -
- - -
- ) : ( + {editingProjectId !== proj.id && ( +
+ +
)}

@@ -371,9 +408,11 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri > 进入逆向工作区 - + {viewMode !== 'mask' && ( + + )} @@ -382,6 +421,22 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri

{/* Left: DICOM Viewer */}
+
+ {planeOptions.map((option) => ( + + ))} +

PATIENT ID: {selectedProject.id}_XYZ

SCAN DATE: {selectedProject.createTime}

@@ -396,19 +451,25 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} - SLICE: {sliceIndex}/{selectedProject.dicomCount} + 第 {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} 张
{/* Right: Vertical Progress Bar */} -
- NAV +
+ 切片 + + {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} + 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 }} + className="flex-1 w-6 accent-blue-600 cursor-pointer" + style={{ writingMode: 'vertical-lr', direction: 'rtl' }} /> - #{sliceIndex} + #{sliceIndex + 1}
)} @@ -422,10 +483,25 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri - {selectedModelFile ? ( + {stlFiles.some((fileName) => moduleStyles[fileName]?.visible !== false) ? (
- + + {stlFiles.map((fileName) => { + const style = moduleStyles[fileName] ?? { visible: true, color: '#3b82f6', opacity: 0.72 }; + if (!style.visible) { + return null; + } + return ( + + ); + })} +
) : ( @@ -446,45 +522,61 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri {/* Right: Sub-module List */}
-

构件层级 ({subModules.length})

+

构件层级 ({stlFiles.length})

- {subModules.map((m, i) => ( + {stlFiles.map((fileName, i) => { + const name = fileName.replace(/\.stl$/i, ''); + const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72 }; + return (
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' : ''}`} + key={fileName} + className={`p-3 rounded-xl border flex items-start gap-3 group transition-all bg-slate-50 border-transparent hover:border-slate-200 ${!style.visible ? 'opacity-50' : ''}`} > -
- -
+ updateModuleStyle(fileName, { color: event.target.value })} + className="w-8 h-8 rounded-lg border border-white bg-white p-0.5 cursor-pointer shrink-0" + title="模型颜色" + />
-

{m}

-

STL | {selectedProject.stlFiles?.[i]}

+

{name}

+

STL | {fileName}

+
+ 透明度 + updateModuleStyle(fileName, { opacity: Number(event.target.value) })} + className="min-w-0 flex-1 accent-blue-600" + /> + {Math.round(style.opacity * 100)}% +
-
- ))} + )})}
@@ -544,6 +636,74 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
)} + + {isCreateModalOpen && ( +
+
+
+

创建项目

+ +
+ setNewProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleCreateProject(); + } + }} + placeholder="请输入项目名称" + className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + +
+
+
+ )} + + {projectToDelete && ( +
+
+

确认删除项目

+

+ 将删除项目“{projectToDelete.name}”。该操作会从项目列表移除项目,需要恢复默认演示项目时可使用出厂设置。 +

+
+ + +
+
+
+ )} ); } diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 4fd249b..4896e59 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -46,8 +46,12 @@ export const api = { method: 'PATCH', body: JSON.stringify({ name }), }), - getDicomPreview: (projectId: string, slice: number) => - request(`/api/projects/${projectId}/dicom-preview?slice=${slice}`), + deleteProject: (projectId: string) => + request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, { + method: 'DELETE', + }), + getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial') => + request(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}`), getUsers: () => request('/api/users'), resetDemo: () => request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', { diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index 5d51c3d..a743569 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -59,6 +59,7 @@ export interface DicomPreview { width: number; height: number; pixels: string; + plane: 'axial' | 'sagittal' | 'coronal'; slice: number; total: number; fileName: string; diff --git a/工程分析/实现方案-2026-05-04-04-12-34.md b/工程分析/实现方案-2026-05-04-04-12-34.md new file mode 100644 index 0000000..cce65aa --- /dev/null +++ b/工程分析/实现方案-2026-05-04-04-12-34.md @@ -0,0 +1,77 @@ +# 实现方案 + +时间戳:2026-05-04-04-12-34 + +## 修改目标 + +完善项目库导入/下载按钮语义、DICOM 三方向预览、STL 多模型颜色透明度控制、项目创建弹窗、删除确认和编辑自动保存。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `工程分析/测试方案-2026-05-04-04-12-34.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 后端 DICOM 预览扩展。 + - `GET /api/projects/:projectId/dicom-preview?slice=&plane=` + - 支持 `plane=axial|sagittal|coronal`。 + - 横断面读取单张 DICOM。 + - 矢状面/冠状面从 DICOM 序列采样生成重建平面。 +2. 后端项目删除。 + - 新增 `DELETE /api/projects/:projectId`。 + - 删除后写入共享状态。 +3. 前端 API 扩展。 + - `getDicomPreview(projectId, slice, plane)`。 + - `deleteProject(projectId)`。 +4. 项目库交互。 + - 顶部右侧按钮:DICOM/3D 视图显示“导入”;分割结果不显示顶部第二按钮。 + - 创建项目改为点击 `+` 弹窗输入名称。 + - 编辑项目名称改为输入框失焦或回车自动保存。 + - 删除项目点击垃圾桶后弹窗二次确认。 +5. DICOM UI。 + - 增加横断面、矢状面、冠状面切换。 + - 右侧滑块改为稳定轨道,显示 `第 n / 总数`。 + - 圆点与轨道对齐。 +6. 3D 模型 UI。 + - 右侧眼睛为全体显示/隐藏。 + - 每个 STL 使用颜色输入框和透明度滑块。 + - Three.js 同时渲染所有可见 STL,并应用对应颜色和透明度。 + - 删除无意义状态点。 + +## 数据流 + +DICOM: + +前端选择方向和切片 -> 后端按方向返回灰度像素 -> 前端 canvas 绘制。 + +3D: + +后端提供 STL 文件 -> 前端为每个 STL 建立颜色/透明度/可见性状态 -> Three.js 渲染多模型。 + +项目: + +创建弹窗 -> `POST /api/projects`;编辑失焦 -> `PATCH /api/projects/:id`;删除确认 -> `DELETE /api/projects/:id`。 + +## 兼容性与回滚方案 + +- 保留原 `axial` 行为,新增方向参数不影响旧调用。 +- 若矢状面/冠状面解析失败,前端显示错误态。 +- 若 STL 多模型性能不足,可通过全体眼睛或单项眼睛隐藏模型。 +- 回滚时恢复 `ProjectLibrary.tsx` 和相关 API 即可。 + +## 预计文件变更 + +- 修改 `server.ts`、`api.ts`、`types.ts`、`ProjectLibrary.tsx`。 +- 更新测试方案执行结果。 +- 更新经验记录。 + +## 人工审核状态 + +本次用户明确要求无需人工二次确认。 + +状态:自动确认,继续执行。 diff --git a/工程分析/测试方案-2026-05-04-04-12-34.md b/工程分析/测试方案-2026-05-04-04-12-34.md new file mode 100644 index 0000000..691e2b8 --- /dev/null +++ b/工程分析/测试方案-2026-05-04-04-12-34.md @@ -0,0 +1,108 @@ +# 测试方案 + +时间戳:2026-05-04-04-12-34 + +## 测试目标 + +验证项目库导入按钮语义、DICOM 三方向切片、STL 颜色透明度控制、项目创建弹窗、编辑自动保存和删除确认能力。 + +## 静态检查 + +- 检查 `ProjectLibrary.tsx`: + - DICOM/3D 顶部第二按钮为“导入”。 + - 分割结果页无顶部第二按钮。 + - 创建项目通过弹窗触发。 + - 删除项目通过确认弹窗触发。 + - 编辑项目名称无保存按钮,失焦自动保存。 + - STL 模块包含颜色输入和透明度滑块。 + - 删除无意义状态点。 +- 检查 `server.ts`: + - DICOM preview 支持 `plane`。 + - 项目支持 `DELETE`。 + +## 构建与类型检查 + +```bash +cd WebSite +npm run lint +npm run build +``` + +预期: + +- TypeScript 检查通过。 +- Vite 构建通过。 + +## API 验证 + +```bash +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0' +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=256' +curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=256' +curl -s -X POST http://127.0.0.1:4000/api/projects -H 'Content-Type: application/json' -d '{"name":"删除测试项目"}' +curl -s -X DELETE http://127.0.0.1:4000/api/projects/' +``` + +预期: + +- 三方向 DICOM preview 均返回 `width`、`height`、`pixels`、`plane`。 +- 创建项目成功。 +- 删除项目成功。 + +## 页面验证 + +- DICOM 页右侧控制条圆点与轨道对齐。 +- DICOM 页显示 `第 n / 总数`。 +- 可切换横断面、矢状面、冠状面。 +- 3D 页整体眼睛可控制所有 STL 显示/隐藏。 +- 单个 STL 的颜色和透明度控制影响模型渲染。 +- 项目创建由弹窗完成。 +- 项目编辑失焦自动保存。 +- 项目删除需要二次确认。 + +## 回归风险 + +- 矢状面/冠状面每次请求会读取多张 DICOM,可能有延迟。 +- 多 STL 同时显示时首屏加载可能较慢。 + +## 人工审核状态 + +本次用户明确要求无需人工二次确认。 + +状态:自动确认,继续执行。 + +## 执行结果 + +- `npm run lint` 执行成功。 +- `npm run build` 执行成功。 +- Vite 仍有大 chunk 警告,当前不影响本次功能。 +- `GET /api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0` 返回: + - `plane: axial` + - `width: 512` + - `height: 512` + - `total: 300` +- `GET /api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=0` 返回: + - `plane: sagittal` + - `width: 300` + - `height: 512` + - `total: 512` +- `GET /api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=0` 返回: + - `plane: coronal` + - `width: 300` + - `height: 512` + - `total: 512` +- `POST /api/projects` 创建删除测试项目成功。 +- `DELETE /api/projects/:id` 删除测试项目成功。 +- `POST /api/demo/reset` 执行成功,演示环境已恢复默认项目。 +- headless Chrome 打开页面后未捕获 Recharts 宽高警告、`Uncaught` 或页面错误。 +- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`。 +- 当前服务由 `tmux` 会话 `revoxelseg-dicom` 托管。 + +## 页面侧验证点 + +- 顶部第二按钮在 `DICOM 影像` 和 `3D 模型` 视图显示为“导入”。 +- `分割结果` 视图顶部不显示额外导出按钮,只保留下方 NII/NII.GZ 下载按钮。 +- 项目创建入口改为点击 `+` 后弹窗。 +- 项目删除通过确认弹窗执行。 +- 项目编辑输入框失焦或回车自动保存。 +- 3D 模型侧栏每个 STL 提供颜色和透明度控制,整体眼睛控制所有 STL 可见性。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 51f0d96..9402e77 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -271,3 +271,57 @@ C. 解决问题方案 D. 后续如何避免问题 只有当需要真实 DICOM 空间解析、STL 体素填充、NIfTI 精确写入或批处理算法时,再引入 Python/conda,并把环境文件纳入项目文档。 + +## 2026-05-04-04-12-34 DICOM 三方向预览 + +A. 具体问题 + +项目库 DICOM 影像只能看横断面,且右侧切片控制显示为 `NAV` 和 `#0`,不符合医学影像浏览习惯。 + +B. 产生问题原因 + +旧前端只把 DICOM 序列当成单一轴向切片数组浏览,后端 DICOM preview API 只返回单张横断面。 + +C. 解决问题方案 + +后端 DICOM preview API 增加 `plane=axial|sagittal|coronal` 参数。横断面读取单张 DICOM,矢状面和冠状面从 DICOM 序列逐张采样重建灰度平面。前端增加方向切换,并把右侧控制改为 `第 n / 总数` 的切片语义。 + +D. 后续如何避免问题 + +医学影像浏览控件应按方向、当前切片和总切片数表达,不使用模糊的导航标签;多方向重建后续可加入缓存或 Python 预处理优化性能。 + +## 2026-05-04-04-12-34 STL 多模型样式控制 + +A. 具体问题 + +3D 模型视图只显示单个 STL,右侧状态点无实际意义,用户需要不同 STL 有独立颜色和透明度,并由整体眼睛统一控制显示。 + +B. 产生问题原因 + +旧实现把 STL 列表当成选择器,只加载当前选中的一个模型;侧栏状态点只是装饰,没有绑定模型材质。 + +C. 解决问题方案 + +前端为每个 STL 建立 `visible`、`color`、`opacity` 状态,同时加载所有可见 STL;每个模型材质绑定对应颜色和透明度,右侧整体眼睛统一切换所有 STL 可见性,删除无意义状态点。 + +D. 后续如何避免问题 + +三维列表中的视觉控件必须和真实渲染状态绑定;颜色、透明度、可见性等控件不应只是静态装饰。 + +## 2026-05-04-04-12-34 项目管理交互 + +A. 具体问题 + +创建项目输入框常驻在项目列表中占空间;项目编辑需要手动保存按钮;项目缺少删除入口和二次确认。 + +B. 产生问题原因 + +项目管理功能初版偏向快速可用,没有区分高频浏览和低频管理操作。 + +C. 解决问题方案 + +创建项目改为点击 `+` 后弹窗输入;项目名编辑改为失焦或回车自动保存;项目右侧增加删除按钮,点击后必须在确认弹窗中再次确认。 + +D. 后续如何避免问题 + +列表页面应减少常驻表单噪声;破坏性操作必须二次确认;轻量编辑可采用失焦保存,但需要避免空名称提交。 diff --git a/工程分析/需求分析-2026-05-04-04-12-34.md b/工程分析/需求分析-2026-05-04-04-12-34.md new file mode 100644 index 0000000..e90f0ae --- /dev/null +++ b/工程分析/需求分析-2026-05-04-04-12-34.md @@ -0,0 +1,59 @@ +# 需求分析 + +时间戳:2026-05-04-04-12-34 + +## 原始需求摘要 + +用户要求严格使用代码编纂工作流处理本次修改,并在开始时确认工作流整体流程;本次需求分析、实现方案、测试方案和执行修改均不需要人工二次确认。 + +具体需求: + +1. 项目库中 `DICOM 影像`、`3D 模型` 视图右侧按钮应为“导入”,不是“导出”;`分割结果` 视图右侧不需要顶部导出按钮,因为下方已有下载按钮。 +2. DICOM 影像右侧滚动条展示差,圆圈不在条上;切片进度不应显示为 `0~NAV`,应显示当前第几张/总张数;除横断面外,增加矢状面、冠状面选择。 +3. 3D 模型右侧眼睛表示整体显示开关;不同 STL 前面应为 RGB 颜色框,可调整颜色与透明度,并在模型显示中生效;删除无意义状态点样式。 +4. 项目列表中已有项目右侧除编辑外增加删除按钮,删除需要二次确认。 +5. 创建项目交互改为点击 `+` 后弹窗创建,删除常驻的“新增项目名称”输入栏。 +6. 项目名称编辑后不需要保存按钮,点击其他区域自动保存。 + +## 业务目标 + +- 优化项目库的资产管理交互,使导入、下载、创建、编辑、删除的语义明确。 +- 改善 DICOM 浏览体验,支持横断面、矢状面、冠状面三方向预览。 +- 改善 STL 多模型浏览体验,支持每个 STL 独立颜色和透明度,并提供整体显示开关。 +- 降低项目列表的视觉噪声,创建项目采用弹窗,编辑项目采用自动保存。 + +## 输入与输出 + +输入: + +- 用户在项目库中选择 DICOM 方向与切片。 +- 用户调整 STL 模块颜色、透明度、可见性。 +- 用户创建、编辑、删除项目。 + +输出: + +- DICOM 预览支持 `axial`、`sagittal`、`coronal`。 +- 右侧切片控制显示为 `第 n / 总数`。 +- 3D 模型视图同时显示多个 STL,并应用颜色/透明度。 +- 项目创建弹窗。 +- 项目删除确认弹窗。 +- 项目名编辑失焦自动保存。 + +## 影响范围 + +- `WebSite/server.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `工程分析/经验记录.md` + +## 风险点 + +- 矢状面/冠状面预览需要从多个 DICOM 切片采样,性能比横断面低。本次以演示可用为主,后续可加入缓存或 Python 预处理。 +- 同时加载 9 个 STL 可能增加浏览器渲染压力,需要保持透明度和可见性状态可控。 +- 自动保存项目名需要避免空名称提交。 +- 删除项目需要防止误删默认项目或至少提供明确二次确认。本次默认项目也允许删除前确认,但恢复出厂设置可恢复默认项目。 + +## 待确认问题 + +- 本次用户已明确无需二次确认,直接执行。