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

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