diff --git a/WebSite/server.ts b/WebSite/server.ts index 69edfe3..9b37c28 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -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(); + 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; + } + if (typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function parsePipelineMetadata(body: unknown) { + const source = body && typeof body === 'object' && !Array.isArray(body) ? body as Record : {}; + 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; + 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).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 : {}; +} + +function normalizePipelinePose(metadata: Record) { + 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 = { + ...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) { + const bounds = (project.stlFiles ?? []).reduce((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) { + 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>; +}) { + 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) { + 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(); + 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>, + }); + + 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 | 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) { diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 644f9c3..0330a78 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -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(null); const modelBoundsCacheRef = useRef(new Map()); const [workspaceLoadState, setWorkspaceLoadState] = useState({ @@ -2840,9 +2841,17 @@ export default function ReverseWorkspace({ const poseImportInputRef = useRef(null); const visualToolbarScrollRef = useRef(null); const saveToastTimerRef = useRef(null); + const exportProgressTimerRef = useRef(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 (
+ {exportProgress.active && ( +
+
+
+
+
+ {exportProgress.phase} {Math.round(exportProgress.percent)}% +
+
+ )} {saveStatus && ( <>