2026-05-25-14-00-24 修正项目导出结构与新增批处理API

This commit is contained in:
2026-05-25 14:26:51 +08:00
parent 21b372f705
commit acdff763b5
7 changed files with 912 additions and 38 deletions

View File

@@ -2310,6 +2310,22 @@ function collectPreparedAssetFiles(kind: 'dicom' | 'stl', uploadedFiles: Express
return expandedFiles.filter((file) => file.data.length > 0 && isImportableAsset(kind, file.name, file.data));
}
function writePreparedAssetFiles(kind: 'dicom' | 'stl', targetDir: string, preparedFiles: PreparedAssetFile[]) {
fs.rmSync(targetDir, { recursive: true, force: true });
ensureDir(targetDir);
const usedNames = new Set<string>();
preparedFiles.forEach((file, index) => {
const fileName = safeImportedFileName(
file.name,
kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`,
kind === 'dicom' ? '.dcm' : '.stl',
usedNames,
);
fs.writeFileSync(path.join(targetDir, fileName), file.data);
});
return listFiles(targetDir, kind === 'dicom' ? '.dcm' : '.stl');
}
function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
files.forEach((file) => {
if (file.path) {
@@ -2318,6 +2334,289 @@ function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
});
}
function parseJsonObjectField(raw: unknown) {
if (!raw) {
return {};
}
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return {};
}
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('metadata 必须是 JSON 对象');
}
return parsed as Record<string, unknown>;
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function parsePipelineMetadata(body: unknown) {
const source = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
const metadata = parseJsonObjectField(source.metadata ?? source.payload ?? source.options);
return {
...source,
...metadata,
};
}
function parseFlexibleUploadedAssets(raw: unknown) {
if (!raw) {
return [];
}
const value = Array.isArray(raw) && raw.length === 1 && typeof raw[0] === 'string' ? raw[0] : raw;
try {
return parseUploadedAssets(typeof value === 'string' ? JSON.parse(value) as unknown : value);
} catch (error) {
throw new Error(error instanceof Error ? error.message : '上传文件列表无效');
}
}
function requestFilesFromFields(files: unknown, fieldNames: string[]) {
if (!files || Array.isArray(files) || typeof files !== 'object') {
return [];
}
const groups = files as Record<string, Express.Multer.File[]>;
return fieldNames.flatMap((name) => Array.isArray(groups[name]) ? groups[name] : []);
}
function allRequestMulterFiles(files: unknown) {
if (Array.isArray(files)) {
return files as Express.Multer.File[];
}
if (!files || typeof files !== 'object') {
return [];
}
return Object.values(files as Record<string, Express.Multer.File[]>).flatMap((group) => (
Array.isArray(group) ? group : []
));
}
function readBooleanOption(value: unknown, fallback = false) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
return false;
}
}
return fallback;
}
function readNumberOption(value: unknown) {
const numberValue = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : NaN;
return Number.isFinite(numberValue) ? numberValue : undefined;
}
function readNestedObject(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function normalizePipelinePose(metadata: Record<string, unknown>) {
const poseSource = readNestedObject(metadata.pose);
const rotation = readNestedObject(metadata.rotation ?? metadata.rotate);
const translation = readNestedObject(metadata.translation ?? metadata.translate);
const mirror = readNestedObject(metadata.mirror ?? metadata.flip);
const pose: Partial<ModelPoseValue> = {
...poseSource,
};
const rotationX = readNumberOption(rotation.x ?? rotation.rotateX ?? metadata.rotateX);
const rotationY = readNumberOption(rotation.y ?? rotation.rotateY ?? metadata.rotateY);
const rotationZ = readNumberOption(rotation.z ?? rotation.rotateZ ?? metadata.rotateZ);
const translateX = readNumberOption(translation.x ?? translation.translateX ?? metadata.translateX);
const translateY = readNumberOption(translation.y ?? translation.translateY ?? metadata.translateY);
const translateZ = readNumberOption(translation.z ?? translation.translateZ ?? metadata.translateZ);
const scale = readNumberOption(metadata.scale ?? poseSource.scale);
if (rotationX !== undefined) pose.rotateX = rotationX;
if (rotationY !== undefined) pose.rotateY = rotationY;
if (rotationZ !== undefined) pose.rotateZ = rotationZ;
if (translateX !== undefined) pose.translateX = translateX;
if (translateY !== undefined) pose.translateY = translateY;
if (translateZ !== undefined) pose.translateZ = translateZ;
if (scale !== undefined) pose.scale = scale;
(['X', 'Y', 'Z'] as const).forEach((axis) => {
const lowerAxis = axis.toLowerCase();
const key = `flip${axis}` as 'flipX' | 'flipY' | 'flipZ';
const value = mirror[lowerAxis] ?? mirror[key] ?? metadata[key] ?? metadata[`mirror${axis}`];
if (value !== undefined) {
pose[key] = readBooleanOption(value);
}
});
return normalizeModelPoseValue(pose);
}
function normalizePipelineAxis(value: unknown) {
const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
return raw === 'x' || raw === 'y' || raw === 'z' ? raw as 'x' | 'y' | 'z' : null;
}
function collectProjectModelBounds(project: ProjectRecord, previews: Record<string, ModelPreviewRecord>) {
const bounds = (project.stlFiles ?? []).reduce<ModelBoundsRecord>((accumulator, fileName) => {
const payload = previews[fileName];
if (!payload) {
return accumulator;
}
accumulator.min.x = Math.min(accumulator.min.x, payload.bounds.min.x);
accumulator.min.y = Math.min(accumulator.min.y, payload.bounds.min.y);
accumulator.min.z = Math.min(accumulator.min.z, payload.bounds.min.z);
accumulator.max.x = Math.max(accumulator.max.x, payload.bounds.max.x);
accumulator.max.y = Math.max(accumulator.max.y, payload.bounds.max.y);
accumulator.max.z = Math.max(accumulator.max.z, payload.bounds.max.z);
return accumulator;
}, {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
});
return Number.isFinite(bounds.min.x) ? bounds : null;
}
function rotatedModelSizeForPose(bounds: ModelBoundsRecord, pose: ModelPoseValue) {
const center = {
x: (bounds.min.x + bounds.max.x) / 2,
y: (bounds.min.y + bounds.max.y) / 2,
z: (bounds.min.z + bounds.max.z) / 2,
};
const rotateX = (pose.rotateX * Math.PI) / 180;
const rotateY = (pose.rotateY * Math.PI) / 180;
const rotateZ = (pose.rotateZ * Math.PI) / 180;
const cosX = Math.cos(rotateX);
const sinX = Math.sin(rotateX);
const cosY = Math.cos(rotateY);
const sinY = Math.sin(rotateY);
const cosZ = Math.cos(rotateZ);
const sinZ = Math.sin(rotateZ);
const rotatedBounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
[bounds.min.x, bounds.max.x].forEach((x) => {
[bounds.min.y, bounds.max.y].forEach((y) => {
[bounds.min.z, bounds.max.z].forEach((z) => {
let px = (x - center.x) * (pose.flipX ? -1 : 1);
let py = (y - center.y) * (pose.flipY ? -1 : 1);
let pz = (z - center.z) * (pose.flipZ ? -1 : 1);
const afterX = {
x: px,
y: py * cosX - pz * sinX,
z: py * sinX + pz * cosX,
};
const afterY = {
x: afterX.x * cosY + afterX.z * sinY,
y: afterX.y,
z: -afterX.x * sinY + afterX.z * cosY,
};
px = afterY.x * cosZ - afterY.y * sinZ;
py = afterY.x * sinZ + afterY.y * cosZ;
pz = afterY.z;
rotatedBounds.min.x = Math.min(rotatedBounds.min.x, px);
rotatedBounds.min.y = Math.min(rotatedBounds.min.y, py);
rotatedBounds.min.z = Math.min(rotatedBounds.min.z, pz);
rotatedBounds.max.x = Math.max(rotatedBounds.max.x, px);
rotatedBounds.max.y = Math.max(rotatedBounds.max.y, py);
rotatedBounds.max.z = Math.max(rotatedBounds.max.z, pz);
});
});
});
return {
x: Math.max(rotatedBounds.max.x - rotatedBounds.min.x, 1e-6),
y: Math.max(rotatedBounds.max.y - rotatedBounds.min.y, 1e-6),
z: Math.max(rotatedBounds.max.z - rotatedBounds.min.z, 1e-6),
};
}
function applyAxisStretchPose(project: ProjectRecord, files: string[], pose: ModelPoseValue, axis: 'x' | 'y' | 'z') {
const volume = readDicomHuVolume(project, files);
const previews = collectAutoMatchPreviews(project);
const metrics = getExportMetrics(project, volume, previews);
const bounds = collectProjectModelBounds(project, previews);
if (!metrics || !bounds) {
throw new Error('无法读取 STL 全局边界,不能执行轴向拉伸');
}
const rotatedSize = rotatedModelSizeForPose(bounds, pose);
const dicomSize = {
x: metrics.dicomWidth,
y: metrics.dicomHeight,
z: metrics.dicomDepth,
};
const axisFitScale = dicomSize[axis] / (rotatedSize[axis] * metrics.modelBaseScale);
const containmentScale = Math.min(
dicomSize.x / (rotatedSize.x * metrics.modelBaseScale),
dicomSize.y / (rotatedSize.y * metrics.modelBaseScale),
dicomSize.z / (rotatedSize.z * metrics.modelBaseScale),
);
return normalizeModelPoseValue({
...pose,
scale: Math.min(axisFitScale, containmentScale),
});
}
function parsePipelineExportConfig(metadata: Record<string, unknown>) {
const exportConfig = readNestedObject(metadata.export);
const targets = Array.isArray(exportConfig.targets)
? parseExportTargets(exportConfig.targets.join(','))
: parseExportTargets(exportConfig.targets ?? metadata.exportTargets ?? metadata.targets ?? '');
const format = exportConfig.format === 'nii' || metadata.format === 'nii' ? 'nii' : 'nii.gz';
return {
targets,
format: format as 'nii' | 'nii.gz',
compressed: format === 'nii.gz',
segmentationScope: parseSegmentationScope(exportConfig.segmentationScope ?? metadata.segmentationScope),
segmentationExportMode: parseSegmentationExportMode(exportConfig.segmentationExportMode ?? metadata.segmentationExportMode),
};
}
function createPipelineProject({
name,
dicomFiles,
stlFiles,
moduleStyles,
}: {
name: string;
dicomFiles: PreparedAssetFile[];
stlFiles: PreparedAssetFile[];
moduleStyles?: Record<string, Partial<ModuleStyleRecord>>;
}) {
const project = buildEmptyProject(name);
const projectRoot = path.join(uploadDir, project.id);
const dicomTargetDir = path.join(projectRoot, 'DICOM');
const stlTargetDir = path.join(projectRoot, 'STL');
const dicomFileNames = writePreparedAssetFiles('dicom', dicomTargetDir, dicomFiles);
const stlFileNames = writePreparedAssetFiles('stl', stlTargetDir, stlFiles);
project.dicomPath = toRepoRelativePath(dicomTargetDir);
project.modelPath = toRepoRelativePath(stlTargetDir);
project.dicomCount = dicomFileNames.length;
project.stlFiles = stlFileNames;
project.modelCount = stlFileNames.length;
project.hasModel = stlFileNames.length > 0;
project.moduleStyles = buildModuleStyles(stlFileNames, moduleStyles);
project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending';
touchProject(project);
if (project.dicomCount > 0) {
try {
writeCachedDicomInfo(project, dicomFileNames, createDicomInfo(project, dicomFileNames));
} catch {
// DICOM info is an optimization cache; export/registration will report hard parse failures later.
}
}
return project;
}
function createNiftiExport(
project: ProjectRecord,
files: string[],
@@ -2355,6 +2654,90 @@ function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
}, null, 2), 'utf8');
}
function createZipArchive(entries: Array<{ name: string; data: Buffer }>) {
const zip = new AdmZip();
entries.forEach((entry) => {
const normalizedName = entry.name
.replace(/\\/g, '/')
.split('/')
.map((part) => sanitizeFilenamePart(part, 'entry'))
.filter((part) => part && part !== '.' && part !== '..')
.join('/');
zip.addFile(normalizedName || 'entry', entry.data);
});
return zip.toBuffer();
}
function splitArchiveFileName(fileName: string) {
if (fileName.toLowerCase().endsWith('.nii.gz')) {
return {
stem: fileName.slice(0, -'.nii.gz'.length),
extension: '.nii.gz',
};
}
const parsed = path.posix.parse(fileName);
return {
stem: path.posix.join(parsed.dir, parsed.name),
extension: parsed.ext,
};
}
function uniqueArchiveEntryName(name: string, usedNames: Set<string>) {
const normalized = name.replace(/\\/g, '/').replace(/^\/+/, '');
const { stem, extension } = splitArchiveFileName(normalized);
let candidate = normalized;
let suffix = 2;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${stem}-${suffix}${extension}`;
suffix += 1;
}
usedNames.add(candidate.toLowerCase());
return candidate;
}
function createProjectExportManifest({
project,
targets,
format,
activePose,
segmentationScope,
segmentationExportMode,
}: {
project: ProjectRecord;
targets: ProjectExportTarget[];
format: 'nii' | 'nii.gz';
activePose?: ModelPoseValue;
segmentationScope: SegmentationExportScope;
segmentationExportMode: SegmentationExportMode;
}) {
return Buffer.from(JSON.stringify({
schemaVersion: 2,
generatedAt: now(),
archiveFormat: 'zip',
project: {
id: project.id,
name: project.name,
createTime: project.createTime,
dicomCount: project.dicomCount,
modelCount: project.modelCount,
dicomPath: project.dicomPath,
modelPath: project.modelPath,
},
export: {
targets,
format,
segmentationScope,
segmentationExportMode,
},
activePose: activePose ?? null,
notes: [
'Archive entries are stored without a project-name root directory because the archive filename already carries the project name.',
'segmentation/labels.json maps label values to STL component categories.',
'When segmentationExportMode is separate, segmentation-parts/*.nii.gz files are single-category masks named by category.',
],
}, null, 2), 'utf8');
}
function createProjectLockSnapshot(project: ProjectRecord, lockedAt: string) {
const latestResult = latestSegmentationResult(project);
const activePose = latestResult?.pose
@@ -2401,7 +2784,6 @@ function createProjectExportBundle({
activePose,
segmentationScope,
segmentationExportMode,
exportRoot,
}: {
project: ProjectRecord;
files: string[];
@@ -2410,18 +2792,32 @@ function createProjectExportBundle({
activePose?: ModelPoseValue;
segmentationScope: SegmentationExportScope;
segmentationExportMode: SegmentationExportMode;
exportRoot: string;
}) {
const entries: Array<{ name: string; data: Buffer; mtime?: number }> = [];
const entries: Array<{ name: string; data: Buffer }> = [];
const usedEntryNames = new Set<string>();
const format = compressed ? 'nii.gz' : 'nii';
let contentEntryCount = 0;
const pushEntry = (name: string, data: Buffer) => {
entries.push({ name: uniqueArchiveEntryName(name, usedEntryNames), data });
};
const pushContentEntry = (name: string, data: Buffer) => {
contentEntryCount += 1;
pushEntry(name, data);
};
const needsVolume = targets.includes('dicom') || targets.includes('segmentation');
const volume = needsVolume ? readDicomHuVolume(project, files) : null;
const format = compressed ? 'nii.gz' : 'nii';
pushEntry('manifest.json', createProjectExportManifest({
project,
targets,
format,
activePose,
segmentationScope,
segmentationExportMode,
}));
if (targets.includes('dicom') && volume) {
entries.push({
name: `${exportRoot}/${project.id}-dicom-image.${format}`,
data: createNiftiBuffer(volume, volume.data, 'dicom', compressed),
});
pushContentEntry(`dicom/image.${format}`, createNiftiBuffer(volume, volume.data, 'dicom', compressed));
}
if (targets.includes('segmentation') && volume) {
@@ -2432,38 +2828,32 @@ function createProjectExportBundle({
return;
}
const moduleName = sanitizeFilenamePart(fileName.replace(/\.stl$/i, ''), `module-${index + 1}`);
entries.push({
name: `${exportRoot}/segmentation-parts/${String(style.partId).padStart(3, '0')}-${moduleName}-label.${format}`,
data: createNiftiBuffer(
pushContentEntry(
`segmentation-parts/${moduleName}.${format}`,
createNiftiBuffer(
volume,
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope, fileName),
'segmentation',
compressed,
),
});
);
});
} else {
entries.push({
name: `${exportRoot}/${project.id}-segmentation-label.${format}`,
data: createNiftiBuffer(
pushContentEntry(
`segmentation/label.${format}`,
createNiftiBuffer(
volume,
createSegmentationData(project, volume, activePose ?? defaultModelPose, segmentationScope),
'segmentation',
compressed,
),
});
);
}
entries.push({
name: `${exportRoot}/${project.id}-segmentation-labels.json`,
data: createSegmentationLabelMetadata(project, segmentationScope, activePose),
});
pushContentEntry('segmentation/labels.json', createSegmentationLabelMetadata(project, segmentationScope, activePose));
}
if (targets.includes('pose')) {
entries.push({
name: `${exportRoot}/${project.id}-pose-data.json`,
data: createPoseExport(project, activePose),
});
pushContentEntry('pose/pose.json', createPoseExport(project, activePose));
}
if (targets.includes('stl')) {
@@ -2473,20 +2863,15 @@ function createProjectExportBundle({
return;
}
const stat = fs.statSync(filePath);
entries.push({
name: `${exportRoot}/STL/${fileName}`,
data: fs.readFileSync(filePath),
mtime: stat.mtimeMs / 1000,
});
pushContentEntry(`stl/${fileName}`, fs.readFileSync(filePath));
});
}
if (!entries.length) {
if (contentEntryCount === 0) {
throw new Error('未选择可导出的内容');
}
return createTarGz(entries);
return createZipArchive(entries);
}
function findProject(state: AppState, projectId: string) {
@@ -3234,6 +3619,166 @@ async function startServer() {
res.json(publicSession(state));
});
app.post('/api/reverse-pipeline', (req, res) => {
assetUpload.fields([
{ name: 'dicomFiles', maxCount: 5000 },
{ name: 'dicom', maxCount: 5000 },
{ name: 'stlFiles', maxCount: 2000 },
{ name: 'stl', maxCount: 2000 },
{ name: 'models', maxCount: 2000 },
{ name: 'files', maxCount: 5000 },
])(req, res, (uploadError) => {
const multerFiles = allRequestMulterFiles(req.files);
let project: ProjectRecord | null = null;
let persisted = false;
try {
if (uploadError) {
res.status(413).json({ message: uploadError instanceof Error ? uploadError.message : '上传文件过大或格式无效' });
return;
}
const metadata = parsePipelineMetadata(req.body);
const fallbackFiles = requestFilesFromFields(req.files, ['files']);
const dicomMulterFiles = [
...requestFilesFromFields(req.files, ['dicomFiles', 'dicom']),
...fallbackFiles,
];
const stlMulterFiles = [
...requestFilesFromFields(req.files, ['stlFiles', 'stl', 'models']),
...fallbackFiles,
];
const dicomLegacyFiles = parseFlexibleUploadedAssets(metadata.dicomAssets ?? metadata.dicomFilesJson);
const stlLegacyFiles = parseFlexibleUploadedAssets(metadata.stlAssets ?? metadata.stlFilesJson ?? metadata.modelAssets);
const preparedDicomFiles = collectPreparedAssetFiles('dicom', dicomMulterFiles, dicomLegacyFiles);
const preparedStlFiles = collectPreparedAssetFiles('stl', stlMulterFiles, stlLegacyFiles);
if (!preparedDicomFiles.length) {
res.status(400).json({ message: '请上传 DICOM 文件、DICOM 文件夹压缩包或 dicomAssets' });
return;
}
if (!preparedStlFiles.length) {
res.status(400).json({ message: '请上传 STL 文件、STL 压缩包或 stlAssets' });
return;
}
const rawProjectName = typeof metadata.name === 'string' && metadata.name.trim()
? metadata.name.trim()
: typeof metadata.projectName === 'string' && metadata.projectName.trim()
? metadata.projectName.trim()
: `API逆向项目-${timestampForFilename()}`;
project = createPipelineProject({
name: rawProjectName,
dicomFiles: preparedDicomFiles,
stlFiles: preparedStlFiles,
moduleStyles: readNestedObject(metadata.moduleStyles) as Record<string, Partial<ModuleStyleRecord>>,
});
const files = getProjectDicomFiles(project);
let activePose = normalizePipelinePose(metadata);
const stretchConfig = readNestedObject(metadata.stretch ?? metadata.axisStretch);
const stretchAxis = normalizePipelineAxis(
stretchConfig.axis
?? metadata.stretch
?? metadata.axisStretch
?? metadata.stretchAxis,
);
if (stretchAxis) {
activePose = applyAxisStretchPose(project, files, activePose, stretchAxis);
}
project.modelPoses = normalizeModelPoses([{ id: 'default', name: 'API 位姿', pose: activePose }]);
const autoMatchConfig = readNestedObject(metadata.autoMatch);
const autoMatchEnabled = readBooleanOption(autoMatchConfig.enabled ?? metadata.autoMatch, false);
let autoMatchResult: ReturnType<typeof runProjectAutoMatch> | null = null;
if (autoMatchEnabled) {
autoMatchResult = runProjectAutoMatch(project, {
...autoMatchConfig,
pose: activePose,
});
activePose = autoMatchResult.bestPose;
applyAutoMatchedPose(project, activePose);
}
const shouldLock = readBooleanOption(metadata.lock ?? metadata.locked, false);
const shouldRecord = readBooleanOption(metadata.recordProject ?? metadata.saveToProjectLibrary, false) || shouldLock;
if (shouldLock) {
const lockedAt = now();
project.locked = true;
project.lockedAt = lockedAt;
project.unlockedAt = null;
project.lockedPoseSnapshotPath = writeProjectLockSnapshot(project, lockedAt);
touchProject(project, lockedAt);
}
const exportConfig = parsePipelineExportConfig(metadata);
let exportPayload: Buffer | null = null;
let exportFilename = '';
if (exportConfig.targets.length) {
const exportProject = projectWithSegmentationResultStyles(project);
const exportBase = `${sanitizeFilenamePart(project.name, project.id)}_${timestampForFilename()}`;
exportPayload = createProjectExportBundle({
project: exportProject,
files,
targets: exportConfig.targets,
compressed: exportConfig.compressed,
activePose,
segmentationScope: exportConfig.segmentationScope,
segmentationExportMode: exportConfig.segmentationExportMode,
});
exportFilename = `${exportBase}.zip`;
fs.writeFileSync(path.join(exportDir, exportFilename), exportPayload);
project.exportedMaskCount += exportConfig.targets.includes('segmentation') ? 1 : 0;
touchProject(project);
}
if (shouldRecord) {
const state = readState();
state.projects = state.projects.filter((candidate) => candidate.id !== project?.id);
state.projects.push(project);
writeState(state);
persisted = true;
}
if (exportPayload) {
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', contentDispositionAttachment(exportFilename));
res.send(exportPayload);
return;
}
res.status(201).json({
ok: true,
recorded: shouldRecord,
locked: project.locked,
project: {
id: project.id,
name: project.name,
dicomCount: project.dicomCount,
modelCount: project.modelCount,
dicomPath: persisted ? project.dicomPath : null,
modelPath: persisted ? project.modelPath : null,
},
activePose,
autoMatch: autoMatchResult,
export: null,
});
} catch (error) {
console.error('[reverse-pipeline] failed', {
message: error instanceof Error ? error.message : error,
});
if (!res.headersSent) {
res.status(422).json({ message: error instanceof Error ? error.message : '自动逆向处理失败' });
}
} finally {
cleanupUploadedTempFiles(multerFiles);
if (project && !persisted) {
fs.rmSync(path.join(uploadDir, project.id), { recursive: true, force: true });
}
}
});
});
app.get('/api/users', (_req, res) => {
res.json(readState().users.map(publicUser));
});
@@ -3890,15 +4435,14 @@ async function startServer() {
activePose,
segmentationScope,
segmentationExportMode,
exportRoot: exportBase,
});
const filename = `${exportBase}.tar.gz`;
const filename = `${exportBase}.zip`;
fs.writeFileSync(path.join(exportDir, filename), payload);
project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0;
touchProject(project);
writeState(state);
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', contentDispositionAttachment(filename));
res.send(payload);
} catch (error) {

View File

@@ -2825,6 +2825,7 @@ export default function ReverseWorkspace({
const [fusionError, setFusionError] = useState('');
const [saveStatus, setSaveStatus] = useState('');
const [exporting, setExporting] = useState(false);
const [exportProgress, setExportProgress] = useState({ active: false, percent: 0, phase: '' });
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
@@ -2840,9 +2841,17 @@ export default function ReverseWorkspace({
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const visualToolbarScrollRef = useRef<HTMLDivElement | null>(null);
const saveToastTimerRef = useRef<number | null>(null);
const exportProgressTimerRef = useRef<number | null>(null);
const savedWorkspaceSnapshotRef = useRef('');
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
const clearExportProgressTimer = () => {
if (exportProgressTimerRef.current !== null) {
window.clearInterval(exportProgressTimerRef.current);
exportProgressTimerRef.current = null;
}
};
const handleExportSelected = async () => {
const selectedItems = exportOptions
.filter((option) => exportSelection[option.id])
@@ -2854,18 +2863,46 @@ export default function ReverseWorkspace({
setExporting(true);
setFusionError('');
clearExportProgressTimer();
setExportProgress({ active: true, percent: 8, phase: '准备导出压缩包' });
exportProgressTimerRef.current = window.setInterval(() => {
setExportProgress((current) => {
if (!current.active || current.percent >= 88) {
return current;
}
const step = current.percent < 50 ? 5 : 2;
return { ...current, percent: Math.min(88, current.percent + step), phase: '正在生成导出内容' };
});
}, 450);
try {
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
pose: modelPose,
segmentationScope: segmentationExportScope,
segmentationExportMode,
moduleStyles,
}, (progress) => {
if (progress.percent <= 0) {
return;
}
setExportProgress((current) => ({
active: true,
percent: Math.max(current.percent, Math.min(99, progress.percent)),
phase: '正在下载压缩包',
}));
});
window.setTimeout(() => setExporting(false), 900);
clearExportProgressTimer();
setExportProgress({ active: true, percent: 100, phase: '导出完成' });
window.setTimeout(() => {
setExporting(false);
setExportProgress({ active: false, percent: 0, phase: '' });
}, 900);
setShowExportMenu(false);
} catch (error) {
clearExportProgressTimer();
setFusionError(error instanceof Error ? error.message : '导出失败');
setExporting(false);
setExportProgress({ active: true, percent: 100, phase: '导出失败' });
window.setTimeout(() => setExportProgress({ active: false, percent: 0, phase: '' }), 1200);
}
};
@@ -2893,6 +2930,13 @@ export default function ReverseWorkspace({
cutEnabled,
]);
useEffect(() => () => {
if (exportProgressTimerRef.current !== null) {
window.clearInterval(exportProgressTimerRef.current);
exportProgressTimerRef.current = null;
}
}, []);
const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
if (!project) {
return false;
@@ -3716,6 +3760,19 @@ export default function ReverseWorkspace({
return (
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
{exportProgress.active && (
<div className="pointer-events-none fixed inset-x-0 top-0 z-[80]">
<div className="h-1 w-full bg-slate-200/70">
<div
className={`h-full transition-all duration-300 ${exportProgress.phase === '导出失败' ? 'bg-rose-500' : 'bg-emerald-500'}`}
style={{ width: `${Math.max(3, Math.min(100, exportProgress.percent))}%` }}
/>
</div>
<div className="absolute right-6 top-2 rounded-full border border-emerald-100 bg-white/95 px-3 py-1 text-[11px] font-bold text-emerald-700 shadow-lg shadow-slate-950/10">
{exportProgress.phase} {Math.round(exportProgress.percent)}%
</div>
</div>
)}
{saveStatus && (
<>
<style>

View File

@@ -11,6 +11,8 @@ export interface ProjectAssetImportProgress {
percent: number;
}
export type ProjectExportDownloadProgress = ProjectAssetImportProgress;
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
headers: {
@@ -200,6 +202,83 @@ function triggerFileDownload(url: string) {
link.remove();
}
function triggerBlobDownload(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
}
function filenameFromContentDisposition(header: string | null, fallback: string) {
if (!header) {
return fallback;
}
const encodedMatch = header.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1].trim().replace(/^"|"$/g, ''));
} catch {
return fallback;
}
}
const plainMatch = header.match(/filename="([^"]+)"/i) ?? header.match(/filename=([^;]+)/i);
return plainMatch?.[1]?.trim() || fallback;
}
function downloadFileWithProgress(
url: string,
fallbackFilename: string,
onProgress?: (progress: ProjectExportDownloadProgress) => void,
) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.onprogress = (event) => {
const total = event.lengthComputable ? event.total : 0;
const loaded = event.loaded;
const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
onProgress?.({ loaded, total, percent });
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const filename = filenameFromContentDisposition(xhr.getResponseHeader('Content-Disposition'), fallbackFilename);
triggerBlobDownload(xhr.response, filename);
onProgress?.({ loaded: xhr.response?.size ?? 0, total: xhr.response?.size ?? 0, percent: 100 });
resolve();
return;
}
const reader = new FileReader();
reader.onload = () => {
let message = `请求失败:${xhr.status}`;
try {
const data = JSON.parse(String(reader.result ?? ''));
if (typeof data?.message === 'string') {
message = data.message;
}
} catch {
const text = String(reader.result ?? '').trim();
if (text) {
message = text.slice(0, 240);
}
}
reject(new Error(message));
};
reader.onerror = () => reject(new Error(`请求失败:${xhr.status}`));
reader.readAsText(xhr.response);
};
xhr.onerror = () => reject(new Error('网络连接中断,导出失败'));
xhr.onabort = () => reject(new Error('导出已取消'));
xhr.send();
});
}
function appendPose(params: URLSearchParams, pose?: ModelPose) {
if (pose) {
params.set('pose', JSON.stringify(pose));
@@ -232,7 +311,13 @@ export async function downloadProjectExport(projectId: string, target: ProjectEx
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
}
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {}) {
export async function downloadProjectExportBundle(
projectId: string,
targets: ProjectExportTarget[],
format: 'nii' | 'nii.gz' = 'nii.gz',
options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {},
onProgress?: (progress: ProjectExportDownloadProgress) => void,
) {
const params = new URLSearchParams({
targets: targets.join(','),
format,
@@ -241,7 +326,11 @@ export async function downloadProjectExportBundle(projectId: string, targets: Pr
});
appendPose(params, options.pose);
appendModuleStyles(params, options.moduleStyles);
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
await downloadFileWithProgress(
`/api/projects/${projectId}/export-bundle?${params.toString()}`,
`${projectId}-export.zip`,
onProgress,
);
}
export async function downloadDicomArchive(projectId: string) {