2026-05-07-18-42-53 优化可视化工具栏和构件ID联动

This commit is contained in:
2026-05-07 18:55:14 +08:00
parent 796619632b
commit 97edf35bd0
9 changed files with 393 additions and 155 deletions

View File

@@ -10,6 +10,13 @@ type ProjectStatus = 'pending' | 'completed' | 'processing';
type DicomPlane = 'axial' | 'sagittal' | 'coronal';
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
interface ModuleStyleRecord {
visible: boolean;
color: string;
opacity: number;
partId: number;
}
interface UserRecord {
id: number;
name: string;
@@ -33,6 +40,7 @@ interface ProjectRecord {
maskFormats: Array<'nii' | 'nii.gz'>;
exportedMaskCount: number;
isDefault?: boolean;
moduleStyles: Record<string, ModuleStyleRecord>;
}
interface SessionRecord {
@@ -70,6 +78,7 @@ const dicomVolumeCache = new Map<DicomDisplayMode, {
spacingBetweenSlices: number | null;
}>();
const modelPreviewCache = new Map<string, unknown>();
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
interface DicomAttributes {
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 {
const stlFiles = listFiles(modelDir, '.stl');
@@ -163,6 +203,7 @@ function buildDefaultProject(): ProjectRecord {
maskFormats: ['nii', 'nii.gz'],
exportedMaskCount: 0,
isDefault: true,
moduleStyles: buildModuleStyles(stlFiles),
};
}
@@ -180,6 +221,7 @@ function buildEmptyProject(name: string): ProjectRecord {
stlFiles: [],
maskFormats: ['nii', 'nii.gz'],
exportedMaskCount: 0,
moduleStyles: {},
};
}
@@ -197,13 +239,16 @@ function defaultState(): AppState {
function normalizeState(state: AppState): AppState {
const defaultProject = buildDefaultProject();
const savedDefaultProject = state.projects?.find((project) => project.id === defaultProject.id);
const customProjects = Array.isArray(state.projects)
? state.projects
.filter((project) => project.id !== defaultProject.id)
.map((project) => ({
...project,
stlFiles: Array.isArray(project.stlFiles) ? project.stlFiles : [],
exportedMaskCount: project.exportedMaskCount ?? 0,
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: [
{
...defaultProject,
name: state.projects?.find((project) => project.id === defaultProject.id)?.name ?? defaultProject.name,
exportedMaskCount: state.projects?.find((project) => project.id === defaultProject.id)?.exportedMaskCount ?? 0,
name: savedDefaultProject?.name ?? defaultProject.name,
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
moduleStyles: buildModuleStyles(defaultProject.stlFiles, savedDefaultProject?.moduleStyles),
},
...customProjects,
],
@@ -1052,6 +1098,28 @@ async function startServer() {
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) => {
const project = findProject(readState(), req.params.projectId);
if (!project) {

View File

@@ -21,20 +21,13 @@ import {
Upload
} from 'lucide-react';
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';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
type SolidityLevel = 'standard' | 'fine' | 'ultra' | 'solid';
interface ModuleStyle {
visible: boolean;
color: string;
opacity: number;
partId: number;
}
interface ModelPose {
rotateX: number;
rotateY: number;
@@ -708,6 +701,28 @@ export default function ProjectLibrary({
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 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(() => {
setViewMode(initialViewMode);
}, [initialViewMode]);
@@ -715,12 +730,7 @@ export default function ProjectLibrary({
useEffect(() => {
const next: Record<string, ModuleStyle> = {};
stlFiles.forEach((fileName, index) => {
next[fileName] = moduleStyles[fileName] ?? {
visible: true,
color: defaultModuleColors[index % defaultModuleColors.length],
opacity: 0.72,
partId: index + 1,
};
next[fileName] = makeDefaultModuleStyle(index, selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
});
setModuleStyles(next);
setSliceIndex(0);
@@ -773,17 +783,15 @@ export default function ProjectLibrary({
}, [sliceIndex, sliceTotal]);
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
setModuleStyles(prev => ({
...prev,
[fileName]: {
visible: true,
color: '#3b82f6',
opacity: 0.72,
partId: 1,
...(prev[fileName] ?? {}),
const index = Math.max(0, stlFiles.indexOf(fileName));
const next = {
...moduleStyles,
[fileName]: makeDefaultModuleStyle(index, {
...(moduleStyles[fileName] ?? selectedProject?.moduleStyles?.[fileName]),
...partial,
},
}));
}),
};
commitModuleStyles(next);
};
const updateModulePartId = (fileName: string, value: number) => {
@@ -793,18 +801,14 @@ export default function ProjectLibrary({
const toggleAllModules = () => {
const nextVisible = !allModulesVisible;
setModuleStyles(prev => {
const next = { ...prev };
stlFiles.forEach((fileName, index) => {
next[fileName] = {
visible: nextVisible,
color: next[fileName]?.color ?? defaultModuleColors[index % defaultModuleColors.length],
opacity: next[fileName]?.opacity ?? 0.72,
partId: next[fileName]?.partId ?? index + 1,
};
const next = { ...moduleStyles };
stlFiles.forEach((fileName, index) => {
next[fileName] = makeDefaultModuleStyle(index, {
...(next[fileName] ?? selectedProject?.moduleStyles?.[fileName]),
visible: nextVisible,
});
return next;
});
commitModuleStyles(next);
};
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="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">
<button
onClick={resetModelRotationPose}

View File

@@ -7,13 +7,12 @@ import {
Download,
Rotate3d,
AlertCircle,
FileJson,
Play,
Eye,
Save,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, MaskMapping, Project } from '../types';
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api';
interface ModelPose {
@@ -37,13 +36,6 @@ interface ModelPreviewPayload {
};
}
interface ModuleStyle {
visible: boolean;
color: string;
opacity: number;
partId: number;
}
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
@@ -422,7 +414,6 @@ function FusionThreeView({
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
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(() => {
api.getProject(projectId).then((item) => {
setProject(item);
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
setSliceStart(0);
setSliceEnd(end);
setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
nextStyles[fileName] = {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
});
setModuleStyles(nextStyles);
}).catch(() => {
@@ -491,8 +497,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
if (!project?.dicomCount) return;
const maxSlice = Math.max(project.dicomCount - 1, 0);
const safeStart = clamp(Math.min(sliceStart, sliceEnd), 0, maxSlice);
const safeEnd = clamp(Math.max(sliceStart, sliceEnd), safeStart, maxSlice);
const safeStart = 0;
const safeEnd = clamp(sliceEnd, 0, maxSlice);
const timer = window.setTimeout(() => {
setFusionError('');
api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft')
@@ -503,7 +509,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
});
}, 180);
return () => window.clearTimeout(timer);
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
}, [project?.id, project?.dicomCount, sliceEnd]);
useEffect(() => {
if (isRegistering && progress < 100) {
@@ -525,17 +531,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
};
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
setModuleStyles((current) => ({
...current,
[fileName]: {
visible: true,
color: '#3b82f6',
opacity: 0.72,
partId: 1,
...(current[fileName] ?? {}),
const stlFiles = project?.stlFiles ?? [];
const index = Math.max(0, stlFiles.indexOf(fileName));
const next = {
...moduleStyles,
[fileName]: makeDefaultModuleStyle(index, {
...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]),
...partial,
},
}));
}),
};
commitModuleStyles(next);
};
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 displayStart = Math.min(sliceStart, sliceEnd);
const displayEnd = Math.max(sliceStart, sliceEnd);
const displayStart = 0;
const displayEnd = clamp(sliceEnd, 0, maxSlice);
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
return (
@@ -633,81 +638,28 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</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="mb-3 flex items-center justify-between">
<p className="text-xs font-bold text-slate-700">DICOM </p>
<span className="text-[10px] font-mono text-blue-600">
{displayStart + 1} - {displayEnd + 1}
</span>
</div>
<div className="space-y-3">
<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={sliceStart}
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))}
className="accent-blue-600"
/>
<span className="text-right font-mono">{sliceEnd + 1}</span>
</label>
</div>
<p className="mt-3 text-[10px] leading-5 text-slate-400">
DICOM CT
</p>
</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 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">DICOM </p>
<span className="text-[10px] font-mono text-blue-600">
{displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0}
</span>
</div>
<label className="grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={displayEnd}
onChange={(event) => setSliceEnd(Number(event.target.value))}
className="accent-blue-600"
/>
<span className="text-right font-mono">{displayEnd + 1} </span>
</label>
<p className="mt-3 text-[10px] leading-5 text-slate-400">
1 使
</p>
</div>
</div>
@@ -740,7 +692,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<div>
<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">
<Save size={12} />
@@ -765,6 +717,31 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
>
姿
</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>
@@ -831,16 +808,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</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>

View File

@@ -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> {
const response = await fetch(path, {
@@ -50,6 +50,11 @@ export const api = {
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
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') =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
getDicomFusionVolume: (projectId: string, start: number, end: number, mode: DicomPreview['mode'] = 'soft') =>

View File

@@ -21,6 +21,14 @@ export interface Project {
maskFormats?: Array<'nii' | 'nii.gz'>;
exportedMaskCount?: number;
isDefault?: boolean;
moduleStyles?: Record<string, ModuleStyle>;
}
export interface ModuleStyle {
visible: boolean;
color: string;
opacity: number;
partId: number;
}
export interface MaskMapping {