From 14c8eb153d79fbd1c5c743b3326d1493616a206a Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Thu, 21 May 2026 00:24:29 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-21-00-05-04=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E4=B8=8E=E5=8E=8B=E7=BC=A9=E5=8C=85=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/package-lock.json | 133 +++++++++++++++++ WebSite/package.json | 4 + WebSite/server.ts | 168 +++++++++++++++++++++- WebSite/src/components/ProjectLibrary.tsx | 109 +++++++++++--- WebSite/src/lib/api.ts | 70 ++++++++- 工程分析/实现方案-2026-05-21-00-05-04.md | 57 ++++++++ 工程分析/测试方案-2026-05-21-00-05-04.md | 58 ++++++++ 工程分析/经验记录.md | 18 +++ 工程分析/需求分析-2026-05-21-00-05-04.md | 50 +++++++ 9 files changed, 640 insertions(+), 27 deletions(-) create mode 100644 工程分析/实现方案-2026-05-21-00-05-04.md create mode 100644 工程分析/测试方案-2026-05-21-00-05-04.md create mode 100644 工程分析/需求分析-2026-05-21-00-05-04.md diff --git a/WebSite/package-lock.json b/WebSite/package-lock.json index c3c3b62..d7aaac1 100644 --- a/WebSite/package-lock.json +++ b/WebSite/package-lock.json @@ -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", diff --git a/WebSite/package.json b/WebSite/package.json index 5ab037b..a4575c2 100644 --- a/WebSite/package.json +++ b/WebSite/package.json @@ -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", diff --git a/WebSite/server.ts b/WebSite/server.ts index 823ac25..ecff4ae 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -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) { + 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(); + 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) => { diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 38f051d..0adc23a 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -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('combined'); const [maskExporting, setMaskExporting] = useState(false); const [assetImporting, setAssetImporting] = useState(false); + const [assetImportProgress, setAssetImportProgress] = useState(null); const importInputRef = useRef(null); const importKindRef = useRef('dicom'); const sliceRepeatRef = useRef(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({ + {assetImportProgress && ( +
+
+
+
+ +
+
+

+ {assetImportProgress.phase === 'done' + ? `${describeImportKind(assetImportProgress.kind)}导入完成` + : assetImportProgress.phase === 'processing' + ? '上传完成,服务器正在解压与解析' + : `正在上传${describeImportKind(assetImportProgress.kind)}`} +

+

+ {assetImportProgress.fileCount} 个文件 · {formatFileSize(assetImportProgress.loadedBytes)} / {formatFileSize(assetImportProgress.totalBytes)} +

+
+
+ + {assetImportProgress.percent}% + +
+
+
+
+
+ )} +
{viewMode === 'dicom' && (
diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index b2e9352..9ca1b12 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -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(path: string, options: RequestInit = {}): Promise { const response = await fetch(path, { headers: { @@ -30,6 +36,59 @@ async function request(path: string, options: RequestInit = {}): Promise { return response.json() as Promise; } +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((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('/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(`/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: { diff --git a/工程分析/实现方案-2026-05-21-00-05-04.md b/工程分析/实现方案-2026-05-21-00-05-04.md new file mode 100644 index 0000000..e73ce37 --- /dev/null +++ b/工程分析/实现方案-2026-05-21-00-05-04.md @@ -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` 解压 ZIP;TAR/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.GZ,ZIP 支持后续补齐。 + +## 预计文件变更 + +- 前端 API 1 个、项目库组件 1 个、后端服务 1 个、依赖清单 2 个、工程分析文档 4 个。 + +## 提交与部署策略 + +- 只暂存本次相关代码和工程分析文档。 +- commit message 包含 `2026-05-21-00-05-04` 与“导入进度与压缩包支持”。 +- 重新构建并使用 `tmux` 会话 `revoxelseg-dicom` 运行 4000 服务。 diff --git a/工程分析/测试方案-2026-05-21-00-05-04.md b/工程分析/测试方案-2026-05-21-00-05-04.md new file mode 100644 index 0000000..a59c34a --- /dev/null +++ b/工程分析/测试方案-2026-05-21-00-05-04.md @@ -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 预览在导入成功后的渲染精度仍需控制,避免一次性加载过多超大构件。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index b02dd55..6d91aab 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1405,3 +1405,21 @@ C. 解决问题方案 D. 后续如何避免问题 可滚动面板内的长按按钮要优先处理焦点滚动和滚动位置恢复;医学视图内的统计面板若影响主画布或菜单可达性,应外置为复核信息块。任何覆盖式资产导入都要在前端显式确认,并在后端同步生成或清理与资产强绑定的缓存,防止新旧 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% 就已经完成解析。 diff --git a/工程分析/需求分析-2026-05-21-00-05-04.md b/工程分析/需求分析-2026-05-21-00-05-04.md new file mode 100644 index 0000000..1971775 --- /dev/null +++ b/工程分析/需求分析-2026-05-21-00-05-04.md @@ -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//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`。