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

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