2026-05-20-23-28-51 项目库映射交互与导入提示优化
This commit is contained in:
@@ -25,6 +25,7 @@ import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, Segme
|
||||
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectExportTarget, SegmentationExportMode } from '../lib/api';
|
||||
import {
|
||||
FusionThreeView,
|
||||
OverlayStats,
|
||||
VoxelizationMappingView,
|
||||
clearCachedProjectAssets,
|
||||
getCachedDicomFusionVolume,
|
||||
@@ -91,6 +92,12 @@ const defaultModelPose: ModelPose = {
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
};
|
||||
const emptyOverlayStats: OverlayStats = {
|
||||
activeModules: 0,
|
||||
filledPixels: 0,
|
||||
segmentCount: 0,
|
||||
modules: [],
|
||||
};
|
||||
const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
|
||||
rotateX: { min: -180, max: 180 },
|
||||
rotateY: { min: -180, max: 180 },
|
||||
@@ -680,6 +687,8 @@ export default function ProjectLibrary({
|
||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||
const [resultFusionVolume, setResultFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||
const [resultFusionError, setResultFusionError] = useState('');
|
||||
const [resultOverlayStats, setResultOverlayStats] = useState<OverlayStats>(emptyOverlayStats);
|
||||
const [resultVisibleModuleCount, setResultVisibleModuleCount] = useState(0);
|
||||
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
|
||||
const [dicomInfoError, setDicomInfoError] = useState('');
|
||||
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
|
||||
@@ -839,6 +848,19 @@ export default function ProjectLibrary({
|
||||
return;
|
||||
}
|
||||
const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom';
|
||||
const hasExistingAssets = kind === 'dicom'
|
||||
? (selectedProject.dicomCount ?? 0) > 0
|
||||
: (selectedProject.stlFiles?.length ?? selectedProject.modelCount ?? 0) > 0;
|
||||
if (hasExistingAssets) {
|
||||
const confirmed = window.confirm(
|
||||
kind === 'dicom'
|
||||
? '当前项目已有 DICOM 影像。继续导入会覆盖项目库中的现有 DICOM 影像,并清空当前逆向分割结果,是否继续?'
|
||||
: '当前项目已有 3D 模型。继续导入会覆盖项目库中的现有 STL 模型,并清空当前逆向分割结果,是否继续?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const input = importInputRef.current;
|
||||
if (!input) {
|
||||
setActionMessage('导入控件尚未就绪,请稍后重试');
|
||||
@@ -1159,6 +1181,119 @@ export default function ProjectLibrary({
|
||||
{ id: 'model' as const, label: '3D 模型', icon: Box },
|
||||
{ id: 'mask' as const, label: '逆向分割结果', icon: Layers },
|
||||
];
|
||||
const renderMaskExportMenu = (widthClass = 'w-80') => (
|
||||
<div className={`absolute right-0 top-12 z-50 ${widthClass} rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl`}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="font-bold text-slate-700">导出内容</p>
|
||||
<button
|
||||
onClick={() => setMaskExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
|
||||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{exportOptions.map((option) => (
|
||||
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={maskExportSelection[option.id]}
|
||||
onChange={(event) => setMaskExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
|
||||
className="accent-emerald-600"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block">{option.label}</span>
|
||||
<span className="block text-[10px] text-slate-400">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{maskExportSelection.segmentation && (
|
||||
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
|
||||
<span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationScopeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationScope(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationScope === option.id
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'bg-white text-emerald-700 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-emerald-100 pt-2">
|
||||
<p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationExportModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationExportMode(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationExportMode === option.id
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleMaskBundleExport}
|
||||
disabled={maskExporting}
|
||||
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
导出所选压缩包
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const renderResultOverlaySummary = () => (
|
||||
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-800">Overlay Label Map</p>
|
||||
<p className="mt-1 font-mono text-[11px] font-bold text-cyan-700">
|
||||
{resultOverlayStats.activeModules}/{resultVisibleModuleCount} 构件 · {resultOverlayStats.segmentCount} 边 · {resultOverlayStats.filledPixels} px
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-lg bg-white px-2 py-1 text-[10px] font-bold text-slate-400">当前切片</span>
|
||||
</div>
|
||||
{resultOverlayStats.modules.length ? (
|
||||
<div className="grid max-h-52 grid-cols-1 gap-2 overflow-auto pr-1">
|
||||
{resultOverlayStats.modules.map((item) => (
|
||||
<div key={item.fileName} className="grid grid-cols-[12px_1fr_auto] items-center gap-2 rounded-xl border border-slate-100 bg-white px-3 py-2 text-[10px] font-bold text-slate-600">
|
||||
<span className="h-3 w-3 rounded-full border border-white shadow-sm" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||||
<span className="min-w-0 truncate">{item.name}</span>
|
||||
<span className="font-mono text-cyan-700">ID {item.partId}</span>
|
||||
<span className="col-start-2 font-mono text-slate-400">{item.segmentCount} 边</span>
|
||||
<span className="font-mono text-slate-400">{item.filledPixels} px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-100 bg-white px-3 py-2 text-[10px] font-bold text-slate-400">
|
||||
当前切片暂无可见构件
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-6 overflow-hidden">
|
||||
@@ -1312,7 +1447,19 @@ export default function ProjectLibrary({
|
||||
>
|
||||
<RotateCw size={18} /> 进入逆向工作区
|
||||
</button>
|
||||
{viewMode !== 'mask' && (
|
||||
{viewMode === 'mask' ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMaskExportMenu((value) => !value)}
|
||||
disabled={maskExporting || !latestSegmentationResult}
|
||||
className="bg-emerald-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-emerald-700 transition-all shadow-lg disabled:opacity-50"
|
||||
>
|
||||
<Download size={18} />
|
||||
{maskExporting ? '正在导出' : '导出项目及结果'}
|
||||
</button>
|
||||
{showMaskExportMenu && renderMaskExportMenu('w-80')}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={triggerProjectAssetImport}
|
||||
disabled={assetImporting}
|
||||
@@ -1692,6 +1839,11 @@ export default function ProjectLibrary({
|
||||
displayMode={resultDisplayMode}
|
||||
rotation={resultRotation}
|
||||
variant="library"
|
||||
overlayPlacement="none"
|
||||
onOverlayStatsChange={(stats, visibleCount) => {
|
||||
setResultOverlayStats(stats);
|
||||
setResultVisibleModuleCount(visibleCount);
|
||||
}}
|
||||
toolbar={(
|
||||
<>
|
||||
<div className="flex rounded-xl bg-white/10 p-1">
|
||||
@@ -1733,14 +1885,14 @@ export default function ProjectLibrary({
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800">逆向分割结果</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
项目库仅保留最新一次保存结果,导出时默认沿用该结果的模型位姿与构件样式。
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-lg px-2 py-1 text-[10px] font-bold ${latestSegmentationResult ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'}`}>
|
||||
<span className={`shrink-0 whitespace-nowrap rounded-lg px-2 py-1 text-[10px] font-bold ${latestSegmentationResult ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'}`}>
|
||||
{latestSegmentationResult ? '已保存' : '未保存'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1764,99 +1916,7 @@ export default function ProjectLibrary({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMaskExportMenu((value) => !value)}
|
||||
disabled={maskExporting || !latestSegmentationResult}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white shadow-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Download size={18} />
|
||||
{maskExporting ? '正在导出' : '导出项目及结果'}
|
||||
</button>
|
||||
{showMaskExportMenu && (
|
||||
<div className="absolute right-0 top-14 z-30 w-full rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="font-bold text-slate-700">导出内容</p>
|
||||
<button
|
||||
onClick={() => setMaskExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
|
||||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{exportOptions.map((option) => (
|
||||
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={maskExportSelection[option.id]}
|
||||
onChange={(event) => setMaskExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
|
||||
className="accent-emerald-600"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block">{option.label}</span>
|
||||
<span className="block text-[10px] text-slate-400">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{maskExportSelection.segmentation && (
|
||||
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
|
||||
<span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationScopeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationScope(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationScope === option.id
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'bg-white text-emerald-700 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-emerald-100 pt-2">
|
||||
<p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationExportModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationExportMode(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationExportMode === option.id
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleMaskBundleExport}
|
||||
disabled={maskExporting}
|
||||
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
导出所选压缩包
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{latestSegmentationResult && renderResultOverlaySummary()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user