2026-05-24-15-55-48 增加项目锁定与切片控件修正
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user