2026-05-21-00-05-04 导入进度与压缩包支持
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user