2026-05-20-14-53-31 逆向结果复核与用户管理修复
This commit is contained in:
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Settings2,
|
||||
Download,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Rotate3d,
|
||||
AlertCircle,
|
||||
ChevronLeft,
|
||||
@@ -28,6 +30,7 @@ interface ModelPreviewPayload {
|
||||
|
||||
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
type DicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
type MappingDisplayMode = DicomPreview['mode'];
|
||||
type ModelPoseKey = keyof ModelPose;
|
||||
type PoseDraftValues = Record<ModelPoseKey, string>;
|
||||
type AxisKey = 'x' | 'y' | 'z';
|
||||
@@ -54,6 +57,12 @@ const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOp
|
||||
{ id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 },
|
||||
{ id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 },
|
||||
];
|
||||
const mappingDisplayModes: Array<{ id: MappingDisplayMode; label: string }> = [
|
||||
{ id: 'default', label: '默认' },
|
||||
{ id: 'bone', label: '骨窗' },
|
||||
{ id: 'soft', label: '软组织' },
|
||||
{ id: 'contrast', label: '高对比' },
|
||||
];
|
||||
const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = {
|
||||
rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||||
rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||||
@@ -207,6 +216,41 @@ function poseValuesMatch(left: ModelPose, right: ModelPose) {
|
||||
return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6);
|
||||
}
|
||||
|
||||
function stableModuleStyles(styles: Record<string, ModuleStyle>) {
|
||||
return Object.keys(styles)
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-Hans-CN'))
|
||||
.reduce<Record<string, ModuleStyle>>((accumulator, key) => {
|
||||
accumulator[key] = styles[key];
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function createWorkspaceSnapshot(input: {
|
||||
modelPose: ModelPose;
|
||||
segmentationExportScope: SegmentationExportScope;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
sliceStart: number;
|
||||
sliceEnd: number;
|
||||
mappingSlice: number;
|
||||
displayLevel: DisplayLevel;
|
||||
dicomOpacityLevel: DicomOpacityLevel;
|
||||
showBounds: boolean;
|
||||
cutEnabled: boolean;
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
modelPose: input.modelPose,
|
||||
segmentationExportScope: input.segmentationExportScope,
|
||||
moduleStyles: stableModuleStyles(input.moduleStyles),
|
||||
sliceStart: input.sliceStart,
|
||||
sliceEnd: input.sliceEnd,
|
||||
mappingSlice: input.mappingSlice,
|
||||
displayLevel: input.displayLevel,
|
||||
dicomOpacityLevel: input.dicomOpacityLevel,
|
||||
showBounds: input.showBounds,
|
||||
cutEnabled: input.cutEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
function parseImportedPosePayload(payload: unknown) {
|
||||
const record = isRecord(payload) ? payload : {};
|
||||
const importedModelPoses = normalizeImportedModelPoses(record.modelPoses);
|
||||
@@ -1701,6 +1745,8 @@ function VoxelizationMappingView({
|
||||
slice,
|
||||
totalSlices,
|
||||
onSliceChange,
|
||||
displayMode,
|
||||
rotation,
|
||||
}: {
|
||||
project: Project | null;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
@@ -1709,6 +1755,8 @@ function VoxelizationMappingView({
|
||||
slice: number;
|
||||
totalSlices: number;
|
||||
onSliceChange: (slice: number) => void;
|
||||
displayMode: MappingDisplayMode;
|
||||
rotation: number;
|
||||
}) {
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -1731,7 +1779,7 @@ function VoxelizationMappingView({
|
||||
|
||||
let disposed = false;
|
||||
setDicomStatus('正在载入 DICOM Base Layer...');
|
||||
api.getDicomPreview(project.id, safeSlice, 'axial', 'soft')
|
||||
api.getDicomPreview(project.id, safeSlice, 'axial', displayMode)
|
||||
.then((preview) => {
|
||||
if (disposed) return;
|
||||
setDicomPreview(preview);
|
||||
@@ -1746,7 +1794,7 @@ function VoxelizationMappingView({
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [project?.id, project?.dicomCount, safeSlice]);
|
||||
}, [project?.id, project?.dicomCount, safeSlice, displayMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !stlFiles.length) {
|
||||
@@ -1847,7 +1895,10 @@ function VoxelizationMappingView({
|
||||
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
|
||||
</div>
|
||||
{dicomPreview ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ transform: `rotate(${rotation}deg)` }}
|
||||
>
|
||||
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||||
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||||
</div>
|
||||
@@ -1947,6 +1998,8 @@ export default function ReverseWorkspace({
|
||||
const [poseImportStatus, setPoseImportStatus] = useState('');
|
||||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||||
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
|
||||
const [mappingDisplayMode, setMappingDisplayMode] = useState<MappingDisplayMode>('soft');
|
||||
const [mappingRotation, setMappingRotation] = useState(0);
|
||||
const [showBounds, setShowBounds] = useState(true);
|
||||
const [cutEnabled, setCutEnabled] = useState(false);
|
||||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||||
@@ -1969,6 +2022,7 @@ export default function ReverseWorkspace({
|
||||
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
const savedWorkspaceSnapshotRef = useRef('');
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
const selectedItems = exportOptions
|
||||
@@ -1994,6 +2048,30 @@ export default function ReverseWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentWorkspaceSnapshot = useCallback(() => createWorkspaceSnapshot({
|
||||
modelPose,
|
||||
segmentationExportScope,
|
||||
moduleStyles,
|
||||
sliceStart,
|
||||
sliceEnd,
|
||||
mappingSlice,
|
||||
displayLevel,
|
||||
dicomOpacityLevel,
|
||||
showBounds,
|
||||
cutEnabled,
|
||||
}), [
|
||||
modelPose,
|
||||
segmentationExportScope,
|
||||
moduleStyles,
|
||||
sliceStart,
|
||||
sliceEnd,
|
||||
mappingSlice,
|
||||
displayLevel,
|
||||
dicomOpacityLevel,
|
||||
showBounds,
|
||||
cutEnabled,
|
||||
]);
|
||||
|
||||
const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
|
||||
if (!project) {
|
||||
return false;
|
||||
@@ -2016,6 +2094,7 @@ export default function ReverseWorkspace({
|
||||
cutEnabled,
|
||||
});
|
||||
setProject(updated);
|
||||
savedWorkspaceSnapshotRef.current = getCurrentWorkspaceSnapshot();
|
||||
if (options.showToast !== false) {
|
||||
setSaveStatus('已保存至项目库的分割结果区域');
|
||||
}
|
||||
@@ -2040,6 +2119,7 @@ export default function ReverseWorkspace({
|
||||
dicomOpacityLevel,
|
||||
showBounds,
|
||||
cutEnabled,
|
||||
getCurrentWorkspaceSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2072,7 +2152,10 @@ export default function ReverseWorkspace({
|
||||
if (!project) {
|
||||
return true;
|
||||
}
|
||||
const shouldSave = window.confirm('是否保存当前结果至项目库?\\n确定:保存后退出。\\n取消:直接退出,不保存当前结果。');
|
||||
if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) {
|
||||
return true;
|
||||
}
|
||||
const shouldSave = window.confirm('是否保存当前结果至项目库? 确定:保存后退出。取消:直接退出,不保存当前结果。');
|
||||
if (!shouldSave) {
|
||||
return true;
|
||||
}
|
||||
@@ -2080,7 +2163,7 @@ export default function ReverseWorkspace({
|
||||
});
|
||||
|
||||
return () => onLeaveGuardChange(null);
|
||||
}, [handleSaveSegmentationResult, onLeaveGuardChange, project]);
|
||||
}, [getCurrentWorkspaceSnapshot, handleSaveSegmentationResult, onLeaveGuardChange, project]);
|
||||
|
||||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||
visible: fallback?.visible ?? true,
|
||||
@@ -2149,11 +2232,26 @@ export default function ReverseWorkspace({
|
||||
setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible');
|
||||
setDisplayLevel(latestResult?.displayLevel ?? 'standard');
|
||||
setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low');
|
||||
setMappingDisplayMode('soft');
|
||||
setMappingRotation(0);
|
||||
setShowBounds(latestResult?.showBounds ?? true);
|
||||
setCutEnabled(latestResult?.cutEnabled ?? false);
|
||||
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
|
||||
modelPose: restoredPose,
|
||||
segmentationExportScope: latestResult?.segmentationScope ?? 'visible',
|
||||
moduleStyles: nextStyles,
|
||||
sliceStart: restoredSliceStart,
|
||||
sliceEnd: restoredSliceEnd,
|
||||
mappingSlice: restoredMappingSlice,
|
||||
displayLevel: latestResult?.displayLevel ?? 'standard',
|
||||
dicomOpacityLevel: latestResult?.dicomOpacityLevel ?? 'low',
|
||||
showBounds: latestResult?.showBounds ?? true,
|
||||
cutEnabled: latestResult?.cutEnabled ?? false,
|
||||
});
|
||||
}).catch(() => {
|
||||
setProject(null);
|
||||
setFusionVolume(null);
|
||||
savedWorkspaceSnapshotRef.current = '';
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
@@ -2890,11 +2988,40 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="px-2 flex items-center justify-between shrink-0">
|
||||
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Layers size={18} className="text-cyan-500" />
|
||||
逆向分割映射视图
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<div className="flex rounded-xl bg-slate-100 p-1">
|
||||
{mappingDisplayModes.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setMappingDisplayMode(mode.id)}
|
||||
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
|
||||
mappingDisplayMode === mode.id ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMappingRotation((value) => (value + 270) % 360)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
|
||||
title="左转 90°"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMappingRotation((value) => (value + 90) % 360)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
|
||||
title="右转 90°"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VoxelizationMappingView
|
||||
@@ -2905,6 +3032,8 @@ export default function ReverseWorkspace({
|
||||
slice={safeMappingSlice}
|
||||
totalSlices={project?.dicomCount ?? 0}
|
||||
onSliceChange={setMappingSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={mappingRotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user