2026-05-21-00-05-04 导入进度与压缩包支持

This commit is contained in:
2026-05-21 00:24:29 +08:00
parent dcd6fe56c7
commit 14c8eb153d
9 changed files with 640 additions and 27 deletions

View File

@@ -1,4 +1,6 @@
import express from 'express';
import AdmZip from 'adm-zip';
import multer from 'multer';
import { createServer as createViteServer } from 'vite';
import fs from 'node:fs';
import path from 'node:path';
@@ -101,12 +103,18 @@ interface UploadedAssetPayload {
data: string;
}
interface PreparedAssetFile {
name: string;
data: Buffer;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const dataDir = path.join(__dirname, 'data');
const exportDir = path.join(__dirname, 'exports');
const uploadDir = path.join(dataDir, 'uploads');
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');
@@ -1373,6 +1381,116 @@ function parseUploadedAssets(raw: unknown): UploadedAssetPayload[] {
.filter((item) => item.data);
}
function trimTarText(value: Buffer) {
return value.toString('utf8').replace(/\0.*$/s, '').trim();
}
function looksLikeDicom(data: Buffer) {
return data.length > 132 && data.toString('ascii', 128, 132) === 'DICM';
}
function isImportableAsset(kind: 'dicom' | 'stl', name: string, data: Buffer) {
const lowerName = name.toLowerCase();
if (kind === 'stl') {
return lowerName.endsWith('.stl');
}
return lowerName.endsWith('.dcm') || lowerName.endsWith('.dicom') || looksLikeDicom(data);
}
function parseTarEntries(data: Buffer, sourceName: string): PreparedAssetFile[] {
const entries: PreparedAssetFile[] = [];
let offset = 0;
while (offset + 512 <= data.length) {
const header = data.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) {
break;
}
const name = trimTarText(header.subarray(0, 100));
const prefix = trimTarText(header.subarray(345, 500));
const typeFlag = header.toString('utf8', 156, 157);
const sizeValue = trimTarText(header.subarray(124, 136)).replace(/[^0-7]/g, '');
const size = Number.parseInt(sizeValue || '0', 8) || 0;
const dataStart = offset + 512;
const dataEnd = Math.min(dataStart + size, data.length);
const archiveName = prefix ? `${prefix}/${name}` : name;
if ((typeFlag === '0' || typeFlag === '') && archiveName && size > 0) {
entries.push({
name: archiveName,
data: Buffer.from(data.subarray(dataStart, dataEnd)),
});
}
offset = dataStart + Math.ceil(size / 512) * 512;
}
if (!entries.length) {
throw new Error(`${sourceName} 中没有可读取的 TAR 文件条目`);
}
return entries;
}
function expandUploadedAsset(file: Express.Multer.File): PreparedAssetFile[] {
const lowerName = file.originalname.toLowerCase();
if (lowerName.endsWith('.zip')) {
const archive = new AdmZip(file.path);
return archive.getEntries()
.filter((entry) => !entry.isDirectory)
.map((entry) => ({ name: entry.entryName, data: entry.getData() }));
}
if (lowerName.endsWith('.tar.gz') || lowerName.endsWith('.tgz')) {
return parseTarEntries(zlib.gunzipSync(fs.readFileSync(file.path)), file.originalname);
}
if (lowerName.endsWith('.tar')) {
return parseTarEntries(fs.readFileSync(file.path), file.originalname);
}
if (lowerName.endsWith('.gz')) {
const outputName = file.originalname.replace(/\.gz$/i, '') || `${file.originalname}.raw`;
return [{ name: outputName, data: zlib.gunzipSync(fs.readFileSync(file.path)) }];
}
return [{ name: file.originalname, data: fs.readFileSync(file.path) }];
}
function safeImportedFileName(name: string, fallback: string, extension: string, usedNames: Set<string>) {
const initial = sanitizeUploadFileName(name, fallback, extension);
const parsed = path.parse(initial);
let candidate = initial;
let suffix = 2;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${parsed.name}-${suffix}${parsed.ext || extension}`;
suffix += 1;
}
usedNames.add(candidate.toLowerCase());
return candidate;
}
function collectPreparedAssetFiles(kind: 'dicom' | 'stl', uploadedFiles: Express.Multer.File[], legacyFiles: UploadedAssetPayload[]) {
const expandedFiles: PreparedAssetFile[] = [];
uploadedFiles.forEach((file) => {
expandedFiles.push(...expandUploadedAsset(file));
});
legacyFiles.forEach((file) => {
expandedFiles.push({ name: file.name, data: decodeUploadedAssetData(file.data) });
});
return expandedFiles.filter((file) => file.data.length > 0 && isImportableAsset(kind, file.name, file.data));
}
function cleanupUploadedTempFiles(files: Express.Multer.File[]) {
files.forEach((file) => {
if (file.path) {
fs.rmSync(file.path, { force: true });
}
});
}
function createNiftiExport(
project: ProjectRecord,
files: string[],
@@ -2169,6 +2287,23 @@ async function startServer() {
const port = Number(portArg ?? 4000);
ensureDir(exportDir);
ensureDir(uploadTempDir);
const assetUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, callback) => {
ensureDir(uploadTempDir);
callback(null, uploadTempDir);
},
filename: (_req, file, callback) => {
const safeName = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}-${path.basename(file.originalname).replace(/[\\/:*?"<>|]+/g, '_')}`;
callback(null, safeName);
},
}),
limits: {
files: 5000,
fileSize: 2 * 1024 * 1024 * 1024,
},
});
app.use(express.json({ limit: '512mb' }));
app.get('/api/health', (_req, res) => {
@@ -2386,16 +2521,24 @@ async function startServer() {
});
app.post('/api/projects/:projectId/import-assets', (req, res) => {
assetUpload.array('files', 5000)(req, res, (uploadError) => {
if (uploadError) {
res.status(413).json({ message: uploadError instanceof Error ? uploadError.message : '上传文件过大或格式无效' });
return;
}
const kind = req.body?.kind === 'stl' ? 'stl' : 'dicom';
let uploadedFiles: UploadedAssetPayload[];
const multerFiles = Array.isArray(req.files) ? req.files as Express.Multer.File[] : [];
let legacyUploadedFiles: UploadedAssetPayload[];
try {
uploadedFiles = parseUploadedAssets(req.body?.files);
legacyUploadedFiles = req.body?.files ? parseUploadedAssets(req.body.files) : [];
} catch (error) {
cleanupUploadedTempFiles(multerFiles);
res.status(400).json({ message: error instanceof Error ? error.message : '上传文件列表无效' });
return;
}
if (!uploadedFiles.length) {
if (!multerFiles.length && !legacyUploadedFiles.length) {
res.status(400).json({ message: '请选择需要导入的文件' });
return;
}
@@ -2403,21 +2546,31 @@ async function startServer() {
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
cleanupUploadedTempFiles(multerFiles);
res.status(404).json({ message: '项目不存在' });
return;
}
const targetDir = path.join(uploadDir, project.id, kind === 'dicom' ? 'DICOM' : 'STL');
try {
const preparedFiles = collectPreparedAssetFiles(kind, multerFiles, legacyUploadedFiles);
if (!preparedFiles.length) {
throw new Error(kind === 'dicom'
? '未找到可导入的 DICOM 文件,请选择 .dcm/.dicom 文件或包含 DICOM 的 ZIP/TAR 压缩包'
: '未找到可导入的 STL 文件,请选择 .stl 文件或包含 STL 的 ZIP/TAR 压缩包');
}
fs.rmSync(targetDir, { recursive: true, force: true });
ensureDir(targetDir);
uploadedFiles.forEach((file, index) => {
const fileName = sanitizeUploadFileName(
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), decodeUploadedAssetData(file.data));
fs.writeFileSync(path.join(targetDir, fileName), file.data);
});
if (kind === 'dicom') {
@@ -2443,7 +2596,10 @@ async function startServer() {
res.json(project);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' });
} finally {
cleanupUploadedTempFiles(multerFiles);
}
});
});
app.post('/api/projects/:projectId/segmentation-results', (req, res) => {