2026-05-07-18-42-53 优化可视化工具栏和构件ID联动
This commit is contained in:
@@ -10,6 +10,13 @@ type ProjectStatus = 'pending' | 'completed' | 'processing';
|
|||||||
type DicomPlane = 'axial' | 'sagittal' | 'coronal';
|
type DicomPlane = 'axial' | 'sagittal' | 'coronal';
|
||||||
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
|
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
|
||||||
|
|
||||||
|
interface ModuleStyleRecord {
|
||||||
|
visible: boolean;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
partId: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UserRecord {
|
interface UserRecord {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -33,6 +40,7 @@ interface ProjectRecord {
|
|||||||
maskFormats: Array<'nii' | 'nii.gz'>;
|
maskFormats: Array<'nii' | 'nii.gz'>;
|
||||||
exportedMaskCount: number;
|
exportedMaskCount: number;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
moduleStyles: Record<string, ModuleStyleRecord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionRecord {
|
interface SessionRecord {
|
||||||
@@ -70,6 +78,7 @@ const dicomVolumeCache = new Map<DicomDisplayMode, {
|
|||||||
spacingBetweenSlices: number | null;
|
spacingBetweenSlices: number | null;
|
||||||
}>();
|
}>();
|
||||||
const modelPreviewCache = new Map<string, unknown>();
|
const modelPreviewCache = new Map<string, unknown>();
|
||||||
|
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||||
|
|
||||||
interface DicomAttributes {
|
interface DicomAttributes {
|
||||||
patientName: string;
|
patientName: string;
|
||||||
@@ -146,6 +155,37 @@ function publicSession(state: AppState) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModuleStyle(
|
||||||
|
style: Partial<ModuleStyleRecord> | undefined,
|
||||||
|
index: number,
|
||||||
|
): ModuleStyleRecord {
|
||||||
|
const opacity = typeof style?.opacity === 'number' && Number.isFinite(style.opacity) ? style.opacity : 0.72;
|
||||||
|
const partId = typeof style?.partId === 'number' && Number.isFinite(style.partId) ? style.partId : index + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible: typeof style?.visible === 'boolean' ? style.visible : true,
|
||||||
|
color: typeof style?.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(style.color)
|
||||||
|
? style.color
|
||||||
|
: defaultModuleColors[index % defaultModuleColors.length],
|
||||||
|
opacity: clampNumber(opacity, 0.1, 1),
|
||||||
|
partId: clampNumber(Math.round(partId), 1, 255),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModuleStyles(
|
||||||
|
stlFiles: string[],
|
||||||
|
existing?: Record<string, Partial<ModuleStyleRecord>>,
|
||||||
|
) {
|
||||||
|
return stlFiles.reduce<Record<string, ModuleStyleRecord>>((acc, fileName, index) => {
|
||||||
|
acc[fileName] = normalizeModuleStyle(existing?.[fileName], index);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultProject(): ProjectRecord {
|
function buildDefaultProject(): ProjectRecord {
|
||||||
const stlFiles = listFiles(modelDir, '.stl');
|
const stlFiles = listFiles(modelDir, '.stl');
|
||||||
|
|
||||||
@@ -163,6 +203,7 @@ function buildDefaultProject(): ProjectRecord {
|
|||||||
maskFormats: ['nii', 'nii.gz'],
|
maskFormats: ['nii', 'nii.gz'],
|
||||||
exportedMaskCount: 0,
|
exportedMaskCount: 0,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
moduleStyles: buildModuleStyles(stlFiles),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +221,7 @@ function buildEmptyProject(name: string): ProjectRecord {
|
|||||||
stlFiles: [],
|
stlFiles: [],
|
||||||
maskFormats: ['nii', 'nii.gz'],
|
maskFormats: ['nii', 'nii.gz'],
|
||||||
exportedMaskCount: 0,
|
exportedMaskCount: 0,
|
||||||
|
moduleStyles: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,13 +239,16 @@ function defaultState(): AppState {
|
|||||||
|
|
||||||
function normalizeState(state: AppState): AppState {
|
function normalizeState(state: AppState): AppState {
|
||||||
const defaultProject = buildDefaultProject();
|
const defaultProject = buildDefaultProject();
|
||||||
|
const savedDefaultProject = state.projects?.find((project) => project.id === defaultProject.id);
|
||||||
const customProjects = Array.isArray(state.projects)
|
const customProjects = Array.isArray(state.projects)
|
||||||
? state.projects
|
? state.projects
|
||||||
.filter((project) => project.id !== defaultProject.id)
|
.filter((project) => project.id !== defaultProject.id)
|
||||||
.map((project) => ({
|
.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
|
stlFiles: Array.isArray(project.stlFiles) ? project.stlFiles : [],
|
||||||
exportedMaskCount: project.exportedMaskCount ?? 0,
|
exportedMaskCount: project.exportedMaskCount ?? 0,
|
||||||
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
|
||||||
|
moduleStyles: buildModuleStyles(Array.isArray(project.stlFiles) ? project.stlFiles : [], project.moduleStyles),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -212,8 +257,9 @@ function normalizeState(state: AppState): AppState {
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
...defaultProject,
|
...defaultProject,
|
||||||
name: state.projects?.find((project) => project.id === defaultProject.id)?.name ?? defaultProject.name,
|
name: savedDefaultProject?.name ?? defaultProject.name,
|
||||||
exportedMaskCount: state.projects?.find((project) => project.id === defaultProject.id)?.exportedMaskCount ?? 0,
|
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
|
||||||
|
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
|
||||||
},
|
},
|
||||||
...customProjects,
|
...customProjects,
|
||||||
],
|
],
|
||||||
@@ -1052,6 +1098,28 @@ async function startServer() {
|
|||||||
res.json({ ok: true, deletedId: deleted.id });
|
res.json({ ok: true, deletedId: deleted.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:projectId/module-styles', (req, res) => {
|
||||||
|
const incoming = req.body?.moduleStyles;
|
||||||
|
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
|
||||||
|
res.status(400).json({ message: '构件样式数据无效' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readState();
|
||||||
|
const project = findProject(state, req.params.projectId);
|
||||||
|
if (!project) {
|
||||||
|
res.status(404).json({ message: '项目不存在' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
project.moduleStyles = buildModuleStyles(project.stlFiles, {
|
||||||
|
...(project.moduleStyles ?? {}),
|
||||||
|
...(incoming as Record<string, Partial<ModuleStyleRecord>>),
|
||||||
|
});
|
||||||
|
writeState(state);
|
||||||
|
res.json(project);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/projects/:projectId/dicom-preview', (req, res) => {
|
app.get('/api/projects/:projectId/dicom-preview', (req, res) => {
|
||||||
const project = findProject(readState(), req.params.projectId);
|
const project = findProject(readState(), req.params.projectId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|||||||
@@ -21,20 +21,13 @@ import {
|
|||||||
Upload
|
Upload
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomInfo, DicomPreview, Project } from '../types';
|
import { DicomInfo, DicomPreview, ModuleStyle, Project } from '../types';
|
||||||
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
|
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
|
||||||
|
|
||||||
type Plane = 'axial' | 'sagittal' | 'coronal';
|
type Plane = 'axial' | 'sagittal' | 'coronal';
|
||||||
type DisplayMode = DicomPreview['mode'];
|
type DisplayMode = DicomPreview['mode'];
|
||||||
type SolidityLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
type SolidityLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||||
|
|
||||||
interface ModuleStyle {
|
|
||||||
visible: boolean;
|
|
||||||
color: string;
|
|
||||||
opacity: number;
|
|
||||||
partId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelPose {
|
interface ModelPose {
|
||||||
rotateX: number;
|
rotateX: number;
|
||||||
rotateY: number;
|
rotateY: number;
|
||||||
@@ -708,6 +701,28 @@ export default function ProjectLibrary({
|
|||||||
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
||||||
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
||||||
|
|
||||||
|
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||||
|
visible: fallback?.visible ?? true,
|
||||||
|
color: fallback?.color ?? defaultModuleColors[index % defaultModuleColors.length],
|
||||||
|
opacity: fallback?.opacity ?? 0.72,
|
||||||
|
partId: Math.max(1, Math.min(255, Math.round(fallback?.partId ?? index + 1))),
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
|
||||||
|
setModuleStyles(next);
|
||||||
|
if (!selectedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.updateProjectModuleStyles(selectedProject.id, next)
|
||||||
|
.then((updated) => {
|
||||||
|
setSelectedProject(updated);
|
||||||
|
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setActionMessage(error instanceof Error ? error.message : '构件样式保存失败');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewMode(initialViewMode);
|
setViewMode(initialViewMode);
|
||||||
}, [initialViewMode]);
|
}, [initialViewMode]);
|
||||||
@@ -715,12 +730,7 @@ export default function ProjectLibrary({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next: Record<string, ModuleStyle> = {};
|
const next: Record<string, ModuleStyle> = {};
|
||||||
stlFiles.forEach((fileName, index) => {
|
stlFiles.forEach((fileName, index) => {
|
||||||
next[fileName] = moduleStyles[fileName] ?? {
|
next[fileName] = makeDefaultModuleStyle(index, selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
|
||||||
visible: true,
|
|
||||||
color: defaultModuleColors[index % defaultModuleColors.length],
|
|
||||||
opacity: 0.72,
|
|
||||||
partId: index + 1,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
setModuleStyles(next);
|
setModuleStyles(next);
|
||||||
setSliceIndex(0);
|
setSliceIndex(0);
|
||||||
@@ -773,17 +783,15 @@ export default function ProjectLibrary({
|
|||||||
}, [sliceIndex, sliceTotal]);
|
}, [sliceIndex, sliceTotal]);
|
||||||
|
|
||||||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||||
setModuleStyles(prev => ({
|
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||||||
...prev,
|
const next = {
|
||||||
[fileName]: {
|
...moduleStyles,
|
||||||
visible: true,
|
[fileName]: makeDefaultModuleStyle(index, {
|
||||||
color: '#3b82f6',
|
...(moduleStyles[fileName] ?? selectedProject?.moduleStyles?.[fileName]),
|
||||||
opacity: 0.72,
|
|
||||||
partId: 1,
|
|
||||||
...(prev[fileName] ?? {}),
|
|
||||||
...partial,
|
...partial,
|
||||||
},
|
}),
|
||||||
}));
|
};
|
||||||
|
commitModuleStyles(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateModulePartId = (fileName: string, value: number) => {
|
const updateModulePartId = (fileName: string, value: number) => {
|
||||||
@@ -793,18 +801,14 @@ export default function ProjectLibrary({
|
|||||||
|
|
||||||
const toggleAllModules = () => {
|
const toggleAllModules = () => {
|
||||||
const nextVisible = !allModulesVisible;
|
const nextVisible = !allModulesVisible;
|
||||||
setModuleStyles(prev => {
|
const next = { ...moduleStyles };
|
||||||
const next = { ...prev };
|
|
||||||
stlFiles.forEach((fileName, index) => {
|
stlFiles.forEach((fileName, index) => {
|
||||||
next[fileName] = {
|
next[fileName] = makeDefaultModuleStyle(index, {
|
||||||
|
...(next[fileName] ?? selectedProject?.moduleStyles?.[fileName]),
|
||||||
visible: nextVisible,
|
visible: nextVisible,
|
||||||
color: next[fileName]?.color ?? defaultModuleColors[index % defaultModuleColors.length],
|
|
||||||
opacity: next[fileName]?.opacity ?? 0.72,
|
|
||||||
partId: next[fileName]?.partId ?? index + 1,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
|
commitModuleStyles(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepSlice = (delta: number) => {
|
const stepSlice = (delta: number) => {
|
||||||
@@ -1290,7 +1294,7 @@ export default function ProjectLibrary({
|
|||||||
|
|
||||||
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
|
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-bold text-slate-700">整体位姿</p>
|
<p className="text-xs font-bold text-slate-700">模型位姿</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={resetModelRotationPose}
|
onClick={resetModelRotationPose}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Rotate3d,
|
Rotate3d,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FileJson,
|
|
||||||
Play,
|
Play,
|
||||||
Eye,
|
Eye,
|
||||||
Save,
|
Save,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomFusionVolume, MaskMapping, Project } from '../types';
|
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
|
||||||
import { api, downloadMask } from '../lib/api';
|
import { api, downloadMask } from '../lib/api';
|
||||||
|
|
||||||
interface ModelPose {
|
interface ModelPose {
|
||||||
@@ -37,13 +36,6 @@ interface ModelPreviewPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleStyle {
|
|
||||||
visible: boolean;
|
|
||||||
color: string;
|
|
||||||
opacity: number;
|
|
||||||
partId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||||
|
|
||||||
const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
||||||
@@ -422,7 +414,6 @@ function FusionThreeView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||||
const [sliceStart, setSliceStart] = useState(0);
|
|
||||||
const [sliceEnd, setSliceEnd] = useState(49);
|
const [sliceEnd, setSliceEnd] = useState(49);
|
||||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||||||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||||||
@@ -465,21 +456,36 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||||
|
visible: fallback?.visible ?? true,
|
||||||
|
color: fallback?.color ?? moduleColors[index % moduleColors.length],
|
||||||
|
opacity: fallback?.opacity ?? 0.72,
|
||||||
|
partId: clamp(Math.round(fallback?.partId ?? index + 1), 1, 255),
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
|
||||||
|
setModuleStyles(next);
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.updateProjectModuleStyles(project.id, next)
|
||||||
|
.then((updated) => {
|
||||||
|
setProject(updated);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setFusionError('构件样式保存失败,请稍后重试');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getProject(projectId).then((item) => {
|
api.getProject(projectId).then((item) => {
|
||||||
setProject(item);
|
setProject(item);
|
||||||
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
|
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
|
||||||
setSliceStart(0);
|
|
||||||
setSliceEnd(end);
|
setSliceEnd(end);
|
||||||
setModelPose(defaultModelPose);
|
setModelPose(defaultModelPose);
|
||||||
const nextStyles: Record<string, ModuleStyle> = {};
|
const nextStyles: Record<string, ModuleStyle> = {};
|
||||||
(item.stlFiles ?? []).forEach((fileName, index) => {
|
(item.stlFiles ?? []).forEach((fileName, index) => {
|
||||||
nextStyles[fileName] = {
|
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
|
||||||
visible: true,
|
|
||||||
color: moduleColors[index % moduleColors.length],
|
|
||||||
opacity: 0.72,
|
|
||||||
partId: index + 1,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
setModuleStyles(nextStyles);
|
setModuleStyles(nextStyles);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@@ -491,8 +497,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project?.dicomCount) return;
|
if (!project?.dicomCount) return;
|
||||||
const maxSlice = Math.max(project.dicomCount - 1, 0);
|
const maxSlice = Math.max(project.dicomCount - 1, 0);
|
||||||
const safeStart = clamp(Math.min(sliceStart, sliceEnd), 0, maxSlice);
|
const safeStart = 0;
|
||||||
const safeEnd = clamp(Math.max(sliceStart, sliceEnd), safeStart, maxSlice);
|
const safeEnd = clamp(sliceEnd, 0, maxSlice);
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setFusionError('');
|
setFusionError('');
|
||||||
api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft')
|
api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft')
|
||||||
@@ -503,7 +509,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
});
|
});
|
||||||
}, 180);
|
}, 180);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
|
}, [project?.id, project?.dicomCount, sliceEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRegistering && progress < 100) {
|
if (isRegistering && progress < 100) {
|
||||||
@@ -525,17 +531,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||||
setModuleStyles((current) => ({
|
const stlFiles = project?.stlFiles ?? [];
|
||||||
...current,
|
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||||||
[fileName]: {
|
const next = {
|
||||||
visible: true,
|
...moduleStyles,
|
||||||
color: '#3b82f6',
|
[fileName]: makeDefaultModuleStyle(index, {
|
||||||
opacity: 0.72,
|
...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]),
|
||||||
partId: 1,
|
|
||||||
...(current[fileName] ?? {}),
|
|
||||||
...partial,
|
...partial,
|
||||||
},
|
}),
|
||||||
}));
|
};
|
||||||
|
commitModuleStyles(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateModulePartId = (fileName: string, value: number) => {
|
const updateModulePartId = (fileName: string, value: number) => {
|
||||||
@@ -560,8 +565,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||||
const displayStart = Math.min(sliceStart, sliceEnd);
|
const displayStart = 0;
|
||||||
const displayEnd = Math.max(sliceStart, sliceEnd);
|
const displayEnd = clamp(sliceEnd, 0, maxSlice);
|
||||||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -633,82 +638,29 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
||||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
||||||
<span className="text-[10px] font-mono text-blue-600">
|
<span className="text-[10px] font-mono text-blue-600">
|
||||||
{displayStart + 1} - {displayEnd + 1}
|
{displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<label className="grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
|
||||||
<label className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
显示范围
|
||||||
起点
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max={maxSlice}
|
max={maxSlice}
|
||||||
value={sliceStart}
|
value={displayEnd}
|
||||||
onChange={(event) => setSliceStart(Number(event.target.value))}
|
|
||||||
className="accent-blue-600"
|
|
||||||
/>
|
|
||||||
<span className="text-right font-mono">{sliceStart + 1}</span>
|
|
||||||
</label>
|
|
||||||
<label className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
|
||||||
终点
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={maxSlice}
|
|
||||||
value={sliceEnd}
|
|
||||||
onChange={(event) => setSliceEnd(Number(event.target.value))}
|
onChange={(event) => setSliceEnd(Number(event.target.value))}
|
||||||
className="accent-blue-600"
|
className="accent-blue-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-right font-mono">{sliceEnd + 1}</span>
|
<span className="text-right font-mono">{displayEnd + 1} 张</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-[10px] leading-5 text-slate-400">
|
<p className="mt-3 text-[10px] leading-5 text-slate-400">
|
||||||
DICOM 以黑色体数据长方体显示,表面贴附当前范围的最后一张 CT 切片。
|
默认从第 1 张开始显示,滑条控制融合体使用到第几张切片。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<p className="text-xs font-bold text-slate-700">模型位姿</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setModelPose(defaultModelPose)}
|
|
||||||
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
重置模型位姿
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
|
|
||||||
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
|
|
||||||
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ },
|
|
||||||
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
|
|
||||||
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
|
|
||||||
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
|
|
||||||
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
|
|
||||||
].map((item) => (
|
|
||||||
<label key={item.key} className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
|
||||||
{item.label}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={item.min}
|
|
||||||
max={item.max}
|
|
||||||
step={item.step}
|
|
||||||
value={item.value}
|
|
||||||
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
|
|
||||||
className="accent-blue-600"
|
|
||||||
/>
|
|
||||||
<span className="text-right font-mono">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
|
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
|
||||||
@@ -740,7 +692,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">整体位姿</p>
|
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型位姿</p>
|
||||||
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
|
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
|
||||||
<Save size={12} />
|
<Save size={12} />
|
||||||
保存
|
保存
|
||||||
@@ -765,6 +717,31 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
>
|
>
|
||||||
重置默认位姿
|
重置默认位姿
|
||||||
</button>
|
</button>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{[
|
||||||
|
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
|
||||||
|
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
|
||||||
|
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ },
|
||||||
|
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
|
||||||
|
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
|
||||||
|
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
|
||||||
|
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
|
||||||
|
].map((item) => (
|
||||||
|
<label key={item.key} className="grid grid-cols-[44px_1fr_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||||
|
{item.label}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={item.min}
|
||||||
|
max={item.max}
|
||||||
|
step={item.step}
|
||||||
|
value={item.value}
|
||||||
|
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
|
||||||
|
className="accent-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="text-right font-mono">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -831,16 +808,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 bg-slate-900 rounded-2xl shrink-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<FileJson size={12} className="text-blue-400" />
|
|
||||||
<span className="text-[10px] font-bold text-white/50 uppercase tracking-widest">Metadata</span>
|
|
||||||
</div>
|
|
||||||
<pre className="text-[9px] text-blue-300/70 font-mono overflow-hidden">
|
|
||||||
{`{ project: "${project?.id ?? projectId}", display: "${selectedDisplay.label}" }`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DicomFusionVolume, DicomInfo, DicomPreview, OverviewSummary, Project, SessionState, UserRecord } from '../types';
|
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, OverviewSummary, Project, SessionState, UserRecord } from '../types';
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
@@ -50,6 +50,11 @@ export const api = {
|
|||||||
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
|
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
updateProjectModuleStyles: (projectId: string, moduleStyles: Record<string, ModuleStyle>) =>
|
||||||
|
request<Project>(`/api/projects/${projectId}/module-styles`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ moduleStyles }),
|
||||||
|
}),
|
||||||
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
|
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
|
||||||
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
|
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
|
||||||
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
|
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export interface Project {
|
|||||||
maskFormats?: Array<'nii' | 'nii.gz'>;
|
maskFormats?: Array<'nii' | 'nii.gz'>;
|
||||||
exportedMaskCount?: number;
|
exportedMaskCount?: number;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
moduleStyles?: Record<string, ModuleStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleStyle {
|
||||||
|
visible: boolean;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
partId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaskMapping {
|
export interface MaskMapping {
|
||||||
|
|||||||
79
工程分析/实现方案-2026-05-07-18-42-53.md
Normal file
79
工程分析/实现方案-2026-05-07-18-42-53.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 实现方案 - 2026-05-07-18-42-53
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
1. 移除逆向工作区可视化工具栏底部 Metadata 信息块。
|
||||||
|
2. 将 DICOM 融合切片范围控制改为融合视角下方单滑条,滑条值表示展示到第几张切片。
|
||||||
|
3. 将整体位姿控制区统一命名为“模型位姿”,并保留现有位姿保存、选择、重置和调节能力。
|
||||||
|
4. 将构件 ID 从纯组件状态提升为后端项目状态字段,使项目库 3D 模型与逆向工作区可视化工具栏联动。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/server.ts`
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
### 构件 ID 联动
|
||||||
|
|
||||||
|
- 在后端项目数据中增加 `moduleStyles` 字段,存储每个 STL 文件的:
|
||||||
|
- `visible`
|
||||||
|
- `color`
|
||||||
|
- `opacity`
|
||||||
|
- `partId`
|
||||||
|
- 项目库与逆向工作区读取项目时都使用同一个 `project.moduleStyles` 初始化。
|
||||||
|
- 任一页面修改 ID、颜色、透明度、显示状态时,通过 `PATCH /api/projects/:projectId/module-styles` 写回后端。
|
||||||
|
- 前端本地状态更新后同步调用 API,保证跨页面和跨浏览器一致。
|
||||||
|
|
||||||
|
### 融合切片范围
|
||||||
|
|
||||||
|
- 保留后端接口需要的 `start/end` 参数。
|
||||||
|
- 前端只展示一个滑条 `sliceEnd`,内部固定 `start = 0`,`end = sliceEnd`。
|
||||||
|
- 滑条放在影像与模型融合视角下方,便于操作与结果对应。
|
||||||
|
|
||||||
|
### 位姿模块整合
|
||||||
|
|
||||||
|
- 将工具栏内的“整体位姿”标题改为“模型位姿”。
|
||||||
|
- 保留模型显示、位姿保存/选择、旋转、平移、缩放与重置功能,但减少重复命名。
|
||||||
|
|
||||||
|
### UI 清理
|
||||||
|
|
||||||
|
- 删除可视化工具栏底部 Metadata 代码块。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
1. 后端读取或重置项目时生成默认 `moduleStyles`,`partId` 默认从 1 到 N。
|
||||||
|
2. 项目库修改构件 ID 后调用 API 保存。
|
||||||
|
3. 逆向工作区加载项目时读取最新 `moduleStyles`。
|
||||||
|
4. 逆向工作区修改构件 ID 后同样调用 API 保存。
|
||||||
|
5. 融合视角滑条变化后触发 `dicom-fusion-volume?start=0&end=<sliceEnd>` 重新加载体数据。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 对旧 `state.json` 做兼容:项目没有 `moduleStyles` 时由后端按 STL 列表自动补齐。
|
||||||
|
- 若新增 API 出现问题,前端仍可回退为本地状态,但 ID 联动会退化。
|
||||||
|
- 所有修改集中在项目状态、项目库与逆向工作区,回滚本次 commit 即可恢复。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- `server.ts`:补齐项目 moduleStyles、增加更新 API。
|
||||||
|
- `types.ts`:补充项目 moduleStyles 类型。
|
||||||
|
- `ProjectLibrary.tsx`:读取/保存共享 moduleStyles。
|
||||||
|
- `ReverseWorkspace.tsx`:读取/保存共享 moduleStyles,调整切片范围 UI,删除 Metadata,重命名位姿模块。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 已新增共享构件样式数据 `moduleStyles`,包含 `visible/color/opacity/partId`。
|
||||||
|
- 已新增 `PATCH /api/projects/:projectId/module-styles`,用于项目库与逆向工作区同步构件 ID、颜色、透明度、显示隐藏状态。
|
||||||
|
- 已将项目库和逆向工作区的构件 ID 读写改为同一后端项目状态。
|
||||||
|
- 已删除逆向工作区可视化工具栏下方 `Metadata` 信息块。
|
||||||
|
- 已将融合视角下方 DICOM 切片范围改为单一进度条,内部固定从第 1 张开始显示。
|
||||||
|
- 已将可视化工具栏中的“整体位姿”改为“模型位姿”,并合并位姿保存、选择和各项旋转/平移/缩放控制。
|
||||||
|
- 已同步将项目库 3D 模型中的“整体位姿”标题改为“模型位姿”。
|
||||||
46
工程分析/测试方案-2026-05-07-18-42-53.md
Normal file
46
工程分析/测试方案-2026-05-07-18-42-53.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 测试方案 - 2026-05-07-18-42-53
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||||
|
- 执行 `npm run build`,确认生产构建通过。
|
||||||
|
|
||||||
|
## 单元或集成测试
|
||||||
|
|
||||||
|
- 使用现有 API 和页面构建流程做集成验证:
|
||||||
|
- `GET /api/projects` 返回项目数据且包含共享 `moduleStyles`。
|
||||||
|
- `PATCH /api/projects/:projectId/module-styles` 可保存构件 ID。
|
||||||
|
- 重新读取项目后构件 ID 保持一致。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
1. 项目库 3D 模型构件层级修改 ID 后,进入逆向工作区可视化工具栏显示同一 ID。
|
||||||
|
2. 逆向工作区修改 ID 后,返回项目库 3D 模型显示同一 ID。
|
||||||
|
3. 可视化工具栏不再显示 Metadata。
|
||||||
|
4. 融合视角下方只有一个 DICOM 切片范围滑条,不再暴露起点/终点输入。
|
||||||
|
5. 位姿控制区域标题显示“模型位姿”。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 切片范围滑条最小值不小于 1,最大值不超过 DICOM 总切片数。
|
||||||
|
- API 请求仍使用合理 `start/end`,避免空体数据。
|
||||||
|
- 构件 ID 限定为 `1~255`,防止与背景 `0` 冲突。
|
||||||
|
|
||||||
|
## 回归风险
|
||||||
|
|
||||||
|
- 共享 moduleStyles 可能影响已有颜色、透明度、显示隐藏控制。
|
||||||
|
- 融合体请求频繁变化可能导致加载状态更新较频繁。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- `npm run lint`:通过。
|
||||||
|
- `npm run build`:通过,仅保留 Vite 大 chunk 体积提醒。
|
||||||
|
- 重新部署:已通过 `tmux` 重启 `revoxelseg-dicom` 服务,运行在 `http://0.0.0.0:4000/`。
|
||||||
|
- `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。
|
||||||
|
- `GET /api/projects/head-ct-demo`:返回默认项目,包含 9 个 STL 构件的 `moduleStyles`。
|
||||||
|
- `PATCH /api/projects/head-ct-demo/module-styles`:可修改构件 ID 和可见性。
|
||||||
|
- 构件 ID 边界:提交 `partId=0` 后服务端返回 `partId=1`,符合不可修改为 0 的约束。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -667,3 +667,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
构件层级状态应作为统一结构在不同页面复用;任何新增构件字段都要检查初始化、单项更新、全局更新和默认 fallback 四条路径;逆向工作区的可视化控制应与项目库模型页保持一致,避免用户在两个页面学两套交互。
|
构件层级状态应作为统一结构在不同页面复用;任何新增构件字段都要检查初始化、单项更新、全局更新和默认 fallback 四条路径;逆向工作区的可视化控制应与项目库模型页保持一致,避免用户在两个页面学两套交互。
|
||||||
|
|
||||||
|
## 2026-05-07-18-42-53 构件 ID 跨页面联动
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
项目库 3D 模型和逆向工作区可视化工具栏都能修改构件 ID,但两边原先分别维护本地状态,无法保证 ID、显示隐藏、颜色和透明度一致。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
构件样式属于项目级配置,但旧实现只保存在 React 组件 state 中,页面切换或不同浏览器访问时不会自动共享。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
在项目数据中新增 `moduleStyles` 字段,并新增 `PATCH /api/projects/:projectId/module-styles` 接口。项目库和逆向工作区都读写同一后端状态,服务端统一补齐默认值并将 `partId` 限制在 `1~255`。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
凡是需要跨页面、跨浏览器一致的配置项,优先放入后端项目状态;前端可以保留本地 state 提升响应速度,但必须通过 API 持久化并从项目数据恢复。
|
||||||
|
|||||||
43
工程分析/需求分析-2026-05-07-18-42-53.md
Normal file
43
工程分析/需求分析-2026-05-07-18-42-53.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 需求分析 - 2026-05-07-18-42-53
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
本次需要优化逆向工作区与项目库之间的可视化控制体验:
|
||||||
|
|
||||||
|
1. 删除可视化工具栏下方的 `Metadata` 信息块。
|
||||||
|
2. DICOM 切片范围不再使用起点/终点输入,改为影像与模型融合视角下方的一条进度条控制切片范围。
|
||||||
|
3. 将模型位姿模块与整体位姿整合,统一命名为“模型位姿”。
|
||||||
|
4. 项目库 3D 模型中的构件 ID 与可视化工具栏中的构件 ID 保持联动。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 让影像与模型融合视角更聚焦实际操作,减少无效信息。
|
||||||
|
- 降低 DICOM 切片范围操作复杂度,使用单进度条表达当前展示范围。
|
||||||
|
- 统一模型位姿概念,避免“模型位姿”和“整体位姿”并存造成理解负担。
|
||||||
|
- 保证同一项目的构件 ID 在项目库和逆向工作区中一致。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:用户在项目库或逆向工作区中修改构件 ID、切换切片范围、调整模型位姿。
|
||||||
|
- 输出:
|
||||||
|
- 可视化工具栏不再展示 Metadata。
|
||||||
|
- 融合视角下方展示 DICOM 切片范围滑条。
|
||||||
|
- 位姿控制区域统一显示为“模型位姿”。
|
||||||
|
- 项目库和可视化工具栏中的构件 ID 双向同步。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- 可能涉及后端项目状态 API 与类型定义,用于持久化或共享构件 ID。
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 如果构件 ID 只保存在组件本地状态,项目库和逆向工作区可能仍不同步。
|
||||||
|
- 切片范围由两个输入改为一个滑条后,需要保持后端融合体数据请求仍有合理 `start/end`。
|
||||||
|
- 位姿模块重命名和合并时需要避免破坏现有旋转、平移、缩放、保存位姿功能。
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
用户已明确本次需求分析、实现方案、测试方案、执行修改均不需要二次人工确认,因此按默认执行确认规则直接实施。
|
||||||
Reference in New Issue
Block a user