2026-05-24-15-55-48 增加项目锁定与切片控件修正
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ WebSite/data/
|
||||
WebSite/exports/
|
||||
Docker部署/**/data/
|
||||
Docker部署/**/exports/
|
||||
Docker部署/**/locked-results/
|
||||
项目数据/锁定结果/
|
||||
|
||||
# Local env
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/` 会复制进镜像,用于新环境首次启动后的默认项目。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, ModuleStyleRecord>;
|
||||
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<string, unknown>();
|
||||
const dicomVolumeCache = new Map<string, {
|
||||
frames: Buffer[];
|
||||
@@ -214,6 +220,60 @@ function ensureDir(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function timestampMillis(value: unknown) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return 0;
|
||||
}
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function normalizeOptionalTimestamp(value: unknown) {
|
||||
const time = timestampMillis(value);
|
||||
return time > 0 ? new Date(time).toISOString() : null;
|
||||
}
|
||||
|
||||
function latestProjectTimestamp(project: Partial<ProjectRecord>, 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<ProjectRecord>, 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<string, Partial<ModuleStyleRecord>>),
|
||||
});
|
||||
touchProject(project);
|
||||
writeState(state);
|
||||
res.json(project);
|
||||
});
|
||||
@@ -2747,6 +2899,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
project.modelPoses = normalizeModelPoses(incoming as Partial<ModelPoseRecord>[]);
|
||||
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');
|
||||
|
||||
@@ -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<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(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<SegmentationExportMode>('combined');
|
||||
const [maskExporting, setMaskExporting] = useState(false);
|
||||
const [assetImporting, setAssetImporting] = useState(false);
|
||||
const [lockChangingProjectId, setLockChangingProjectId] = useState('');
|
||||
const [assetImportProgress, setAssetImportProgress] = useState<AssetImportProgressState | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const importKindRef = useRef<ProjectAssetImportKind>('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<string, ModuleStyle> = {};
|
||||
(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)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setShowUnlockedOnly(false)}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg text-[10px] font-bold transition ${
|
||||
!showUnlockedOnly ? 'bg-slate-900 text-white shadow-sm' : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<FolderRoot size={12} />
|
||||
全部项目
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUnlockedOnly(true)}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg text-[10px] font-bold transition ${
|
||||
showUnlockedOnly ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Unlock size={12} />
|
||||
未上锁
|
||||
</button>
|
||||
</div>
|
||||
<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) => (
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className={`truncate text-xs font-bold ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
{proj.locked && (
|
||||
<span className={`flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[8px] font-black ${
|
||||
selectedProject?.id === proj.id ? 'bg-white/15 text-blue-50' : 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
<Lock size={9} />
|
||||
锁
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingProjectId !== proj.id && (
|
||||
@@ -1613,7 +1702,7 @@ export default function ProjectLibrary({
|
||||
)}
|
||||
</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}
|
||||
处理 {formatProjectActivity(proj)} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -1628,11 +1717,12 @@ export default function ProjectLibrary({
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<FolderRoot size={16} />
|
||||
{p.locked && <Lock size={9} className="absolute -right-1 -top-1 rounded-full bg-amber-100 p-0.5 text-amber-600" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1665,8 +1755,27 @@ export default function ProjectLibrary({
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => onReverse(selectedProject.id)}
|
||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-blue-700 transition-all shadow-lg"
|
||||
onClick={() => void handleToggleProjectLock(selectedProject)}
|
||||
disabled={lockChangingProjectId === selectedProject.id}
|
||||
className={`px-5 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-lg disabled:opacity-60 ${
|
||||
selectedProject.locked
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-white text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
title={selectedProject.locked ? '解除项目锁定' : '锁定项目并保存位姿数据'}
|
||||
>
|
||||
{selectedProject.locked ? <Unlock size={18} /> : <Lock size={18} />}
|
||||
{lockChangingProjectId === selectedProject.id
|
||||
? '处理中'
|
||||
: selectedProject.locked ? '解锁项目' : '锁定项目'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEnterReverseWorkspace(selectedProject)}
|
||||
className={`px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-lg ${
|
||||
selectedProject.locked
|
||||
? 'bg-slate-300 text-slate-500 hover:bg-slate-300'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
<RotateCw size={18} /> 进入逆向工作区
|
||||
</button>
|
||||
@@ -1846,12 +1955,12 @@ export default function ProjectLibrary({
|
||||
{dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
|
||||
</span>
|
||||
<button
|
||||
onMouseDown={() => startSliceStep(-1)}
|
||||
onMouseDown={() => startSliceStep(1)}
|
||||
onMouseUp={stopSliceStep}
|
||||
onMouseLeave={stopSliceStep}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
startSliceStep(-1);
|
||||
startSliceStep(1);
|
||||
}}
|
||||
onTouchEnd={stopSliceStep}
|
||||
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
||||
@@ -1861,27 +1970,23 @@ export default function ProjectLibrary({
|
||||
</button>
|
||||
<div className="relative min-h-[260px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-800/70" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-cyan-400"
|
||||
style={{ height: `${dicomSlicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={dicomMaxSlice}
|
||||
value={dicomSliderValue}
|
||||
onChange={(event) => setSliceIndex(dicomMaxSlice - Number(event.target.value))}
|
||||
onChange={(event) => setSliceIndex(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库 DICOM 切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onMouseDown={() => startSliceStep(1)}
|
||||
onMouseDown={() => startSliceStep(-1)}
|
||||
onMouseUp={stopSliceStep}
|
||||
onMouseLeave={stopSliceStep}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
startSliceStep(1);
|
||||
startSliceStep(-1);
|
||||
}}
|
||||
onTouchEnd={stopSliceStep}
|
||||
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
Lock,
|
||||
Maximize2,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
@@ -2283,8 +2284,7 @@ export function VoxelizationMappingView({
|
||||
const stepSlice = (delta: number) => {
|
||||
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
||||
};
|
||||
const sliderSliceValue = maxSlice - safeSlice;
|
||||
const slicePercent = maxSlice > 0 ? (sliderSliceValue / maxSlice) * 100 : 0;
|
||||
const sliderSliceValue = safeSlice;
|
||||
const displaySliceNumber = getDicomDisplaySliceNumber(safeSlice, Math.max(totalSlices, 1));
|
||||
const resetMappingViewport = () => {
|
||||
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
@@ -2423,16 +2423,12 @@ export function VoxelizationMappingView({
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||||
<div className="relative min-h-[220px] w-8 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-cyan-400"
|
||||
style={{ height: `${slicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库逆向分割映射视图切片导航"
|
||||
/>
|
||||
@@ -2533,8 +2529,8 @@ export function VoxelizationMappingView({
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="上一层"
|
||||
>
|
||||
@@ -2542,32 +2538,28 @@ export function VoxelizationMappingView({
|
||||
</button>
|
||||
<div className="relative min-h-[240px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-blue-600"
|
||||
style={{ height: `${slicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-vertical-input"
|
||||
aria-label="逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="下一层"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
||||
<span>顶层 {Math.max(totalSlices, 1)}</span>
|
||||
<span>顶层 1</span>
|
||||
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||||
<span>底层 1</span>
|
||||
<span>底层 {Math.max(totalSlices, 1)}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -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<ModuleStyle>) => {
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
|
||||
<div className="w-full max-w-2xl rounded-3xl border border-amber-100 bg-white p-8 text-center shadow-sm">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
|
||||
<Lock size={24} />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-black text-slate-900">项目已锁定</h2>
|
||||
<p className="mx-auto mt-3 max-w-lg text-sm font-semibold leading-6 text-slate-500">
|
||||
请先在项目库点击“解锁项目”,再进入逆向工作区继续修改位姿和分割结果。
|
||||
</p>
|
||||
<div className="mt-6 rounded-2xl bg-slate-50 px-4 py-3 text-left text-xs font-bold text-slate-500">
|
||||
<p>项目:{project.name}</p>
|
||||
<p className="mt-1">锁定时间:{project.lockedAt ? new Date(project.lockedAt).toLocaleString('zh-CN') : '未记录'}</p>
|
||||
<p className="mt-1">锁定结果:{project.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceLoadState.ready) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
|
||||
@@ -3943,7 +3988,17 @@ export default function ReverseWorkspace({
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">构件层级</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
|
||||
<button
|
||||
onClick={toggleAllModules}
|
||||
disabled={!project?.stlFiles?.length}
|
||||
className={`rounded p-1 transition ${allModulesVisible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white disabled:opacity-40`}
|
||||
title={allModulesVisible ? '隐藏所有构件' : '显示所有构件'}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(project?.stlFiles ?? []).map((fileName, index) => {
|
||||
|
||||
@@ -114,6 +114,11 @@ export const api = {
|
||||
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
updateProjectLock: (projectId: string, locked: boolean) =>
|
||||
request<Project>(`/api/projects/${projectId}/lock`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ locked }),
|
||||
}),
|
||||
updateProjectModuleStyles: (projectId: string, moduleStyles: Record<string, ModuleStyle>) =>
|
||||
request<Project>(`/api/projects/${projectId}/module-styles`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -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<string, ModuleStyle>;
|
||||
modelPoses?: SavedModelPose[];
|
||||
segmentationResults?: SegmentationResult[];
|
||||
|
||||
62
工程分析/实现方案-2026-05-24-15-55-48.md
Normal file
62
工程分析/实现方案-2026-05-24-15-55-48.md
Normal file
@@ -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`。
|
||||
58
工程分析/测试方案-2026-05-24-15-55-48.md
Normal file
58
工程分析/测试方案-2026-05-24-15-55-48.md
Normal file
@@ -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`,解锁与删除正常;临时快照文件已清理。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.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 切片控件要明确区分“数组索引”“用户显示层号”和“滑块位置”,不要用同一个变量同时表达三种语义。
|
||||
|
||||
49
工程分析/需求分析-2026-05-24-15-55-48.md
Normal file
49
工程分析/需求分析-2026-05-24-15-55-48.md
Normal file
@@ -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。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- “锁定按钮”需要支持再次点击解锁,解锁后允许进入逆向工作区。
|
||||
- “最后处理时间”使用项目最近更新时间、最后导出时间或最后锁定时间中的最新值排序。
|
||||
- 切片滚动条无需保留蓝色进度轨道,只保留中性轨道与滑块。
|
||||
Reference in New Issue
Block a user