2026-05-20-23-28-51 项目库映射交互与导入提示优化

This commit is contained in:
2026-05-20 23:44:42 +08:00
parent 67295ddd9f
commit dcd6fe56c7
8 changed files with 427 additions and 106 deletions

View File

@@ -243,6 +243,40 @@ function getProjectModelFilePath(project: ProjectRecord, fileName: string) {
return path.join(getProjectModelDir(project), fileName);
}
function getProjectDicomInfoCachePath(project: ProjectRecord) {
const dicomAssetDir = getProjectDicomDir(project);
const resolvedDir = path.resolve(dicomAssetDir);
const resolvedUploadDir = path.resolve(uploadDir);
if (!resolvedDir.startsWith(`${resolvedUploadDir}${path.sep}`)) {
return null;
}
return path.join(resolvedDir, '.revoxelseg-dicom-info.json');
}
function readCachedDicomInfo(project: ProjectRecord, files: string[]) {
const cachePath = getProjectDicomInfoCachePath(project);
if (!cachePath || !fs.existsSync(cachePath)) {
return null;
}
try {
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8')) as { files?: string[]; info?: unknown };
if (!Array.isArray(cached.files) || cached.files.join('|') !== files.join('|') || !cached.info) {
return null;
}
return cached.info;
} catch {
return null;
}
}
function writeCachedDicomInfo(project: ProjectRecord, files: string[], info: unknown) {
const cachePath = getProjectDicomInfoCachePath(project);
if (!cachePath) {
return;
}
fs.writeFileSync(cachePath, JSON.stringify({ generatedAt: now(), files, info }, null, 2));
}
function clearProjectRuntimeCaches(projectId: string) {
[...dicomPreviewCache.keys()].forEach((key) => {
if (key.startsWith(`${projectId}:`)) {
@@ -2391,6 +2425,8 @@ async function startServer() {
project.dicomPath = toRepoRelativePath(targetDir);
project.dicomCount = dicomFiles.length;
project.segmentationResults = [];
const dicomInfo = createDicomInfo(project, dicomFiles);
writeCachedDicomInfo(project, dicomFiles, dicomInfo);
} else {
const stlFiles = listFiles(targetDir, '.stl');
project.modelPath = toRepoRelativePath(targetDir);
@@ -2566,7 +2602,15 @@ async function startServer() {
}
try {
res.json(createDicomInfo(project, files));
const cachedInfo = readCachedDicomInfo(project, files);
if (cachedInfo) {
res.json(cachedInfo);
return;
}
const info = createDicomInfo(project, files);
writeCachedDicomInfo(project, files, info);
res.json(info);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 信息解析失败' });
}

View File

@@ -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>
)}

View File

@@ -1257,7 +1257,7 @@ interface PlaneSegment {
b: Point2D;
}
interface OverlayStats {
export interface OverlayStats {
activeModules: number;
filledPixels: number;
segmentCount: number;
@@ -1881,6 +1881,7 @@ export function VoxelizationMappingView({
variant = 'workspace',
toolbar,
overlayPlacement,
onOverlayStatsChange,
}: {
project: Project | null;
moduleStyles: Record<string, ModuleStyle>;
@@ -1893,7 +1894,8 @@ export function VoxelizationMappingView({
rotation: number;
variant?: 'workspace' | 'library';
toolbar?: React.ReactNode;
overlayPlacement?: 'bottom' | 'side';
overlayPlacement?: 'bottom' | 'side' | 'none';
onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void;
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -1918,6 +1920,10 @@ export function VoxelizationMappingView({
const isLibraryVariant = variant === 'library';
const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom');
useEffect(() => {
onOverlayStatsChange?.(overlayStats, visibleModuleCount);
}, [onOverlayStatsChange, overlayStats, visibleModuleCount]);
useEffect(() => {
if (!project?.dicomCount) {
setDicomPreview(null);
@@ -2360,6 +2366,7 @@ export default function ReverseWorkspace({
const workspaceLoadProjectRef = useRef('');
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const visualToolbarScrollRef = useRef<HTMLDivElement | null>(null);
const saveToastTimerRef = useRef<number | null>(null);
const savedWorkspaceSnapshotRef = useRef('');
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
@@ -2767,13 +2774,26 @@ export default function ReverseWorkspace({
}
};
const restoreVisualToolbarScroll = (scrollTop: number | null) => {
if (scrollTop === null) {
return;
}
window.requestAnimationFrame(() => {
if (visualToolbarScrollRef.current) {
visualToolbarScrollRef.current.scrollTop = scrollTop;
}
});
};
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
setModelPose((current) => ({
...current,
[key]: clampPoseValue(key, current[key] + delta),
}));
setSelectedPoseId('custom');
setPoseImportStatus('');
restoreVisualToolbarScroll(scrollTop);
};
const handlePoseInputChange = (key: ModelPoseKey, value: string) => {
@@ -3378,7 +3398,7 @@ export default function ReverseWorkspace({
</div>
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
<div className="flex-1 overflow-auto space-y-4 pr-1">
<div ref={visualToolbarScrollRef} className="flex-1 overflow-auto space-y-4 pr-1">
<div>
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400"></p>
<div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
@@ -3511,7 +3531,10 @@ export default function ReverseWorkspace({
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
<span>{item.label}</span>
<button
onMouseDown={() => startPoseRepeat(item.key, -poseStepConfig[item.key].step)}
onMouseDown={(event) => {
event.preventDefault();
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
}}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {
@@ -3535,7 +3558,10 @@ export default function ReverseWorkspace({
className="accent-blue-600"
/>
<button
onMouseDown={() => startPoseRepeat(item.key, poseStepConfig[item.key].step)}
onMouseDown={(event) => {
event.preventDefault();
startPoseRepeat(item.key, poseStepConfig[item.key].step);
}}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {

View File

@@ -122,9 +122,11 @@
-webkit-appearance: none;
background: transparent;
height: 100%;
inset: 0;
left: 50%;
position: absolute;
width: 100%;
top: 0;
transform: translateX(-50%);
width: 32px;
direction: rtl;
writing-mode: vertical-rl;
}
@@ -136,6 +138,7 @@
.mapping-slice-vertical-input::-webkit-slider-runnable-track {
background: transparent;
border: 0;
margin: 0 auto;
width: 8px;
}
@@ -148,6 +151,7 @@
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
cursor: grab;
height: 22px;
margin-left: -7px;
width: 22px;
}
@@ -180,9 +184,11 @@
-webkit-appearance: none;
background: transparent;
height: 100%;
inset: 0;
left: 50%;
position: absolute;
width: 100%;
top: 0;
transform: translateX(-50%);
width: 30px;
direction: rtl;
writing-mode: vertical-rl;
}
@@ -194,6 +200,7 @@
.mapping-slice-dark-vertical-input::-webkit-slider-runnable-track {
background: transparent;
border: 0;
margin: 0 auto;
width: 6px;
}
@@ -206,6 +213,7 @@
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 20px;
margin-left: -7px;
width: 20px;
}