2026-05-21-00-05-04 导入进度与压缩包支持
This commit is contained in:
133
WebSite/package-lock.json
generated
133
WebSite/package-lock.json
generated
@@ -13,12 +13,14 @@
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"adm-zip": "^0.5.17",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"multer": "^2.1.1",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -27,7 +29,9 @@
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"tailwindcss": "^4.1.14",
|
||||
@@ -1611,6 +1615,16 @@
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1788,6 +1802,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
@@ -1958,6 +1982,15 @@
|
||||
"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": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -1967,6 +2000,12 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -2177,6 +2216,23 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -2257,6 +2313,21 @@
|
||||
"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": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -3752,6 +3823,25 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "3.3.12",
|
||||
"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": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
@@ -4465,6 +4569,23 @@
|
||||
"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": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
|
||||
@@ -4678,6 +4799,12 @@
|
||||
"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": {
|
||||
"version": "5.8.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
|
||||
@@ -17,12 +17,14 @@
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"adm-zip": "^0.5.17",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"multer": "^2.1.1",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"recharts": "^3.8.1",
|
||||
@@ -31,7 +33,9 @@
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.8",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"tailwindcss": "^4.1.14",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
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 {
|
||||
FusionThreeView,
|
||||
OverlayStats,
|
||||
@@ -129,15 +129,26 @@ function formatPoseCompactValue(value: number, digits = 2) {
|
||||
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
|
||||
}
|
||||
|
||||
async function fileToBase64(file: File) {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
const chunk = bytes.subarray(index, index + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
interface AssetImportProgressState {
|
||||
kind: ProjectAssetImportKind;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
loadedBytes: number;
|
||||
percent: number;
|
||||
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(
|
||||
@@ -710,6 +721,7 @@ export default function ProjectLibrary({
|
||||
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||||
const [maskExporting, setMaskExporting] = useState(false);
|
||||
const [assetImporting, setAssetImporting] = useState(false);
|
||||
const [assetImportProgress, setAssetImportProgress] = useState<AssetImportProgressState | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const importKindRef = useRef<ProjectAssetImportKind>('dicom');
|
||||
const sliceRepeatRef = useRef<number | null>(null);
|
||||
@@ -868,7 +880,10 @@ export default function ProjectLibrary({
|
||||
}
|
||||
importKindRef.current = kind;
|
||||
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.click();
|
||||
};
|
||||
@@ -884,14 +899,33 @@ export default function ProjectLibrary({
|
||||
}
|
||||
|
||||
const kind = importKindRef.current;
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
setAssetImporting(true);
|
||||
setActionMessage(kind === 'dicom' ? '正在导入 DICOM 影像...' : '正在导入 STL 模型...');
|
||||
setAssetImportProgress({
|
||||
kind,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
loadedBytes: 0,
|
||||
percent: 0,
|
||||
phase: 'uploading',
|
||||
});
|
||||
setActionMessage(`正在导入 ${describeImportKind(kind)}...`);
|
||||
try {
|
||||
const payload = await Promise.all(files.map(async (file) => ({
|
||||
name: file.name,
|
||||
data: await fileToBase64(file),
|
||||
})));
|
||||
const updated = await api.importProjectAssets(selectedProject.id, kind, payload);
|
||||
const updated = await api.importProjectAssets(
|
||||
selectedProject.id,
|
||||
kind,
|
||||
files,
|
||||
(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);
|
||||
preloadedProjectIdsRef.current.delete(updated.id);
|
||||
setSelectedProject(updated);
|
||||
@@ -908,9 +942,19 @@ export default function ProjectLibrary({
|
||||
setDicomPreview(null);
|
||||
setDicomError('');
|
||||
setResultFusionVolume(null);
|
||||
setAssetImportProgress({
|
||||
kind,
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
loadedBytes: totalBytes,
|
||||
percent: 100,
|
||||
phase: 'done',
|
||||
});
|
||||
setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`);
|
||||
window.setTimeout(() => setAssetImportProgress(null), 1800);
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : '项目资产导入失败');
|
||||
window.setTimeout(() => setAssetImportProgress(null), 2400);
|
||||
} finally {
|
||||
setAssetImporting(false);
|
||||
}
|
||||
@@ -1471,6 +1515,39 @@ export default function ProjectLibrary({
|
||||
</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">
|
||||
{viewMode === 'dicom' && (
|
||||
<div className="h-full min-h-0 flex gap-8">
|
||||
|
||||
@@ -5,6 +5,12 @@ export type SegmentationExportMode = 'combined' | 'separate';
|
||||
export type ProjectAssetImportKind = 'dicom' | 'stl';
|
||||
export type { SegmentationExportScope } from '../types';
|
||||
|
||||
export interface ProjectAssetImportProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
@@ -30,6 +36,59 @@ async function request<T>(path: string, options: RequestInit = {}): 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 = {
|
||||
getSession: () => request<SessionState>('/api/session'),
|
||||
login: (account: string, password: string) =>
|
||||
@@ -65,11 +124,12 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ modelPoses }),
|
||||
}),
|
||||
importProjectAssets: (projectId: string, kind: ProjectAssetImportKind, files: Array<{ name: string; data: string }>) =>
|
||||
request<Project>(`/api/projects/${projectId}/import-assets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind, files }),
|
||||
}),
|
||||
importProjectAssets: (
|
||||
projectId: string,
|
||||
kind: ProjectAssetImportKind,
|
||||
files: File[],
|
||||
onProgress?: (progress: ProjectAssetImportProgress) => void,
|
||||
) => uploadProjectAssetFiles(projectId, kind, files, onProgress),
|
||||
saveProjectSegmentationResult: (
|
||||
projectId: string,
|
||||
payload: {
|
||||
|
||||
Reference in New Issue
Block a user