2026-05-20-14-53-31 逆向结果复核与用户管理修复

This commit is contained in:
2026-05-20 15:08:20 +08:00
parent 2a599695e9
commit fd7f3387f7
12 changed files with 886 additions and 111 deletions

View File

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