diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index a12735c..721f3a3 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -9,7 +9,6 @@ import { Box, Image as ImageIcon, Info, - ChevronLeft, ChevronRight, ChevronUp, ChevronDown, @@ -22,8 +21,14 @@ import { Upload } from 'lucide-react'; import * as THREE from 'three'; -import { DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; +import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types'; import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api'; +import { + FusionThreeView, + VoxelizationMappingView, + dicomOpacityOptions as reverseDicomOpacityOptions, + displayOptions as reverseDisplayOptions, +} from './ReverseWorkspace'; type Plane = 'axial' | 'sagittal' | 'coronal'; type DisplayMode = DicomPreview['mode']; @@ -660,7 +665,8 @@ export default function ProjectLibrary({ const [resultRotation, setResultRotation] = useState(0); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); - const [resultDicomPreview, setResultDicomPreview] = useState(null); + const [resultFusionVolume, setResultFusionVolume] = useState(null); + const [resultFusionError, setResultFusionError] = useState(''); const [dicomInfo, setDicomInfo] = useState(null); const [dicomInfoError, setDicomInfoError] = useState(''); const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false); @@ -756,7 +762,10 @@ export default function ProjectLibrary({ const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles; const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0); const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice)); - const resultFineDetailLimit = solidityOptions.find((option) => option.id === 'fine')?.limit ?? 36000; + const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0]; + const resultDicomOpacity = reverseDicomOpacityOptions.find((option) => option.id === 'high') ?? reverseDicomOpacityOptions[reverseDicomOpacityOptions.length - 1]; + const resultCutStart = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceStart ?? 0)); + const resultCutEnd = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceEnd ?? resultMaxSlice)); const resultVisibleModules = stlFiles .map((fileName, index) => ({ fileName, @@ -872,30 +881,33 @@ export default function ProjectLibrary({ }, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]); useEffect(() => { - if (!selectedProject || viewMode !== 'mask' || !selectedProject.dicomCount) { - setResultDicomPreview(null); + if (!selectedProject || viewMode !== 'mask' || !latestSegmentationResult || !selectedProject.dicomCount) { + setResultFusionVolume(null); + setResultFusionError(''); return; } let cancelled = false; - const maxSlice = Math.max(selectedProject.dicomCount - 1, 0); - const previewSlice = Math.max(0, Math.min(maxSlice, resultPreviewSlice)); - api.getDicomPreview(selectedProject.id, previewSlice, 'axial', resultDisplayMode) - .then((preview) => { + const start = Math.min(resultCutStart, resultCutEnd); + const end = Math.max(resultCutStart, resultCutEnd); + setResultFusionError(''); + api.getDicomFusionVolume(selectedProject.id, start, end, 'soft') + .then((volume) => { if (!cancelled) { - setResultDicomPreview(preview); + setResultFusionVolume(volume); } }) - .catch(() => { + .catch((error) => { if (!cancelled) { - setResultDicomPreview(null); + setResultFusionVolume(null); + setResultFusionError(error instanceof Error ? error.message : 'DICOM 三维融合体载入失败'); } }); return () => { cancelled = true; }; - }, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultPreviewSlice, resultDisplayMode]); + }, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultCutStart, resultCutEnd]); useEffect(() => () => { if (sliceRepeatRef.current !== null) { @@ -1554,136 +1566,87 @@ export default function ProjectLibrary({ )} {viewMode === 'mask' && ( -
-
-
-
-

影像与模型融合视角

-

- {latestSegmentationResult ? `模型显示 精细 · 融合显示 DICOM 高 · Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'} -

+
+
+ {latestSegmentationResult ? ( + + ) : ( +
+ 暂无保存结果,请在逆向工作区保存当前映射。
- {latestSegmentationResult ? ( - - ) : ( -
- 暂无保存结果,请在逆向工作区保存当前映射。 -
- )} -
+ )} + {resultFusionError && ( +

+ {resultFusionError} +

+ )} +
-
-
-
- - BASE DICOM - - - OVERLAY LABEL MAP - - - Z {resultMappingSlice + 1}/{selectedProject.dicomCount} - -
-
-
- {displayModes.map((mode) => ( - - ))} -
- - -
-
- {latestSegmentationResult && resultDicomPreview ? ( -
- -
-
- 当前切片构件 - {resultVisibleModules.length} 个构件 -
-
- {resultVisibleModules.slice(0, 8).map(({ fileName, name, style }) => ( -
- - {name} - ID {style.partId} -
- ))} -
-
-
- ) : ( -

- {latestSegmentationResult ? '正在载入逆向分割映射视图...' : '暂无逆向分割映射视图'} -

- )} -
-
-
-
-

Slice Navigator

- - {resultMappingSlice + 1} / {Math.max(selectedProject.dicomCount, 1)} - -
-
- - setResultPreviewSlice(Number(event.target.value))} - className="h-2 w-full accent-cyan-400 disabled:opacity-35" - aria-label="项目库逆向分割结果切片导航" - /> - +
+
+

+ + 逆向分割映射视图 +

+
+
+ {displayModes.map((mode) => ( + + ))}
+ +
+ {latestSegmentationResult ? ( + + ) : ( +
+ 暂无逆向分割映射视图。 +
+ )}
diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index d996f84..a99ba96 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -28,9 +28,9 @@ interface ModelPreviewPayload { }; } -type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; -type DicomOpacityLevel = 'low' | 'medium' | 'high'; -type MappingDisplayMode = DicomPreview['mode']; +export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; +export type DicomOpacityLevel = 'low' | 'medium' | 'high'; +export type MappingDisplayMode = DicomPreview['mode']; type ModelPoseKey = keyof ModelPose; type PoseDraftValues = Record; type AxisKey = 'x' | 'y' | 'z'; @@ -46,13 +46,13 @@ type WorkspaceLeaveGuard = () => Promise; const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale']; -const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [ +export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [ { id: 'standard', label: '标准', limit: 16000 }, { id: 'fine', label: '精细', limit: 36000 }, { id: 'ultra', label: '超精细', limit: 72000 }, { id: 'solid', label: '实体', limit: 200000 }, ]; -const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [ +export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [ { id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 }, { id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 }, { id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 }, @@ -393,7 +393,7 @@ function CoordinateAxesInset({ projection }: { projection: AxisProjection }) { ); } -function FusionThreeView({ +export function FusionThreeView({ project, volume, modelPose, @@ -1737,7 +1737,7 @@ function drawVoxelOverlayLayer( return { activeModules, filledPixels, segmentCount, modules }; } -function VoxelizationMappingView({ +export function VoxelizationMappingView({ project, moduleStyles, modelPose, diff --git a/WebSite/src/components/UserManagement.tsx b/WebSite/src/components/UserManagement.tsx index 10405a4..c1abca5 100644 --- a/WebSite/src/components/UserManagement.tsx +++ b/WebSite/src/components/UserManagement.tsx @@ -23,6 +23,7 @@ interface UserFormState { account: string; department: string; password: string; + confirmPassword: string; } const emptyUserForm: UserFormState = { @@ -30,6 +31,7 @@ const emptyUserForm: UserFormState = { account: '', department: '', password: '', + confirmPassword: '', }; export default function UserManagement() { @@ -83,6 +85,7 @@ export default function UserManagement() { account: user.account, department: user.department, password: '', + confirmPassword: '', }); setFormMode('edit'); setMessage(`正在编辑用户:${user.name}`); @@ -95,6 +98,7 @@ export default function UserManagement() { account: user.account, department: user.department, password: '', + confirmPassword: '', }); setFormMode('password'); setMessage(`正在修改密码:${user.name}`); @@ -114,13 +118,18 @@ export default function UserManagement() { const account = form.account.trim(); const department = form.department.trim(); const password = form.password.trim(); + const confirmPassword = form.confirmPassword.trim(); - if (!name || !account || !department || (formMode === 'create' && !password)) { - setMessage('姓名、账号、科室不能为空;新增用户必须填写密码'); + if (!name || !account || !department) { + setMessage('姓名、账号、科室不能为空'); return; } - if (formMode === 'password' && !password) { - setMessage('请输入新密码'); + if ((formMode === 'create' || formMode === 'password') && (!password || !confirmPassword)) { + setMessage('请输入两遍密码'); + return; + } + if ((formMode === 'create' || formMode === 'password') && password !== confirmPassword) { + setMessage('两次输入的密码不一致'); return; } @@ -134,7 +143,7 @@ export default function UserManagement() { name, account, department, - ...(password ? { password } : {}), + ...(formMode === 'password' ? { password } : {}), }); setMessage(formMode === 'password' ? `已更新 ${name} 的密码` : `已更新用户:${name}`); } @@ -326,34 +335,92 @@ export default function UserManagement() {
- setForm((current) => ({ ...current, name: event.target.value }))} - disabled={formMode === 'password'} - placeholder="姓名" - className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60" - /> - setForm((current) => ({ ...current, account: event.target.value }))} - disabled={formMode === 'password'} - placeholder="账号" - className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60" - /> - setForm((current) => ({ ...current, department: event.target.value }))} - disabled={formMode === 'password'} - placeholder="科室" - className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60" - /> - setForm((current) => ({ ...current, password: event.target.value }))} - placeholder={formMode === 'edit' ? '新密码,留空则不修改' : '密码'} - className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500" - /> + {formMode !== 'password' ? ( + <> + + + + {formMode === 'create' && ( + <> + + + + )} + + ) : ( + <> +
+

当前修改对象

+
+ {form.name} + {form.account} + {form.department} +
+
+ + + + )}