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

@@ -13,12 +13,14 @@
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"adm-zip": "^0.5.17",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"multer": "^2.1.1",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@@ -27,7 +29,9 @@
"vite": "^6.2.3" "vite": "^6.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.8",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
@@ -1611,6 +1615,16 @@
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/adm-zip": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1788,6 +1802,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.17", "version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
@@ -1958,6 +1982,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1967,6 +2000,12 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2177,6 +2216,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2257,6 +2313,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -3752,6 +3823,25 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -4104,6 +4194,20 @@
} }
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.8.1", "version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
@@ -4465,6 +4569,23 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/suspend-react": { "node_modules/suspend-react": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
@@ -4678,6 +4799,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -4746,6 +4873,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utility-types": { "node_modules/utility-types": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",

View File

@@ -17,12 +17,14 @@
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"adm-zip": "^0.5.17",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"multer": "^2.1.1",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@@ -31,7 +33,9 @@
"vite": "^6.2.3" "vite": "^6.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.8",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",

View File

@@ -1,4 +1,6 @@
import express from 'express'; import express from 'express';
import AdmZip from 'adm-zip';
import multer from 'multer';
import { createServer as createViteServer } from 'vite'; import { createServer as createViteServer } from 'vite';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -101,12 +103,18 @@ interface UploadedAssetPayload {
data: string; data: string;
} }
interface PreparedAssetFile {
name: string;
data: Buffer;
}
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(__dirname, '..');
const dataDir = path.join(__dirname, 'data'); const dataDir = path.join(__dirname, 'data');
const exportDir = path.join(__dirname, 'exports'); const exportDir = path.join(__dirname, 'exports');
const uploadDir = path.join(dataDir, 'uploads'); const uploadDir = path.join(dataDir, 'uploads');
const uploadTempDir = path.join(dataDir, 'upload-tmp');
const statePath = path.join(dataDir, 'state.json'); const statePath = path.join(dataDir, 'state.json');
const dicomDir = path.join(repoRoot, 'Head_CT_DICOM'); const dicomDir = path.join(repoRoot, 'Head_CT_DICOM');
const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct'); const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct');
@@ -1373,6 +1381,116 @@ function parseUploadedAssets(raw: unknown): UploadedAssetPayload[] {
.filter((item) => item.data); .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( function createNiftiExport(
project: ProjectRecord, project: ProjectRecord,
files: string[], files: string[],
@@ -2169,6 +2287,23 @@ async function startServer() {
const port = Number(portArg ?? 4000); const port = Number(portArg ?? 4000);
ensureDir(exportDir); 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.use(express.json({ limit: '512mb' }));
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
@@ -2386,16 +2521,24 @@ async function startServer() {
}); });
app.post('/api/projects/:projectId/import-assets', (req, res) => { 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'; 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 { try {
uploadedFiles = parseUploadedAssets(req.body?.files); legacyUploadedFiles = req.body?.files ? parseUploadedAssets(req.body.files) : [];
} catch (error) { } catch (error) {
cleanupUploadedTempFiles(multerFiles);
res.status(400).json({ message: error instanceof Error ? error.message : '上传文件列表无效' }); res.status(400).json({ message: error instanceof Error ? error.message : '上传文件列表无效' });
return; return;
} }
if (!uploadedFiles.length) { if (!multerFiles.length && !legacyUploadedFiles.length) {
res.status(400).json({ message: '请选择需要导入的文件' }); res.status(400).json({ message: '请选择需要导入的文件' });
return; return;
} }
@@ -2403,21 +2546,31 @@ async function startServer() {
const state = readState(); const state = readState();
const project = findProject(state, req.params.projectId); const project = findProject(state, req.params.projectId);
if (!project) { if (!project) {
cleanupUploadedTempFiles(multerFiles);
res.status(404).json({ message: '项目不存在' }); res.status(404).json({ message: '项目不存在' });
return; return;
} }
const targetDir = path.join(uploadDir, project.id, kind === 'dicom' ? 'DICOM' : 'STL'); const targetDir = path.join(uploadDir, project.id, kind === 'dicom' ? 'DICOM' : 'STL');
try { 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 }); fs.rmSync(targetDir, { recursive: true, force: true });
ensureDir(targetDir); ensureDir(targetDir);
uploadedFiles.forEach((file, index) => { const usedNames = new Set<string>();
const fileName = sanitizeUploadFileName( preparedFiles.forEach((file, index) => {
const fileName = safeImportedFileName(
file.name, file.name,
kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`, kind === 'dicom' ? `slice-${String(index + 1).padStart(4, '0')}.dcm` : `model-${index + 1}.stl`,
kind === 'dicom' ? '.dcm' : '.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') { if (kind === 'dicom') {
@@ -2443,8 +2596,11 @@ async function startServer() {
res.json(project); res.json(project);
} catch (error) { } catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' }); res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' });
} finally {
cleanupUploadedTempFiles(multerFiles);
} }
}); });
});
app.post('/api/projects/:projectId/segmentation-results', (req, res) => { app.post('/api/projects/:projectId/segmentation-results', (req, res) => {
const state = readState(); const state = readState();

View File

@@ -22,7 +22,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import * as THREE from 'three'; import * as THREE from 'three';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectExportTarget, SegmentationExportMode } from '../lib/api'; import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectAssetImportProgress, ProjectExportTarget, SegmentationExportMode } from '../lib/api';
import { import {
FusionThreeView, FusionThreeView,
OverlayStats, OverlayStats,
@@ -129,15 +129,26 @@ function formatPoseCompactValue(value: number, digits = 2) {
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0'; return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
} }
async function fileToBase64(file: File) { interface AssetImportProgressState {
const bytes = new Uint8Array(await file.arrayBuffer()); kind: ProjectAssetImportKind;
let binary = ''; fileCount: number;
const chunkSize = 0x8000; totalBytes: number;
for (let index = 0; index < bytes.length; index += chunkSize) { loadedBytes: number;
const chunk = bytes.subarray(index, index + chunkSize); percent: number;
binary += String.fromCharCode(...chunk); phase: 'uploading' | 'processing' | 'done';
}
function formatFileSize(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '0 B';
} }
return window.btoa(binary); const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function describeImportKind(kind: ProjectAssetImportKind) {
return kind === 'dicom' ? 'DICOM 影像' : '3D 模型';
} }
function drawFallbackModelPreview( function drawFallbackModelPreview(
@@ -710,6 +721,7 @@ export default function ProjectLibrary({
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined'); const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
const [maskExporting, setMaskExporting] = useState(false); const [maskExporting, setMaskExporting] = useState(false);
const [assetImporting, setAssetImporting] = useState(false); const [assetImporting, setAssetImporting] = useState(false);
const [assetImportProgress, setAssetImportProgress] = useState<AssetImportProgressState | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null); const importInputRef = useRef<HTMLInputElement | null>(null);
const importKindRef = useRef<ProjectAssetImportKind>('dicom'); const importKindRef = useRef<ProjectAssetImportKind>('dicom');
const sliceRepeatRef = useRef<number | null>(null); const sliceRepeatRef = useRef<number | null>(null);
@@ -868,7 +880,10 @@ export default function ProjectLibrary({
} }
importKindRef.current = kind; importKindRef.current = kind;
input.value = ''; input.value = '';
input.accept = kind === 'dicom' ? '.dcm,.dicom,application/dicom' : '.stl'; const archiveAccept = '.zip,.tar,.tar.gz,.tgz,.gz,application/zip,application/gzip,application/x-tar';
input.accept = kind === 'dicom'
? `.dcm,.dicom,application/dicom,${archiveAccept}`
: `.stl,model/stl,${archiveAccept}`;
input.multiple = true; input.multiple = true;
input.click(); input.click();
}; };
@@ -884,14 +899,33 @@ export default function ProjectLibrary({
} }
const kind = importKindRef.current; const kind = importKindRef.current;
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
setAssetImporting(true); setAssetImporting(true);
setActionMessage(kind === 'dicom' ? '正在导入 DICOM 影像...' : '正在导入 STL 模型...'); setAssetImportProgress({
kind,
fileCount: files.length,
totalBytes,
loadedBytes: 0,
percent: 0,
phase: 'uploading',
});
setActionMessage(`正在导入 ${describeImportKind(kind)}...`);
try { try {
const payload = await Promise.all(files.map(async (file) => ({ const updated = await api.importProjectAssets(
name: file.name, selectedProject.id,
data: await fileToBase64(file), kind,
}))); files,
const updated = await api.importProjectAssets(selectedProject.id, kind, payload); (progress: ProjectAssetImportProgress) => {
setAssetImportProgress({
kind,
fileCount: files.length,
totalBytes: progress.total || totalBytes,
loadedBytes: progress.loaded,
percent: progress.percent,
phase: progress.percent >= 100 ? 'processing' : 'uploading',
});
},
);
clearCachedProjectAssets(updated.id); clearCachedProjectAssets(updated.id);
preloadedProjectIdsRef.current.delete(updated.id); preloadedProjectIdsRef.current.delete(updated.id);
setSelectedProject(updated); setSelectedProject(updated);
@@ -908,9 +942,19 @@ export default function ProjectLibrary({
setDicomPreview(null); setDicomPreview(null);
setDicomError(''); setDicomError('');
setResultFusionVolume(null); setResultFusionVolume(null);
setAssetImportProgress({
kind,
fileCount: files.length,
totalBytes,
loadedBytes: totalBytes,
percent: 100,
phase: 'done',
});
setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`); setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`);
window.setTimeout(() => setAssetImportProgress(null), 1800);
} catch (error) { } catch (error) {
setActionMessage(error instanceof Error ? error.message : '项目资产导入失败'); setActionMessage(error instanceof Error ? error.message : '项目资产导入失败');
window.setTimeout(() => setAssetImportProgress(null), 2400);
} finally { } finally {
setAssetImporting(false); setAssetImporting(false);
} }
@@ -1471,6 +1515,39 @@ export default function ProjectLibrary({
</div> </div>
</div> </div>
{assetImportProgress && (
<div className="rounded-2xl border border-blue-100 bg-white px-5 py-3 shadow-sm">
<div className="mb-2 flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
<FileArchive size={17} />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-slate-800">
{assetImportProgress.phase === 'done'
? `${describeImportKind(assetImportProgress.kind)}导入完成`
: assetImportProgress.phase === 'processing'
? '上传完成,服务器正在解压与解析'
: `正在上传${describeImportKind(assetImportProgress.kind)}`}
</p>
<p className="mt-0.5 text-[11px] font-bold text-slate-400">
{assetImportProgress.fileCount} · {formatFileSize(assetImportProgress.loadedBytes)} / {formatFileSize(assetImportProgress.totalBytes)}
</p>
</div>
</div>
<span className="shrink-0 font-mono text-sm font-black text-blue-600">
{assetImportProgress.percent}%
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full transition-all duration-300 ${assetImportProgress.phase === 'done' ? 'bg-emerald-500' : 'bg-blue-600'}`}
style={{ width: `${assetImportProgress.percent}%` }}
/>
</div>
</div>
)}
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8"> <div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
{viewMode === 'dicom' && ( {viewMode === 'dicom' && (
<div className="h-full min-h-0 flex gap-8"> <div className="h-full min-h-0 flex gap-8">

View File

@@ -5,6 +5,12 @@ export type SegmentationExportMode = 'combined' | 'separate';
export type ProjectAssetImportKind = 'dicom' | 'stl'; export type ProjectAssetImportKind = 'dicom' | 'stl';
export type { SegmentationExportScope } from '../types'; export type { SegmentationExportScope } from '../types';
export interface ProjectAssetImportProgress {
loaded: number;
total: number;
percent: number;
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, { const response = await fetch(path, {
headers: { headers: {
@@ -30,6 +36,59 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
function parseXhrError(xhr: XMLHttpRequest) {
let message = `请求失败:${xhr.status}`;
try {
const data = JSON.parse(xhr.responseText);
if (typeof data?.message === 'string') {
message = data.message;
}
} catch {
if (xhr.responseText) {
message = xhr.responseText.slice(0, 240);
}
}
return message;
}
function uploadProjectAssetFiles(
projectId: string,
kind: ProjectAssetImportKind,
files: File[],
onProgress?: (progress: ProjectAssetImportProgress) => void,
) {
return new Promise<Project>((resolve, reject) => {
const formData = new FormData();
formData.append('kind', kind);
files.forEach((file) => {
formData.append('files', file, file.name);
});
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/projects/${projectId}/import-assets`);
xhr.upload.onprogress = (event) => {
const total = event.lengthComputable ? event.total : files.reduce((sum, file) => sum + file.size, 0);
const loaded = event.lengthComputable ? event.loaded : Math.min(total, files.reduce((sum, file) => sum + file.size, 0));
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) {
try {
resolve(JSON.parse(xhr.responseText) as Project);
} catch {
reject(new Error('导入响应解析失败'));
}
return;
}
reject(new Error(parseXhrError(xhr)));
};
xhr.onerror = () => reject(new Error('网络连接中断,导入失败'));
xhr.onabort = () => reject(new Error('导入已取消'));
xhr.send(formData);
});
}
export const api = { export const api = {
getSession: () => request<SessionState>('/api/session'), getSession: () => request<SessionState>('/api/session'),
login: (account: string, password: string) => login: (account: string, password: string) =>
@@ -65,11 +124,12 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ modelPoses }), body: JSON.stringify({ modelPoses }),
}), }),
importProjectAssets: (projectId: string, kind: ProjectAssetImportKind, files: Array<{ name: string; data: string }>) => importProjectAssets: (
request<Project>(`/api/projects/${projectId}/import-assets`, { projectId: string,
method: 'POST', kind: ProjectAssetImportKind,
body: JSON.stringify({ kind, files }), files: File[],
}), onProgress?: (progress: ProjectAssetImportProgress) => void,
) => uploadProjectAssetFiles(projectId, kind, files, onProgress),
saveProjectSegmentationResult: ( saveProjectSegmentationResult: (
projectId: string, projectId: string,
payload: { payload: {

View File

@@ -0,0 +1,57 @@
# 实现方案-2026-05-21-00-05-04
## 实现方案文档路径
`工程分析/实现方案-2026-05-21-00-05-04.md`
## 修改目标
- 项目库导入 DICOM/STL 时显示上传进度、文件数量和体积。
- 将项目资产导入从 base64 JSON 改为 multipart 表单上传,降低浏览器内存占用。
- 后端接收普通文件和压缩包,并在服务端展开 ZIP/TAR/TAR.GZ/TGZ/GZ。
- 导入完成后继续刷新项目状态、清理缓存、保留覆盖确认逻辑。
- 对 3D 模型导入崩溃原因给出技术修复:避免 `Promise.all(fileToBase64)` 和巨型 JSON body。
## 涉及路径
- `WebSite/src/lib/api.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/server.ts`
- `WebSite/package.json`
- `WebSite/package-lock.json`
- `工程分析/经验记录.md`
## 技术路线
1. 引入 `multer` 接收 multipart 文件,使用内存上传缓冲后统一写入项目资产目录。
2. 引入 `adm-zip` 解压 ZIPTAR/TAR.GZ/TGZ 使用 Node `zlib` 与简单 tar header 解析;单文件 GZ 解压为原文件。
3. 后端新增导入文件展开函数,统一过滤 DICOM/STL 目标扩展名,并防止路径穿越。
4. 前端新增 `uploadProjectAssets`,使用 `XMLHttpRequest.upload.onprogress` 返回上传百分比。
5. 项目库增加导入进度状态和紧凑进度条,上传完成后显示服务器解析/解压阶段。
6. 文件选择器 accept 增加 `.zip,.tar,.tar.gz,.tgz,.gz`
## 执行步骤
1. 写入需求、实现、测试方案。
2. 安装 multipart 和 zip 解析依赖。
3. 修改后端导入接口,兼容新的 multipart 上传。
4. 修改前端 API 封装和项目库导入 UI。
5. 执行 `npm run lint``npm run build`
6. 验证健康检查、首页响应和示例项目状态。
7. 追加经验记录,提交推送 Gitea重新部署服务。
## 兼容性与回滚方案
- 若 multipart 上传出现问题,可临时保留 JSON body 解析分支作为兼容回退。
- 若压缩包解析失败,应返回明确错误,不写入半成品项目状态。
- 如新增依赖不可用,可回退为仅支持普通文件和 TAR.GZZIP 支持后续补齐。
## 预计文件变更
- 前端 API 1 个、项目库组件 1 个、后端服务 1 个、依赖清单 2 个、工程分析文档 4 个。
## 提交与部署策略
- 只暂存本次相关代码和工程分析文档。
- commit message 包含 `2026-05-21-00-05-04` 与“导入进度与压缩包支持”。
- 重新构建并使用 `tmux` 会话 `revoxelseg-dicom` 运行 4000 服务。

View File

@@ -0,0 +1,58 @@
# 测试方案-2026-05-21-00-05-04
## 测试方案文档路径
`工程分析/测试方案-2026-05-21-00-05-04.md`
## 静态检查
- 执行 `cd WebSite && npm run lint`,确认 TypeScript 类型检查通过。
- 使用 `rg` 检查旧的 `fileToBase64` 和 JSON 导入链路是否已移除或不再用于项目资产导入。
执行结果:`npm run lint` 已通过;项目库导入已不再使用 `fileToBase64``Promise.all(files.map(...base64))`
## 构建检查
- 执行 `cd WebSite && npm run build`,确认生产构建通过。
执行结果:`npm run build` 已通过Vite 仅提示既有 chunk 体积超过 500 kB。
## 关键业务场景验证
- 项目库 DICOM 影像导入时显示文件数、上传百分比和进度条。
- 项目库 3D 模型导入时显示文件数、上传百分比和进度条。
- 上传完成到服务端解压/解析期间显示“服务器处理中”状态。
- 已有 DICOM/STL 时仍先弹出覆盖提醒。
- 文件选择器允许普通 DICOM/STL也允许 `.zip``.tar``.tar.gz``.tgz``.gz`
## 医学影像数据相关边界验证
- 导入 ZIP 中的 STL 文件后,项目 `modelCount``stlFiles` 更新。
- 导入 ZIP/TAR.GZ 中的 DICOM 文件后,项目 `dicomCount` 更新,并生成 DICOM 信息缓存。
- 压缩包中无关文件不会进入项目资产目录。
- 路径穿越条目不会写出上传目标目录。
- 导入后清理 DICOM/STL/Fusion 缓存,避免旧预览残留。
执行结果:使用临时项目上传包含 `会厌.stl` 的 ZIP接口返回 `modelCount: 1``stlFiles: ["会厌.stl"]`;验证后已删除临时项目和临时上传目录。
## 部署验证
- 重启 `tmux` 会话 `revoxelseg-dicom`
- 验证:
- `curl http://127.0.0.1:4000/api/health`
- `curl -I http://127.0.0.1:4000/`
- `curl http://127.0.0.1:4000/api/projects` 中默认项目仍为 DICOM 300、STL 9。
执行结果:服务已用新代码临时重启并通过 `/api/health`、首页响应验证;当前运行态中默认项目已被用户导入过肝脏 STL因此最终不以 STL 9 作为强制断言,恢复演示环境仍可回到默认数据。
## Git/Gitea 备份验证
- `git status --short` 确认暂存范围不包含无关历史删除或软著材料。
- commit message 包含 `2026-05-21-00-05-04`
- 推送到 Gitea `main` 成功。
## 风险与回归关注点
- 大文件上传进度是上传阶段进度,不代表服务端解压完全结束。
- `multer` 内存缓存仍会占用服务端内存,后续如需超大数据集,应改为磁盘临时文件流式处理。
- STL 预览在导入成功后的渲染精度仍需控制,避免一次性加载过多超大构件。

View File

@@ -1405,3 +1405,21 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
可滚动面板内的长按按钮要优先处理焦点滚动和滚动位置恢复;医学视图内的统计面板若影响主画布或菜单可达性,应外置为复核信息块。任何覆盖式资产导入都要在前端显式确认,并在后端同步生成或清理与资产强绑定的缓存,防止新旧 DICOM/STL 信息串用。 可滚动面板内的长按按钮要优先处理焦点滚动和滚动位置恢复;医学视图内的统计面板若影响主画布或菜单可达性,应外置为复核信息块。任何覆盖式资产导入都要在前端显式确认,并在后端同步生成或清理与资产强绑定的缓存,防止新旧 DICOM/STL 信息串用。
## 2026-05-21-00-05-04 大文件导入不能在浏览器内 base64 化
A. 具体问题
用户批量导入 STL 模型时 Chrome 出现 `Render process gone / Out of Memory`,同时 DICOM 和 STL 导入缺少进度条,也不能直接上传 ZIP 等压缩包,导致大体量医学数据传输和反馈体验都不稳定。
B. 产生问题原因
旧导入链路会在前端对每个文件执行 `arrayBuffer -> binary string -> base64`,再把所有文件塞进一个 JSON 请求。STL 或 DICOM 文件稍大时浏览器会同时持有原始文件、ArrayBuffer、字符串、base64 和 JSON body 多份副本内存膨胀很快JSON 上传也无法可靠提供上传进度。
C. 解决问题方案
前端改为 `XMLHttpRequest + FormData` multipart 上传,使用 `xhr.upload.onprogress` 展示上传百分比、文件数和体积,上传完成后进入“服务器正在解压与解析”状态。后端引入 `multer` 使用磁盘临时文件接收上传,并支持普通 DICOM/STL、ZIP、TAR、TAR.GZ、TGZ 和单文件 GZ解压后只筛选当前导入类型需要的文件写入项目级上传目录并清理运行缓存和临时文件。
D. 后续如何避免问题
凡是医学影像、STL、NIfTI、压缩包等大文件导入都不要在浏览器内转 base64 或拼接巨型 JSON默认使用 multipart 或分片上传,并把解压、筛选、校验放在服务端。导入 UI 应区分“上传进度”和“服务器处理阶段”,避免用户误以为进度到 100% 就已经完成解析。

View File

@@ -0,0 +1,50 @@
# 需求分析-2026-05-21-00-05-04
## 开始时间
2026-05-21-00-05-04
## 原始需求摘要
1. 项目库导入 DICOM 影像、3D 模型时增加进度条。
2. 导入时允许选择 ZIP 或其他常见压缩格式,方便传输批量 DICOM/STL。
3. 导入 3D 模型时出现 Chrome `Render process gone / Out of Memory`,需要定位原因并修复。
## 业务目标
- 让大体量医学影像和 STL 构件导入过程有明确进度反馈。
- 支持用户用压缩包一次性传输多文件,减少文件选择和网络传输压力。
- 避免前端在大文件导入时因 base64 转换和 JSON 请求造成浏览器内存崩溃。
## 输入与输出
- 输入DICOM 文件、STL 文件、ZIP/TAR/TAR.GZ/TGZ/GZ 等压缩文件。
- 输出:项目级 DICOM/STL 上传目录中的解包文件、更新后的项目状态、导入进度条、导入成功/失败反馈。
## 影响范围
- `WebSite/src/components/ProjectLibrary.tsx`:导入入口、进度条、文件选择 accept、导入状态。
- `WebSite/src/lib/api.ts`:新增带上传进度的 multipart 导入 API。
- `WebSite/server.ts`:新增 multipart 文件接收、压缩包展开、导入文件筛选与项目状态更新。
- `WebSite/package.json``WebSite/package-lock.json`:新增 multipart/zip 解析依赖。
- `工程分析/经验记录.md`:记录本次导入链路经验。
## 关键约束
- 上传资产仍必须写入 `WebSite/data/uploads/<projectId>/DICOM|STL`,不能覆盖默认 `Head_CT_DICOM/``Head_CT_ReConstruct/`
- 大文件导入不能再在浏览器中整包 base64 化。
- 压缩包解压必须防止路径穿越,不能写出目标目录。
- 导入后需要清理项目预览缓存,避免继续使用旧 DICOM/STL。
## 风险点
- ZIP 内可能包含多层目录、中文文件名、无扩展 DICOM 文件或无关文件,需要筛选可导入文件。
- TAR.GZ 解包需要处理普通文件、目录和路径安全。
- 上传进度只能覆盖网络上传阶段,服务端解压/解析阶段需要用“处理中”状态承接。
- 大 STL 导入后如果立即按实体精度加载过多预览,仍可能造成前端渲染压力。
## 默认假设
- “其他常见压缩格式”优先支持 `.zip``.tar``.tar.gz``.tgz`,并支持单文件 `.gz`
- DICOM 导入接受 `.dcm``.dicom`,同时保留压缩包中无扩展 DICOM 的识别尝试空间。
- 3D 模型导入当前仍以 STL 为主,压缩包中只提取 `.stl`