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",
"@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",

View File

@@ -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",

View File

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

View File

@@ -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">

View File

@@ -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: {