2026-05-21-00-05-04 导入进度与压缩包支持
This commit is contained in:
@@ -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