From 3bedf204c8a8782fee4974f2a91e36a673262319 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 24 May 2026 16:15:52 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-24-15-55-48=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=94=81=E5=AE=9A=E4=B8=8E=E5=88=87=E7=89=87?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + Docker部署/Dockerfile | 2 +- Docker部署/README.md | 6 + Docker部署/威联通NAS/docker_compose.yaml | 3 +- Docker部署/本机/docker_compose.yaml | 3 +- WebSite/server.ts | 175 +++++++++++++++++++- WebSite/src/components/ProjectLibrary.tsx | 165 ++++++++++++++---- WebSite/src/components/ReverseWorkspace.tsx | 95 ++++++++--- WebSite/src/lib/api.ts | 5 + WebSite/src/types.ts | 5 + 工程分析/实现方案-2026-05-24-15-55-48.md | 62 +++++++ 工程分析/测试方案-2026-05-24-15-55-48.md | 58 +++++++ 工程分析/经验记录.md | 18 ++ 工程分析/需求分析-2026-05-24-15-55-48.md | 49 ++++++ 14 files changed, 586 insertions(+), 62 deletions(-) create mode 100644 工程分析/实现方案-2026-05-24-15-55-48.md create mode 100644 工程分析/测试方案-2026-05-24-15-55-48.md create mode 100644 工程分析/需求分析-2026-05-24-15-55-48.md diff --git a/.gitignore b/.gitignore index 9fe8d75..92be9b4 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ WebSite/data/ WebSite/exports/ Docker部署/**/data/ Docker部署/**/exports/ +Docker部署/**/locked-results/ +项目数据/锁定结果/ # Local env .env diff --git a/Docker部署/Dockerfile b/Docker部署/Dockerfile index d0c4074..f7f364e 100644 --- a/Docker部署/Dockerfile +++ b/Docker部署/Dockerfile @@ -22,7 +22,7 @@ COPY Head_CT_DICOM ./Head_CT_DICOM COPY Head_CT_ReConstruct ./Head_CT_ReConstruct WORKDIR /app/WebSite -RUN npm run build && mkdir -p data exports +RUN npm run build && mkdir -p data exports /app/项目数据/锁定结果 ENV NODE_ENV=production diff --git a/Docker部署/README.md b/Docker部署/README.md index ba5fade..8dacef0 100644 --- a/Docker部署/README.md +++ b/Docker部署/README.md @@ -17,6 +17,8 @@ - “构件分别导出”会把所有构件 NIfTI 文件集中到导出包内的 `segmentation-parts/` 目录。 - 项目库 DICOM 首页支持滚轮缩放、拖拽平移和位置重置。 - 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。 +- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。 +- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 ## 一、本机部署 @@ -79,6 +81,7 @@ docker compose -f Docker部署/威联通NAS/docker_compose.yaml up -d --build - `build.context` - `/share/Container/revoxelseg_dicom/data` - `/share/Container/revoxelseg_dicom/exports` +- `/share/Container/revoxelseg_dicom/locked-results` ## 三、数据持久化 @@ -86,16 +89,19 @@ docker compose -f Docker部署/威联通NAS/docker_compose.yaml up -d --build - `/app/WebSite/data`:项目状态、上传数据和导入缓存。 - `/app/WebSite/exports`:导出的 NII/NII.GZ/TAR.GZ 文件。 +- `/app/项目数据/锁定结果`:项目锁定时保存的位姿与构件样式快照。 本机部署会挂载到: - `Docker部署/本机/data` - `Docker部署/本机/exports` +- `Docker部署/本机/locked-results` NAS 部署会挂载到: - `/share/Container/revoxelseg_dicom/data` - `/share/Container/revoxelseg_dicom/exports` +- `/share/Container/revoxelseg_dicom/locked-results` 默认演示数据 `Head_CT_DICOM/` 与 `Head_CT_ReConstruct/` 会复制进镜像,用于新环境首次启动后的默认项目。 diff --git a/Docker部署/威联通NAS/docker_compose.yaml b/Docker部署/威联通NAS/docker_compose.yaml index 80481b2..b478a7e 100644 --- a/Docker部署/威联通NAS/docker_compose.yaml +++ b/Docker部署/威联通NAS/docker_compose.yaml @@ -8,7 +8,7 @@ name: revoxelseg-dicom-qnap services: revoxelseg_web: - image: revoxelseg-dicom:web-qnap-20260524 + image: revoxelseg-dicom:web-qnap-20260524-lock build: context: /share/Container/revoxelseg_dicom dockerfile: Docker部署/Dockerfile @@ -32,6 +32,7 @@ services: volumes: - /share/Container/revoxelseg_dicom/data:/app/WebSite/data - /share/Container/revoxelseg_dicom/exports:/app/WebSite/exports + - /share/Container/revoxelseg_dicom/locked-results:/app/项目数据/锁定结果 healthcheck: test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] interval: 10s diff --git a/Docker部署/本机/docker_compose.yaml b/Docker部署/本机/docker_compose.yaml index 8d9d792..67876de 100644 --- a/Docker部署/本机/docker_compose.yaml +++ b/Docker部署/本机/docker_compose.yaml @@ -7,7 +7,7 @@ name: revoxelseg-dicom-local services: revoxelseg_web: - image: revoxelseg-dicom:web-local-20260524 + image: revoxelseg-dicom:web-local-20260524-lock build: context: ../.. dockerfile: Docker部署/Dockerfile @@ -22,6 +22,7 @@ services: volumes: - ./data:/app/WebSite/data - ./exports:/app/WebSite/exports + - ./locked-results:/app/项目数据/锁定结果 healthcheck: test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] interval: 10s diff --git a/WebSite/server.ts b/WebSite/server.ts index 91b4148..039dc30 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -83,6 +83,11 @@ interface ProjectRecord { maskFormats: Array<'nii' | 'nii.gz'>; exportedMaskCount: number; isDefault?: boolean; + locked: boolean; + lockedAt: string | null; + unlockedAt: string | null; + lastProcessedAt: string; + lockedPoseSnapshotPath: string | null; moduleStyles: Record; modelPoses: ModelPoseRecord[]; segmentationResults: SegmentationResultRecord[]; @@ -121,6 +126,7 @@ const uploadTempDir = path.join(dataDir, 'upload-tmp'); const statePath = path.join(dataDir, 'state.json'); const dicomDir = path.join(repoRoot, 'Head_CT_DICOM'); const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct'); +const lockedResultDir = path.join(repoRoot, '项目数据', '锁定结果'); const dicomPreviewCache = new Map(); const dicomVolumeCache = new Map 0 ? new Date(time).toISOString() : null; +} + +function latestProjectTimestamp(project: Partial, fallback: string) { + const latestResult = Array.isArray(project.segmentationResults) + ? project.segmentationResults[project.segmentationResults.length - 1] + : undefined; + const candidates = [ + project.lastProcessedAt, + project.lockedAt, + project.unlockedAt, + latestResult?.createdAt, + project.createTime, + fallback, + ]; + const latest = Math.max(...candidates.map(timestampMillis)); + return new Date(latest || timestampMillis(fallback) || Date.now()).toISOString(); +} + +function normalizeProjectLockFields(project: Partial, fallback: string) { + const snapshotPath = typeof project.lockedPoseSnapshotPath === 'string' && project.lockedPoseSnapshotPath.trim() + ? project.lockedPoseSnapshotPath.trim().slice(0, 240) + : null; + return { + locked: project.locked === true, + lockedAt: normalizeOptionalTimestamp(project.lockedAt), + unlockedAt: normalizeOptionalTimestamp(project.unlockedAt), + lastProcessedAt: latestProjectTimestamp(project, fallback), + lockedPoseSnapshotPath: snapshotPath, + }; +} + +function projectActivityTime(project: ProjectRecord) { + return timestampMillis(project.lastProcessedAt) || timestampMillis(project.createTime); +} + +function sortProjectsByLastProcessed(projects: ProjectRecord[]) { + return [...projects].sort((a, b) => projectActivityTime(b) - projectActivityTime(a)); +} + +function touchProject(project: ProjectRecord, at = now()) { + project.lastProcessedAt = at; +} + function listFiles(dir: string, extension: string) { if (!fs.existsSync(dir)) { return []; @@ -488,6 +548,7 @@ function normalizeSegmentationResults( function buildDefaultProject(): ProjectRecord { const stlFiles = listFiles(modelDir, '.stl'); + const createdAt = now(); return { id: 'head-ct-demo', @@ -503,6 +564,11 @@ function buildDefaultProject(): ProjectRecord { maskFormats: ['nii', 'nii.gz'], exportedMaskCount: 0, isDefault: true, + locked: false, + lockedAt: null, + unlockedAt: null, + lastProcessedAt: createdAt, + lockedPoseSnapshotPath: null, moduleStyles: buildModuleStyles(stlFiles), modelPoses: defaultModelPoses(), segmentationResults: [], @@ -510,6 +576,7 @@ function buildDefaultProject(): ProjectRecord { } function buildEmptyProject(name: string): ProjectRecord { + const createdAt = now(); return { id: `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, name, @@ -523,6 +590,11 @@ function buildEmptyProject(name: string): ProjectRecord { stlFiles: [], maskFormats: ['nii', 'nii.gz'], exportedMaskCount: 0, + locked: false, + lockedAt: null, + unlockedAt: null, + lastProcessedAt: createdAt, + lockedPoseSnapshotPath: null, moduleStyles: {}, modelPoses: defaultModelPoses(), segmentationResults: [], @@ -555,8 +627,12 @@ function normalizeState(state: AppState): AppState { ? listFiles(resolveStoredAssetDir(modelPath, ''), '.stl') : Array.isArray(project.stlFiles) ? project.stlFiles : []; const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles); + const modelPoses = normalizeModelPoses(project.modelPoses); + const segmentationResults = normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount); + const lockFields = normalizeProjectLockFields({ ...project, segmentationResults }, typeof project.createTime === 'string' ? project.createTime : now()); return { ...project, + ...lockFields, dicomPath, modelPath, dicomCount, @@ -566,8 +642,8 @@ function normalizeState(state: AppState): AppState { exportedMaskCount: project.exportedMaskCount ?? 0, maskFormats: project.maskFormats ?? ['nii', 'nii.gz'], moduleStyles, - modelPoses: normalizeModelPoses(project.modelPoses), - segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount), + modelPoses, + segmentationResults, }; }) : []; @@ -576,12 +652,23 @@ function normalizeState(state: AppState): AppState { const defaultDicomCount = listFiles(resolveStoredAssetDir(defaultDicomPath, dicomDir), '.dcm').length; const defaultStlFiles = listFiles(resolveStoredAssetDir(defaultModelPath, modelDir), '.stl'); const defaultModuleStyles = buildModuleStyles(defaultStlFiles, savedDefaultProject?.moduleStyles); + const defaultSegmentationResults = normalizeSegmentationResults( + savedDefaultProject?.segmentationResults, + defaultStlFiles, + defaultModuleStyles, + defaultDicomCount, + ); + const defaultLockFields = normalizeProjectLockFields( + { ...savedDefaultProject, segmentationResults: defaultSegmentationResults }, + savedDefaultProject?.createTime ?? defaultProject.createTime, + ); return { ...state, projects: [ { ...defaultProject, + ...defaultLockFields, name: savedDefaultProject?.name ?? defaultProject.name, dicomPath: defaultDicomPath, modelPath: defaultModelPath, @@ -592,12 +679,7 @@ function normalizeState(state: AppState): AppState { exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0, moduleStyles: defaultModuleStyles, modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses), - segmentationResults: normalizeSegmentationResults( - savedDefaultProject?.segmentationResults, - defaultStlFiles, - defaultModuleStyles, - defaultDicomCount, - ), + segmentationResults: defaultSegmentationResults, }, ...customProjects, ], @@ -1726,6 +1808,44 @@ function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) { }, null, 2), 'utf8'); } +function createProjectLockSnapshot(project: ProjectRecord, lockedAt: string) { + const latestResult = latestSegmentationResult(project); + const activePose = latestResult?.pose + ?? project.modelPoses.find((pose) => pose.id === 'default')?.pose + ?? project.modelPoses[0]?.pose + ?? defaultModelPose; + + return { + schemaVersion: 1, + lockedAt, + project: { + id: project.id, + name: project.name, + status: project.status, + createTime: project.createTime, + dicomCount: project.dicomCount, + modelCount: project.modelCount, + dicomPath: project.dicomPath, + modelPath: project.modelPath, + stlFiles: project.stlFiles, + }, + activePose, + modelPoses: project.modelPoses, + moduleStyles: project.moduleStyles, + latestSegmentationResult: latestResult ?? null, + note: 'This snapshot is written when a project is locked from the project list. It stores ReVoxelSeg pose and style data, not raw DICOM or STL files.', + }; +} + +function writeProjectLockSnapshot(project: ProjectRecord, lockedAt: string) { + ensureDir(lockedResultDir); + const filename = `${sanitizeFilenamePart(project.name, project.id)}-${timestampForFilename(new Date(lockedAt))}-pose-lock.json`; + const filePath = path.join(lockedResultDir, filename); + const exportProject = projectWithSegmentationResultStyles(project); + fs.writeFileSync(filePath, JSON.stringify(createProjectLockSnapshot(exportProject, lockedAt), null, 2), 'utf8'); + return toRepoRelativePath(filePath); +} + function createProjectExportBundle({ project, files, @@ -2652,7 +2772,7 @@ async function startServer() { }); app.get('/api/projects', (_req, res) => { - res.json(readState().projects); + res.json(sortProjectsByLastProcessed(readState().projects)); }); app.post('/api/projects', (req, res) => { @@ -2693,6 +2813,7 @@ async function startServer() { } project.name = name; + touchProject(project); writeState(state); res.json(project); }); @@ -2710,6 +2831,36 @@ async function startServer() { res.json({ ok: true, deletedId: deleted.id }); }); + app.patch('/api/projects/:projectId/lock', (req, res) => { + const state = readState(); + const project = findProject(state, req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const shouldLock = req.body?.locked === true; + const changedAt = now(); + + try { + if (shouldLock) { + project.locked = true; + project.lockedAt = changedAt; + project.unlockedAt = null; + project.lockedPoseSnapshotPath = writeProjectLockSnapshot(project, changedAt); + touchProject(project, changedAt); + } else { + project.locked = false; + project.unlockedAt = changedAt; + touchProject(project, changedAt); + } + writeState(state); + res.json(project); + } catch (error) { + res.status(500).json({ message: error instanceof Error ? error.message : '项目锁定状态更新失败' }); + } + }); + app.patch('/api/projects/:projectId/module-styles', (req, res) => { const incoming = req.body?.moduleStyles; if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { @@ -2728,6 +2879,7 @@ async function startServer() { ...(project.moduleStyles ?? {}), ...(incoming as Record>), }); + touchProject(project); writeState(state); res.json(project); }); @@ -2747,6 +2899,7 @@ async function startServer() { } project.modelPoses = normalizeModelPoses(incoming as Partial[]); + touchProject(project); writeState(state); res.json(project); }); @@ -2822,6 +2975,7 @@ async function startServer() { } project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending'; + touchProject(project); clearProjectRuntimeCaches(project.id); writeState(state); res.json(project); @@ -2878,6 +3032,7 @@ async function startServer() { record.moduleStyles, project.dicomCount, ); + touchProject(project, record.createdAt); writeState(state); res.status(201).json(project); }); @@ -3105,6 +3260,7 @@ async function startServer() { const filename = `${project.id}-${suffix}.${format}`; fs.writeFileSync(path.join(exportDir, filename), payload); project.exportedMaskCount += target === 'segmentation' ? 1 : 0; + touchProject(project); writeState(state); res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream'); @@ -3156,6 +3312,7 @@ async function startServer() { const filename = `${exportBase}.tar.gz`; fs.writeFileSync(path.join(exportDir, filename), payload); project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0; + touchProject(project); writeState(state); res.setHeader('Content-Type', 'application/gzip'); diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index c4167aa..6ec0ec6 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -22,7 +22,9 @@ import { RefreshCcw, FlipHorizontal2, FlipVertical2, - Move3d + Move3d, + Lock, + Unlock } from 'lucide-react'; import * as THREE from 'three'; import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; @@ -176,6 +178,35 @@ function formatPoseCompactValue(value: number, digits = 2) { return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0'; } +function getProjectActivityTime(project: Project) { + const latestResult = project.segmentationResults?.[project.segmentationResults.length - 1]; + const candidates = [ + project.lastProcessedAt, + project.lockedAt, + project.unlockedAt, + latestResult?.createdAt, + project.createTime, + ]; + return Math.max(...candidates.map((value) => (value ? Date.parse(value) : 0)).filter((value) => Number.isFinite(value)), 0); +} + +function sortProjectsByActivity(projects: Project[]) { + return [...projects].sort((a, b) => getProjectActivityTime(b) - getProjectActivityTime(a)); +} + +function formatProjectActivity(project: Project) { + const time = getProjectActivityTime(project); + if (!time) { + return project.createTime; + } + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(time)); +} + interface AssetImportProgressState { kind: ProjectAssetImportKind; fileCount: number; @@ -824,6 +855,7 @@ export default function ProjectLibrary({ const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [selectedProject, setSelectedProject] = useState(null); + const [showUnlockedOnly, setShowUnlockedOnly] = useState(false); const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>(initialViewMode); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [sliceIndex, setSliceIndex] = useState(0); @@ -865,6 +897,7 @@ export default function ProjectLibrary({ const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState('combined'); const [maskExporting, setMaskExporting] = useState(false); const [assetImporting, setAssetImporting] = useState(false); + const [lockChangingProjectId, setLockChangingProjectId] = useState(''); const [assetImportProgress, setAssetImportProgress] = useState(null); const importInputRef = useRef(null); const importKindRef = useRef('dicom'); @@ -891,13 +924,14 @@ export default function ProjectLibrary({ setLoading(true); return api.getProjects() .then((items) => { - setProjects(items); - items.slice(0, 2).forEach(preloadProjectAssets); + const orderedItems = sortProjectsByActivity(items); + setProjects(orderedItems); + orderedItems.slice(0, 2).forEach(preloadProjectAssets); setSelectedProject((current) => { if (!current) { - return items[0] ?? null; + return orderedItems[0] ?? null; } - return items.find((item) => item.id === current.id) ?? items[0] ?? null; + return orderedItems.find((item) => item.id === current.id) ?? orderedItems[0] ?? null; }); }) .finally(() => setLoading(false)); @@ -917,11 +951,12 @@ export default function ProjectLibrary({ const filteredProjects = useMemo(() => { const keyword = search.trim().toLowerCase(); - if (!keyword) { - return projects; - } - return projects.filter((project) => project.name.toLowerCase().includes(keyword)); - }, [projects, search]); + return sortProjectsByActivity(projects).filter((project) => { + const matchesKeyword = !keyword || project.name.toLowerCase().includes(keyword); + const matchesLockFilter = !showUnlockedOnly || project.locked !== true; + return matchesKeyword && matchesLockFilter; + }); + }, [projects, search, showUnlockedOnly]); const stlFiles = selectedProject?.stlFiles ?? []; const planeOptions: Array<{ id: Plane; label: string }> = [ @@ -941,8 +976,7 @@ export default function ProjectLibrary({ const dicomMaxSlice = Math.max(dicomSliceTotal - 1, 0); const safeDicomSlice = Math.max(0, Math.min(dicomMaxSlice, sliceIndex)); const dicomDisplaySlice = getDicomDisplaySliceNumber(safeDicomSlice, dicomSliceTotal); - const dicomSliderValue = dicomMaxSlice - safeDicomSlice; - const dicomSlicePercent = dicomMaxSlice > 0 ? (dicomSliderValue / dicomMaxSlice) * 100 : 0; + const dicomSliderValue = safeDicomSlice; const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0]; const savedSegmentationResults = selectedProject?.segmentationResults ?? []; const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1]; @@ -969,7 +1003,7 @@ export default function ProjectLibrary({ api.updateProjectModuleStyles(selectedProject.id, next) .then((updated) => { setSelectedProject(updated); - setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item))); + setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item)))); }) .catch((error) => { setActionMessage(error instanceof Error ? error.message : '构件样式保存失败'); @@ -1079,7 +1113,7 @@ export default function ProjectLibrary({ clearCachedProjectAssets(updated.id); preloadedProjectIdsRef.current.delete(updated.id); setSelectedProject(updated); - setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item))); + setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item)))); const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1]; const nextStyles: Record = {}; (updated.stlFiles ?? []).forEach((fileName, index) => { @@ -1399,6 +1433,31 @@ export default function ProjectLibrary({ await refreshProjects(); }; + const handleToggleProjectLock = async (project: Project) => { + setLockChangingProjectId(project.id); + setActionMessage(''); + try { + const updated = await api.updateProjectLock(project.id, project.locked !== true); + setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item)))); + setSelectedProject((current) => (current?.id === updated.id ? updated : current)); + setActionMessage(updated.locked + ? `已锁定,位姿数据已保存到 ${updated.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}` + : '项目已解锁,可以进入逆向工作区'); + } catch (error) { + setActionMessage(error instanceof Error ? error.message : '项目锁定状态更新失败'); + } finally { + setLockChangingProjectId(''); + } + }; + + const handleEnterReverseWorkspace = (project: Project) => { + if (project.locked) { + setActionMessage('项目已锁定,请先点击「解锁项目」后再进入逆向工作区'); + return; + } + onReverse(project.id); + }; + const tabs = [ { id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon }, { id: 'model' as const, label: '3D 模型', icon: Box }, @@ -1555,6 +1614,26 @@ export default function ProjectLibrary({ onChange={(e) => setSearch(e.target.value)} /> +
+ + +
{loading &&

正在从后端载入项目...

} {filteredProjects.map((proj) => ( @@ -1581,9 +1660,19 @@ export default function ProjectLibrary({ className="w-full rounded-md px-2 py-1 text-xs text-slate-900 outline-none ring-1 ring-blue-200" /> ) : ( -

- {proj.name} -

+
+

+ {proj.name} +

+ {proj.locked && ( + + + 锁 + + )} +
)}
{editingProjectId !== proj.id && ( @@ -1613,7 +1702,7 @@ export default function ProjectLibrary({ )}

- {proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0} + 处理 {formatProjectActivity(proj)} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}

))} @@ -1628,11 +1717,12 @@ export default function ProjectLibrary({
setSelectedProject(p)} - className={`w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${ + className={`relative w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${ selectedProject?.id === p.id ? 'bg-blue-600 text-white shadow-md' : 'bg-slate-50 text-slate-400' }`} > + {p.locked && }
))} @@ -1664,9 +1754,28 @@ export default function ProjectLibrary({ ))}
+ @@ -1846,12 +1955,12 @@ export default function ProjectLibrary({ {dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
-
setSliceIndex(dicomMaxSlice - Number(event.target.value))} + onChange={(event) => setSliceIndex(Number(event.target.value))} className="mapping-slice-dark-vertical-input" aria-label="项目库 DICOM 切片导航" />
-
onSliceChange(maxSlice - Number(event.target.value))} + onChange={(event) => onSliceChange(Number(event.target.value))} className="mapping-slice-vertical-input" aria-label="逆向分割映射视图切片导航" />
- 顶层 {Math.max(totalSlices, 1)} + 顶层 1 当前 {displaySliceNumber} - 底层 1 + 底层 {Math.max(totalSlices, 1)}
@@ -2759,6 +2751,9 @@ export default function ReverseWorkspace({ if (!project) { return true; } + if (project.locked) { + return true; + } if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) { return true; } @@ -2918,6 +2913,19 @@ export default function ReverseWorkspace({ }); api.getProject(projectId).then((item) => { setProject(item); + if (item.locked) { + setFusionVolume(null); + setWorkspaceLoadState({ + ready: false, + phase: '项目已锁定', + loaded: 1, + total: 1, + startedAt: Date.now(), + error: '项目已锁定,请在项目库解锁后再进入逆向工作区。', + }); + savedWorkspaceSnapshotRef.current = ''; + return; + } const maxIndex = Math.max((item.dicomCount || 1) - 1, 0); const latestResult = item.segmentationResults?.[item.segmentationResults.length - 1]; const restoredSliceStart = clamp(latestResult?.sliceStart ?? 0, 0, maxIndex); @@ -3167,6 +3175,21 @@ export default function ReverseWorkspace({ restoreVisualToolbarScroll(scrollTop); }; + const allModulesVisible = Boolean(project?.stlFiles?.length) && (project?.stlFiles ?? []).every((fileName) => moduleStyles[fileName]?.visible !== false); + + const toggleAllModules = () => { + const stlFiles = project?.stlFiles ?? []; + const nextVisible = !allModulesVisible; + const next = { ...moduleStyles }; + stlFiles.forEach((fileName, index) => { + next[fileName] = makeDefaultModuleStyle(index, { + ...(next[fileName] ?? project?.moduleStyles?.[fileName]), + visible: nextVisible, + }); + }); + commitModuleStyles(next); + }; + const updateModuleStyle = (fileName: string, partial: Partial) => { const stlFiles = project?.stlFiles ?? []; const index = Math.max(0, stlFiles.indexOf(fileName)); @@ -3280,7 +3303,7 @@ export default function ReverseWorkspace({ const workspaceLoadSpeed = workspaceLoadState.loaded / workspaceElapsedSeconds; useEffect(() => { - if (!project?.dicomCount) { + if (!project?.dicomCount || project.locked) { return undefined; } if (workspaceLoadProjectRef.current === project.id) { @@ -3371,6 +3394,7 @@ export default function ReverseWorkspace({ }, [ project?.id, project?.dicomCount, + project?.locked, project?.stlFiles?.join('|'), displayStart, displayEnd, @@ -3398,6 +3422,27 @@ export default function ReverseWorkspace({ modelPose.rotateZ, ]); + if (project?.locked) { + return ( +
+
+
+ +
+

项目已锁定

+

+ 请先在项目库点击“解锁项目”,再进入逆向工作区继续修改位姿和分割结果。 +

+
+

项目:{project.name}

+

锁定时间:{project.lockedAt ? new Date(project.lockedAt).toLocaleString('zh-CN') : '未记录'}

+

锁定结果:{project.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}

+
+
+
+ ); + } + if (!workspaceLoadState.ready) { return (
@@ -3943,7 +3988,17 @@ export default function ReverseWorkspace({

构件层级

- {project?.stlFiles?.length ?? 0} +
+ {project?.stlFiles?.length ?? 0} + +
{(project?.stlFiles ?? []).map((fileName, index) => { diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 9ca1b12..1ed2c0e 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -114,6 +114,11 @@ export const api = { request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, { method: 'DELETE', }), + updateProjectLock: (projectId: string, locked: boolean) => + request(`/api/projects/${projectId}/lock`, { + method: 'PATCH', + body: JSON.stringify({ locked }), + }), updateProjectModuleStyles: (projectId: string, moduleStyles: Record) => request(`/api/projects/${projectId}/module-styles`, { method: 'PATCH', diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index 5103e0b..3a3e4f9 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -20,6 +20,11 @@ export interface Project { maskFormats?: Array<'nii' | 'nii.gz'>; exportedMaskCount?: number; isDefault?: boolean; + locked?: boolean; + lockedAt?: string | null; + unlockedAt?: string | null; + lastProcessedAt?: string; + lockedPoseSnapshotPath?: string | null; moduleStyles?: Record; modelPoses?: SavedModelPose[]; segmentationResults?: SegmentationResult[]; diff --git a/工程分析/实现方案-2026-05-24-15-55-48.md b/工程分析/实现方案-2026-05-24-15-55-48.md new file mode 100644 index 0000000..d180b27 --- /dev/null +++ b/工程分析/实现方案-2026-05-24-15-55-48.md @@ -0,0 +1,62 @@ +# 实现方案-2026-05-24-15-55-48 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-24-15-55-48.md` + +## 修改目标 + +- 在逆向工作区构件层级标题右侧增加全局显隐按钮。 +- 在项目库项目卡片中增加锁定/解锁能力,并将锁定位姿数据保存到 `项目数据/锁定结果/`。 +- 项目列表支持未锁定筛选,并按最后处理时间倒序排列。 +- 修正 DICOM 切片滚动条的方向与无进度色视觉。 + +## 涉及路径 + +- `WebSite/server.ts` +- `WebSite/src/types.ts` +- `WebSite/src/lib/api.ts` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/index.css` +- `Docker部署/` +- `工程分析/经验记录.md` + +## 技术路线 + +- 后端为项目状态增加 `locked`、`lockedAt`、`lastProcessedAt`、`lockedPoseSnapshotPath` 等兼容字段,并提供锁定/解锁 API。 +- 锁定时读取当前项目位姿、构件样式和基础元数据,写入 `项目数据/锁定结果/{projectName或projectId}-{timestamp}.json`。 +- 前端项目库使用新增 API 切换锁定状态;项目卡片在进入逆向工作区前判断锁定状态并提示。 +- 逆向工作区构件层级右侧新增全局眼睛按钮,批量更新所有构件的 `visible` 字段。 +- 切片滑块保持顶部为 1、底部为 N,去掉滚动后轨道进度色。 + +## 执行步骤 + +1. 阅读现有项目类型、项目库、逆向工作区和后端项目 API。 +2. 扩展类型与 API 封装。 +3. 实现后端锁定状态归一化、锁定结果落盘和接口。 +4. 实现项目库锁定按钮、未锁定筛选、排序与锁定提示。 +5. 实现逆向工作区全局构件显隐按钮。 +6. 修正切片滚动条方向和 CSS。 +7. 同步 Docker 部署说明。 +8. 运行 lint/build,重新部署并验证。 +9. 提交并推送 Gitea。 + +## 兼容性与回滚方案 + +- 旧项目缺少锁定字段时默认 `locked=false`,缺少最后处理时间时回退到 `updatedAt`、`createdAt`。 +- 如锁定结果目录不存在则由后端自动创建。 +- 回滚时可移除新增 API 和前端按钮,已有锁定 JSON 文件不影响系统启动。 + +## 预计文件变更 + +- 新增本次三份工程分析文档。 +- 修改前后端项目状态、交互和样式文件。 +- 修改 Docker 部署说明,记录新增锁定结果目录和功能。 +- 追加经验记录。 + +## 提交与部署策略 + +- 仅暂存本次相关文档、前后端源码和 Docker 文档。 +- Commit message 包含 `2026-05-24-15-55-48` 与简要描述。 +- 使用 `tmux` 会话 `revoxelseg-dicom` 重新运行 `npm run serve -- --host 0.0.0.0 --port 4000`。 diff --git a/工程分析/测试方案-2026-05-24-15-55-48.md b/工程分析/测试方案-2026-05-24-15-55-48.md new file mode 100644 index 0000000..c33eccb --- /dev/null +++ b/工程分析/测试方案-2026-05-24-15-55-48.md @@ -0,0 +1,58 @@ +# 测试方案-2026-05-24-15-55-48 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-24-15-55-48.md` + +## 静态检查 + +- 在 `WebSite/` 执行 `npm run lint`,确认 TypeScript 类型检查通过。 + +## 构建检查 + +- 在 `WebSite/` 执行 `npm run build`,确认生产构建成功。 + +## 关键业务场景验证 + +- 项目库项目卡片可锁定和解锁。 +- 锁定项目后,“进入逆向工作区”给出需要解锁提示,不进入工作区。 +- 项目库左侧可筛选未锁定项目。 +- 项目列表按最后处理时间倒序显示。 +- 逆向工作区“构件层级”标题右侧全局眼睛可批量隐藏/显示构件。 +- DICOM 切片滚动条顶部对应第 1 张,向下拖动查看到更大序号,轨道无进度色。 + +## 医学影像数据相关边界验证 + +- 缺少锁定字段的旧项目仍可显示和进入。 +- 锁定时保存的位姿数据包含模型平移、旋转、缩放、镜像和构件样式快照。 +- DICOM 切片显示编号不回退到旧的倒序错误。 + +## 部署验证 + +- 重新构建后通过 `tmux` 会话 `revoxelseg-dicom` 启动服务。 +- 验证 `http://127.0.0.1:4000/api/health`。 +- 验证 `http://127.0.0.1:4000/`。 +- 验证 `https://revoxel.huijutec.cn/` 和 `/api/health`。 + +## Git/Gitea 备份验证 + +- 使用 `git status --short` 检查仅包含本次相关变化。 +- Commit message 包含 `2026-05-24-15-55-48`。 +- 推送到 Gitea `origin/main` 后确认本地分支与远程同步。 + +## 风险与回归关注点 + +- 锁定结果 JSON 不应把大型 DICOM/STL 原始数据写入 Git 跟踪。 +- 批量构件显隐不应覆盖颜色、透明度、Mask ID 等其他配置。 +- 切片滚动方向调整需同时覆盖项目库和逆向工作区相关控件。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;仅保留 Vite chunk size 提示。 +- `tmux` 会话 `revoxelseg-dicom` 已重启,运行 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。 +- `http://127.0.0.1:4000/api/health`:HTTP 200。 +- `http://127.0.0.1:4000/`:HTTP 200。 +- `https://revoxel.huijutec.cn/api/health`:HTTP 200。 +- `https://revoxel.huijutec.cn/`:HTTP 200。 +- 临时项目锁定 API 验证通过:锁定生成 `项目数据/锁定结果/*-pose-lock.json`,解锁与删除正常;临时快照文件已清理。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 3777742..cfd2942 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1693,3 +1693,21 @@ C. 解决问题方案 D. 后续如何避免问题 后续做 STL 到 Label Map 的切面填充时,不能只按全局行交点两两配对,必须先按轮廓连通性分组。遇到长线桥接,应同时检查源 STL 的边界边/非流形边和体素化配对策略;如果 STL 是真实中心线或开口片面,只能近似显示,不能承诺恢复真实医学实体。每次修改导出算法后,至少用包含中文构件名和复杂肝脏血管的项目执行一次 `separate + all` 导出,并用 `tar -tzf` 验证文件完整。 + +## 2026-05-24-15-55-48 项目锁定状态与切片滑块方向要分开建模 + +A. 具体问题 + +用户要求项目库支持锁定/解锁项目,锁定时保存位姿快照到 `项目数据/锁定结果/`;已锁定项目进入逆向工作区时提示需要解锁;项目列表可筛选未上锁项目并按最后处理时间排序。同时,DICOM 切片位置滑条不应显示进度色,且第 1 张应位于滑条顶部,向下查看到第 N 张。 + +B. 产生问题原因 + +项目此前没有锁定字段、最后处理时间和锁定快照 API,项目列表只能按读取顺序展示。切片显示层号已经使用 `total - sliceIndex` 转换为用户可见顺序,但滑块值仍使用旧的反向映射和底部进度填充,导致第 1 张出现在滑条底部,并被误看成进度条。 + +C. 解决问题方案 + +为项目状态增加 `locked`、`lockedAt`、`unlockedAt`、`lastProcessedAt`、`lockedPoseSnapshotPath`,新增锁定 API;锁定时写入只包含项目元数据、模型位姿、构件样式和最新分割结果的 JSON 快照,不写入原始 DICOM/STL。项目库增加锁定/解锁按钮、未上锁筛选、锁定提示和最后处理时间排序;逆向工作区自身也拦截已锁定项目。切片滑块改用实际切片索引作为控件值,保留显示层号转换,并删除滑条上的彩色填充层。 + +D. 后续如何避免问题 + +凡是新增项目运行态数据,都要同时考虑后端状态归一化、前端类型、API、列表排序、Docker 持久化挂载和 `.gitignore`。锁定类快照属于运行态追溯数据,不应混入源码 commit。DICOM 切片控件要明确区分“数组索引”“用户显示层号”和“滑块位置”,不要用同一个变量同时表达三种语义。 diff --git a/工程分析/需求分析-2026-05-24-15-55-48.md b/工程分析/需求分析-2026-05-24-15-55-48.md new file mode 100644 index 0000000..51dab7a --- /dev/null +++ b/工程分析/需求分析-2026-05-24-15-55-48.md @@ -0,0 +1,49 @@ +# 需求分析-2026-05-24-15-55-48 + +## 开始时间 + +2026-05-24-15-55-48 + +## 原始需求摘要 + +1. 逆向工作区“构件层级”标题右侧增加全局小眼睛按钮,可一键显示或隐藏所有构件。 +2. 项目列表中增加项目锁定能力:在“进入逆向工作区”左侧增加“锁定按钮”;锁定时将位姿数据保存到 `项目数据/锁定结果/`;进入逆向工作区时若项目已锁定则提示需要解锁;项目列表左侧支持筛选未上锁项目;项目按最后处理时间排序。 +3. 修正 DICOM 切片位置滚动条:滚动后轨道不要出现进度色;显示顺序保持正确,但 `1` 应位于滚动条最上方,通过下拉从 `1` 到 `N` 查看切片。 + +## 业务目标 + +- 让批量构件显示控制更高效,便于快速观察 DICOM 与 STL 的融合效果。 +- 为项目处理状态增加可控的“锁定”机制,避免已确认位姿的项目被误进入/误编辑。 +- 将锁定时的关键位姿数据落盘,便于后续追溯和复核。 +- 让切片滚动控件语义与用户认知一致:顶部为第 1 张,向下逐步增加。 + +## 输入与输出 + +- 输入:项目列表操作、构件可见性操作、DICOM 切片滚动条操作。 +- 输出:项目锁定状态、锁定结果 JSON 文件、构件显示状态更新、正确方向的切片预览与无进度色滚动条。 + +## 影响范围 + +- 前端:项目库、逆向工作区、DICOM 切片控件、构件层级工具栏。 +- 后端:项目状态结构、锁定/解锁接口、锁定结果落盘、项目排序字段维护。 +- 文档:`Docker部署/` 部署说明与工程分析经验记录。 + +## 关键约束 + +- 必须沿用现有 `WebSite/server.ts` 的项目状态持久化方式。 +- 新增字段需要兼容既有 `state.json`,避免旧项目读取失败。 +- 锁定结果必须写入仓库根目录下的 `项目数据/锁定结果/`。 +- 不提交大型医学影像数据、运行态导出文件或无关工作区变化。 + +## 风险点 + +- 锁定后禁止进入工作区需要和项目卡片按钮状态、错误提示保持一致。 +- 位姿数据来源可能同时存在于项目级 `modelPose` 和后端当前状态,需要归一化保存。 +- DICOM 视图已经做过切片编号反转,滚动方向调整时不能再次破坏显示编号。 +- 一键隐藏所有构件会影响跨页面共享样式,需要调用既有构件样式 API。 + +## 默认假设 + +- “锁定按钮”需要支持再次点击解锁,解锁后允许进入逆向工作区。 +- “最后处理时间”使用项目最近更新时间、最后导出时间或最后锁定时间中的最新值排序。 +- 切片滚动条无需保留蓝色进度轨道,只保留中性轨道与滑块。