2026-05-20-15-20-15 项目库复用逆向视图与用户弹窗调整

This commit is contained in:
2026-05-20 15:32:25 +08:00
parent fd7f3387f7
commit 1f353e97c0
7 changed files with 396 additions and 179 deletions

View File

@@ -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<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [resultDicomPreview, setResultDicomPreview] = useState<DicomPreview | null>(null);
const [resultFusionVolume, setResultFusionVolume] = useState<DicomFusionVolume | null>(null);
const [resultFusionError, setResultFusionError] = useState('');
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(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,52 +1566,49 @@ export default function ProjectLibrary({
)}
{viewMode === 'mask' && (
<div className="h-full grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="grid min-h-0 grid-cols-1 gap-4 lg:grid-cols-2">
<div className="relative min-h-[520px] overflow-hidden rounded-2xl border border-slate-900 bg-slate-950 text-white shadow-sm">
<div className="absolute left-4 top-4 z-10 rounded-xl border border-white/10 bg-black/45 px-3 py-2 backdrop-blur">
<p className="text-sm font-bold"></p>
<p className="mt-1 font-mono text-[10px] text-white/45">
{latestSegmentationResult ? `模型显示 精细 · 融合显示 DICOM 高 · Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'}
</p>
</div>
<div className="h-full grid grid-cols-1 gap-6 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_340px]">
<div className="min-h-[560px]">
{latestSegmentationResult ? (
<NativeStlViewer
projectId={selectedProject.id}
files={stlFiles}
styles={latestResultStyles}
detailLimit={resultFineDetailLimit}
<FusionThreeView
project={selectedProject}
volume={resultFusionVolume}
modelPose={latestResultPose}
moduleStyles={latestResultStyles}
detailLimit={resultDisplayOption.limit}
solidMode={false}
pose={latestResultPose}
onPoseChange={setResultPose}
dicomOpacity={resultDicomOpacity}
showBounds={latestSegmentationResult.showBounds ?? true}
cutEnabled={latestSegmentationResult.cutEnabled ?? false}
cutStart={resultCutStart}
cutEnd={resultCutEnd}
/>
) : (
<div className="flex h-full items-center justify-center px-8 text-center text-sm font-bold text-white/35">
<div className="flex h-full min-h-[560px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
</div>
)}
{resultFusionError && (
<p className="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-bold text-amber-700">
{resultFusionError}
</p>
)}
</div>
<div className="relative flex min-h-[520px] flex-col overflow-hidden rounded-2xl border border-slate-900 bg-black text-white shadow-sm">
<div className="relative min-h-0 flex-1">
<div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2">
<span className="rounded-lg border border-white/10 bg-black/50 px-2.5 py-1 font-mono text-[10px] font-bold text-white/70 backdrop-blur">
BASE DICOM
</span>
<span className="rounded-lg border border-cyan-300/40 bg-cyan-400/15 px-2.5 py-1 font-mono text-[10px] font-bold text-cyan-100 backdrop-blur">
OVERLAY LABEL MAP
</span>
<span className="rounded-lg border border-white/10 bg-black/50 px-2.5 py-1 font-mono text-[10px] font-bold text-white/70 backdrop-blur">
Z {resultMappingSlice + 1}/{selectedProject.dicomCount}
</span>
</div>
<div className="absolute right-4 top-4 z-10 flex flex-wrap justify-end gap-1.5">
<div className="flex rounded-lg border border-white/10 bg-black/50 p-1 backdrop-blur">
<div className="min-h-[560px]">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-bold text-slate-700">
<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">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setResultDisplayMode(mode.id)}
className={`rounded-md px-2 py-1 text-[9px] font-bold transition ${resultDisplayMode === mode.id ? 'bg-cyan-400 text-slate-950' : 'text-white/55 hover:text-white'}`}
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
resultDisplayMode === mode.id ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{mode.label}
</button>
@@ -1607,83 +1616,37 @@ export default function ProjectLibrary({
</div>
<button
onClick={() => setResultRotation((value) => (value + 270) % 360)}
className="rounded-lg border border-white/10 bg-black/50 px-2 py-1 text-[9px] font-bold text-white/70 backdrop-blur hover:text-white"
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={12} />
<RotateCcw size={14} />
</button>
<button
onClick={() => setResultRotation((value) => (value + 90) % 360)}
className="rounded-lg border border-white/10 bg-black/50 px-2 py-1 text-[9px] font-bold text-white/70 backdrop-blur hover:text-white"
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={12} />
<RotateCw size={14} />
</button>
</div>
<div className="absolute inset-0 flex items-center justify-center p-8">
{latestSegmentationResult && resultDicomPreview ? (
<div className="relative flex h-full w-full items-center justify-center">
<DicomCanvas preview={resultDicomPreview} rotation={resultRotation} />
<div className="pointer-events-none absolute inset-x-10 bottom-10 rounded-2xl border border-white/10 bg-black/55 p-3 backdrop-blur">
<div className="mb-2 flex items-center justify-between gap-3 text-[11px] font-bold text-white/70">
<span></span>
<span className="font-mono text-cyan-100">{resultVisibleModules.length} </span>
</div>
<div className="grid grid-cols-2 gap-2">
{resultVisibleModules.slice(0, 8).map(({ fileName, name, style }) => (
<div key={fileName} className="flex min-w-0 items-center gap-2 rounded-lg bg-white/5 px-2 py-1">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
<span className="min-w-0 flex-1 truncate text-[10px] font-bold text-white/75">{name}</span>
<span className="font-mono text-[9px] text-white/45">ID {style.partId}</span>
</div>
))}
</div>
</div>
</div>
) : (
<p className="text-center text-sm font-bold text-white/30">
{latestSegmentationResult ? '正在载入逆向分割映射视图...' : '暂无逆向分割映射视图'}
</p>
)}
</div>
</div>
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Slice Navigator</p>
<span className="font-mono text-[10px] font-bold text-cyan-100">
{resultMappingSlice + 1} / {Math.max(selectedProject.dicomCount, 1)}
</span>
</div>
<div className="grid grid-cols-[28px_1fr_28px] items-center gap-3">
<button
onClick={() => setResultPreviewSlice((value) => Math.max(0, value - 1))}
disabled={!latestSegmentationResult || resultMappingSlice <= 0}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="上一层"
>
<ChevronLeft size={15} />
</button>
<input
type="range"
min="0"
max={resultMaxSlice}
value={resultMappingSlice}
disabled={!latestSegmentationResult}
onChange={(event) => setResultPreviewSlice(Number(event.target.value))}
className="h-2 w-full accent-cyan-400 disabled:opacity-35"
aria-label="项目库逆向分割结果切片导航"
{latestSegmentationResult ? (
<VoxelizationMappingView
project={selectedProject}
moduleStyles={latestResultStyles}
modelPose={latestResultPose}
detailLimit={resultDisplayOption.limit}
slice={resultMappingSlice}
totalSlices={selectedProject.dicomCount}
onSliceChange={setResultPreviewSlice}
displayMode={resultDisplayMode}
rotation={resultRotation}
/>
<button
onClick={() => setResultPreviewSlice((value) => Math.min(resultMaxSlice, value + 1))}
disabled={!latestSegmentationResult || resultMappingSlice >= resultMaxSlice}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="下一层"
>
<ChevronRight size={15} />
</button>
</div>
</div>
) : (
<div className="flex h-full min-h-[520px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
</div>
)}
</div>
<div className="flex flex-col gap-4">

View File

@@ -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<ModelPoseKey, string>;
type AxisKey = 'x' | 'y' | 'z';
@@ -46,13 +46,13 @@ type WorkspaceLeaveGuard = () => Promise<boolean>;
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,

View File

@@ -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() {
</button>
</div>
<div className="space-y-3">
{formMode !== 'password' ? (
<>
<label className="block">
<span className="mb-1.5 block text-xs font-bold text-slate-500"></span>
<input
value={form.name}
onChange={(event) => 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"
placeholder="请输入用户姓名"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-bold text-slate-500"></span>
<input
value={form.account}
onChange={(event) => 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"
placeholder="请输入登录账号"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-bold text-slate-500"></span>
<input
value={form.department}
onChange={(event) => 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"
placeholder="请输入所属科室"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
/>
</label>
{formMode === 'create' && (
<>
<label className="block">
<span className="mb-1.5 block text-xs font-bold text-slate-500"></span>
<input
type="password"
value={form.password}
onChange={(event) => 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"
placeholder="请输入初始密码"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-bold text-slate-500"></span>
<input
type="password"
value={form.confirmPassword}
onChange={(event) => setForm((current) => ({ ...current, confirmPassword: event.target.value }))}
placeholder="请再次输入初始密码"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/20"
/>
</label>
</>
)}
</>
) : (
<>
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
<p className="text-xs font-bold text-slate-400"></p>
<div className="mt-2 grid gap-2 text-sm font-semibold text-slate-700">
<span>{form.name}</span>
<span className="font-mono text-slate-500">{form.account}</span>
<span className="text-slate-500">{form.department}</span>
</div>
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-bold text-slate-500"></span>
<input
type="password"
value={form.password}
onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
placeholder="请输入新密码"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-500/20"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-bold text-slate-500"></span>
<input
type="password"
value={form.confirmPassword}
onChange={(event) => setForm((current) => ({ ...current, confirmPassword: event.target.value }))}
placeholder="请再次输入新密码"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-500/20"
/>
</label>
</>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<button onClick={closeForm} className="rounded-xl px-4 py-2 text-sm font-bold text-slate-600 hover:bg-slate-100">

View File

@@ -0,0 +1,55 @@
# 实现方案:复用逆向工作区真实视图与拆分密码编辑
实现方案文档路径:`工程分析/实现方案-2026-05-20-15-20-15.md`
## 修改目标
让项目库逆向分割结果页直接复用逆向工作区的三维融合和二维映射视图,实现显示效果一致;同时细化用户编辑弹窗,分离资料编辑和密码修改。
## 涉及路径
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/UserManagement.tsx`
- `工程分析/需求分析-2026-05-20-15-20-15.md`
- `工程分析/实现方案-2026-05-20-15-20-15.md`
- `工程分析/测试方案-2026-05-20-15-20-15.md`
- `工程分析/经验记录.md`
## 技术路线
1. 将逆向工作区内部 `FusionThreeView``VoxelizationMappingView``displayOptions``dicomOpacityOptions` 导出为可复用对象。
2. 项目库逆向结果页新增 fusion volume 状态和加载逻辑,按保存结果的切片范围请求 `api.getDicomFusionVolume`
3. 项目库结果页替换简化 `NativeStlViewer` 和静态 DICOM Canvas改为直接渲染 `FusionThreeView``VoxelizationMappingView`
4. 保留项目库二维映射视图的 DICOM 模式、旋转和 Slice Navigator 控制,将状态传入复用组件。
5. 用户管理表单增加字段标签;编辑模式移除密码输入框;密码模式使用新密码/确认新密码双输入校验。
6. 运行类型检查、构建、部署和接口验证。
## 执行步骤
- 阅读当前项目库和逆向工作区视图组件。
- 调整逆向工作区组件导出。
- 更新项目库视图导入、fusion volume 加载和 JSX 结构。
- 更新用户管理弹窗表单和校验。
- 更新测试记录、经验记录。
- 精确暂存、提交并推送 Gitea。
## 兼容性与回滚方案
- 如果项目库 fusion volume 加载失败,复用组件仍显示原有错误/空状态。
- 逆向工作区原组件调用方式保持不变,只增加导出能力。
- 用户密码后端接口保持不变,前端只增加确认密码约束。
## 预计文件变更
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/UserManagement.tsx`
- 本轮工程分析文档与 `工程分析/经验记录.md`
## 提交与部署策略
- 暂存本轮相关代码和工程分析文档。
- 不提交软著材料、历史删除状态、运行态导出文件。
- commit message 包含 `2026-05-20-15-20-15`
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom` 并验证服务。

View File

@@ -0,0 +1,57 @@
# 测试方案:项目库真实视图复用与用户弹窗校验
测试方案文档路径:`工程分析/测试方案-2026-05-20-15-20-15.md`
## 静态检查
- 确认项目库逆向结果页导入并使用逆向工作区 `FusionThreeView`
- 确认项目库逆向结果页导入并使用逆向工作区 `VoxelizationMappingView`
- 确认编辑用户模式没有密码输入框。
- 确认修改密码模式包含两次密码输入和一致性校验。
- 确认每个用户表单输入框上方都有中文提示标签。
## 构建检查
-`WebSite/` 执行 `npm run lint`
-`WebSite/` 执行 `npm run build`
## 关键业务场景验证
- 项目库逆向分割结果未保存时仍为空状态。
- 保存结果后项目库左侧/中间视图与逆向工作区同款组件一致。
- 项目库二维映射视图可切换 DICOM 模式、旋转、拖动 Slice Navigator。
- 编辑用户只能改姓名、账号、科室。
- 修改密码必须输入两遍且一致。
## 医学影像数据相关边界验证
- 不修改 DICOM/STL 原始数据。
- 项目库真实视图仅复用已有 DICOM fusion volume 和 STL 映射预览能力。
- 未保存结果不显示伪造分割。
## 部署验证
- 验证 `http://127.0.0.1:4000/api/health`
- 验证 `http://127.0.0.1:4000/` 返回 200。
## Git/Gitea 备份验证
- commit message 包含 `2026-05-20-15-20-15`
- 推送 Gitea 成功后记录 commit。
- 确认未暂存软著材料、历史删除状态和无关运行态文件。
## 风险与回归关注点
- 导出组件后 TypeScript 类型可能暴露,需要保持导出类型简单。
- 项目库加载 fusion volume 增加请求量,应仅在逆向结果页且有保存结果时请求。
- 用户密码修改的二次确认需阻断空值和不一致值。
## 执行结果
- `npm run lint`通过TypeScript 无报错。
- `npm run build`通过Vite 完成生产构建;仅保留当前项目已有的大 chunk 体积提示。
- 静态确认:项目库逆向结果页已直接渲染逆向工作区导出的 `FusionThreeView``VoxelizationMappingView`
- 静态确认:编辑用户弹窗只保留姓名、账号、科室;新增用户与修改密码均包含两次密码输入。
- 部署验证:已重建 `tmux` 会话 `revoxelseg-dicom`,执行 `npm run serve -- --host 0.0.0.0 --port 4000`
- `curl -fsS http://127.0.0.1:4000/api/health`:通过,返回 `{"ok":true,"service":"revoxelseg-dicom",...}`
- `curl -I -fsS http://127.0.0.1:4000/`:通过,返回 `HTTP/1.1 200 OK`

View File

@@ -1279,3 +1279,21 @@ C. 解决问题方案
D. 后续如何避免问题
凡是“离开前确认保存”类交互,都要先定义脏状态快照,不能只按页面离开事件弹窗。后台管理按钮必须有明确接口、成功/失败反馈和权限边界;至少要覆盖新增、编辑、删除自己、删除其他用户四个验证场景。
## 2026-05-20-15-20-15 项目库结果视图应复用逆向工作区真实组件
A. 具体问题
用户指出项目库中的“影像与模型融合视角”和“逆向分割映射视图”仍像简化预览,不是逆向工作区里的真实效果;同时编辑用户弹窗字段缺少标签,编辑资料时仍出现密码修改入口,容易误解为保存资料会顺带改密。
B. 产生问题原因
项目库结果页此前为节省加载量,使用了单独的 STL 预览和 DICOM Canvas 组合,导致与逆向工作区的三维融合、二维映射算法和视觉层次不一致。用户管理表单把创建、编辑和改密共用一套输入框,密码字段只通过占位提示表达语义,交互边界不够明确。
C. 解决问题方案
将逆向工作区的 `FusionThreeView``VoxelizationMappingView`、显示精度和 DICOM 融合强度配置导出给项目库复用;项目库逆向结果页在有保存结果时加载 fusion volume并直接渲染同款三维融合视图和二维映射视图。用户弹窗改为每个输入框都有字段标签编辑模式只保留姓名、账号、科室新增和修改密码模式均要求输入两遍密码并做一致性校验。
D. 后续如何避免问题
凡是用户要求两个页面“效果一致”,优先抽取或导出已有真实组件,不再维护第二套近似 UI。涉及密码、权限、删除等高风险管理操作时必须把资料编辑和凭据修改拆成不同入口并通过显式标签、校验和反馈减少误操作。

View File

@@ -0,0 +1,57 @@
# 需求分析:项目库复用逆向工作区视图与用户弹窗细化
开始时间:`2026-05-20-15-20-15`
## 原始需求摘要
用户要求继续修改程序:
1. 项目库中的“影像与模型融合视角”需要使用逆向工作区“影像与模型融合视角”的真实效果,而不是简化 STL 模型预览。
2. 项目库中的“逆向分割映射视图”需要使用逆向工作区中“逆向分割映射视图”的真实效果,而不是静态 DICOM 预览加构件提示。
3. 编辑用户弹窗中每个输入框上方需要提示标签。
4. 编辑用户时不要出现修改密码输入框;只能点击右侧“修改密码”按钮后修改密码,且密码需要输入两遍。
## 业务目标
- 项目库保存结果复核页与逆向工作区在视觉和算法展示上保持一致,避免用户看到不同表现误判结果。
- 用户资料编辑和密码修改职责分离,减少误改密码风险。
- 表单控件增加明确字段标签,降低系统管理操作误解。
## 输入与输出
输入:
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/UserManagement.tsx`
输出:
- `FusionThreeView``VoxelizationMappingView` 可被项目库复用。
- 项目库逆向分割结果页直接使用逆向工作区同款三维融合视图与二维映射视图。
- 编辑用户表单只包含姓名、账号、科室。
- 修改密码表单包含新密码和确认新密码,且两次输入一致才允许保存。
## 影响范围
- 逆向工作区组件导出方式。
- 项目库逆向分割结果布局、数据加载和组件复用。
- 系统管理用户弹窗表单。
## 关键约束
- 不复制出第二套近似视图逻辑,优先复用逆向工作区组件。
- 项目库中没有保存结果时仍保持空状态,不伪造结果。
- 密码修改必须通过右侧 Key 按钮进入独立流程。
- 不改变用户管理后端接口语义,只调整前端表单校验。
## 风险点
- 逆向工作区组件导出后需要保持原页面使用不受影响。
- 项目库复用融合视图时需要加载 `dicom-fusion-volume`,否则三维 DICOM 层不会出现。
- 密码二次输入校验如果只在前端做,后端仍可接受单密码;本轮前端按需求阻断误操作。
## 默认假设
- “中间的逆向分割映射视图”指项目库逆向分割结果页中的二维映射预览区域。
- 项目库复用逆向工作区视图时保留右侧导出面板,结果视图区域按左右两个同款视图展示。