) => {
+ 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 可能增加浏览器渲染压力,需要保持透明度和可见性状态可控。
+- 自动保存项目名需要避免空名称提交。
+- 删除项目需要防止误删默认项目或至少提供明确二次确认。本次默认项目也允许删除前确认,但恢复出厂设置可恢复默认项目。
+
+## 待确认问题
+
+- 本次用户已明确无需二次确认,直接执行。