2026-05-20-23-28-51 项目库映射交互与导入提示优化
This commit is contained in:
@@ -243,6 +243,40 @@ function getProjectModelFilePath(project: ProjectRecord, fileName: string) {
|
||||
return path.join(getProjectModelDir(project), fileName);
|
||||
}
|
||||
|
||||
function getProjectDicomInfoCachePath(project: ProjectRecord) {
|
||||
const dicomAssetDir = getProjectDicomDir(project);
|
||||
const resolvedDir = path.resolve(dicomAssetDir);
|
||||
const resolvedUploadDir = path.resolve(uploadDir);
|
||||
if (!resolvedDir.startsWith(`${resolvedUploadDir}${path.sep}`)) {
|
||||
return null;
|
||||
}
|
||||
return path.join(resolvedDir, '.revoxelseg-dicom-info.json');
|
||||
}
|
||||
|
||||
function readCachedDicomInfo(project: ProjectRecord, files: string[]) {
|
||||
const cachePath = getProjectDicomInfoCachePath(project);
|
||||
if (!cachePath || !fs.existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8')) as { files?: string[]; info?: unknown };
|
||||
if (!Array.isArray(cached.files) || cached.files.join('|') !== files.join('|') || !cached.info) {
|
||||
return null;
|
||||
}
|
||||
return cached.info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedDicomInfo(project: ProjectRecord, files: string[], info: unknown) {
|
||||
const cachePath = getProjectDicomInfoCachePath(project);
|
||||
if (!cachePath) {
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(cachePath, JSON.stringify({ generatedAt: now(), files, info }, null, 2));
|
||||
}
|
||||
|
||||
function clearProjectRuntimeCaches(projectId: string) {
|
||||
[...dicomPreviewCache.keys()].forEach((key) => {
|
||||
if (key.startsWith(`${projectId}:`)) {
|
||||
@@ -2391,6 +2425,8 @@ async function startServer() {
|
||||
project.dicomPath = toRepoRelativePath(targetDir);
|
||||
project.dicomCount = dicomFiles.length;
|
||||
project.segmentationResults = [];
|
||||
const dicomInfo = createDicomInfo(project, dicomFiles);
|
||||
writeCachedDicomInfo(project, dicomFiles, dicomInfo);
|
||||
} else {
|
||||
const stlFiles = listFiles(targetDir, '.stl');
|
||||
project.modelPath = toRepoRelativePath(targetDir);
|
||||
@@ -2566,7 +2602,15 @@ async function startServer() {
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(createDicomInfo(project, files));
|
||||
const cachedInfo = readCachedDicomInfo(project, files);
|
||||
if (cachedInfo) {
|
||||
res.json(cachedInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
const info = createDicomInfo(project, files);
|
||||
writeCachedDicomInfo(project, files, info);
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 信息解析失败' });
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, Segme
|
||||
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectExportTarget, SegmentationExportMode } from '../lib/api';
|
||||
import {
|
||||
FusionThreeView,
|
||||
OverlayStats,
|
||||
VoxelizationMappingView,
|
||||
clearCachedProjectAssets,
|
||||
getCachedDicomFusionVolume,
|
||||
@@ -91,6 +92,12 @@ const defaultModelPose: ModelPose = {
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
};
|
||||
const emptyOverlayStats: OverlayStats = {
|
||||
activeModules: 0,
|
||||
filledPixels: 0,
|
||||
segmentCount: 0,
|
||||
modules: [],
|
||||
};
|
||||
const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
|
||||
rotateX: { min: -180, max: 180 },
|
||||
rotateY: { min: -180, max: 180 },
|
||||
@@ -680,6 +687,8 @@ export default function ProjectLibrary({
|
||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||
const [resultFusionVolume, setResultFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||
const [resultFusionError, setResultFusionError] = useState('');
|
||||
const [resultOverlayStats, setResultOverlayStats] = useState<OverlayStats>(emptyOverlayStats);
|
||||
const [resultVisibleModuleCount, setResultVisibleModuleCount] = useState(0);
|
||||
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
|
||||
const [dicomInfoError, setDicomInfoError] = useState('');
|
||||
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
|
||||
@@ -839,6 +848,19 @@ export default function ProjectLibrary({
|
||||
return;
|
||||
}
|
||||
const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom';
|
||||
const hasExistingAssets = kind === 'dicom'
|
||||
? (selectedProject.dicomCount ?? 0) > 0
|
||||
: (selectedProject.stlFiles?.length ?? selectedProject.modelCount ?? 0) > 0;
|
||||
if (hasExistingAssets) {
|
||||
const confirmed = window.confirm(
|
||||
kind === 'dicom'
|
||||
? '当前项目已有 DICOM 影像。继续导入会覆盖项目库中的现有 DICOM 影像,并清空当前逆向分割结果,是否继续?'
|
||||
: '当前项目已有 3D 模型。继续导入会覆盖项目库中的现有 STL 模型,并清空当前逆向分割结果,是否继续?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const input = importInputRef.current;
|
||||
if (!input) {
|
||||
setActionMessage('导入控件尚未就绪,请稍后重试');
|
||||
@@ -1159,6 +1181,119 @@ export default function ProjectLibrary({
|
||||
{ id: 'model' as const, label: '3D 模型', icon: Box },
|
||||
{ id: 'mask' as const, label: '逆向分割结果', icon: Layers },
|
||||
];
|
||||
const renderMaskExportMenu = (widthClass = 'w-80') => (
|
||||
<div className={`absolute right-0 top-12 z-50 ${widthClass} rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl`}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="font-bold text-slate-700">导出内容</p>
|
||||
<button
|
||||
onClick={() => setMaskExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
|
||||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{exportOptions.map((option) => (
|
||||
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={maskExportSelection[option.id]}
|
||||
onChange={(event) => setMaskExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
|
||||
className="accent-emerald-600"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block">{option.label}</span>
|
||||
<span className="block text-[10px] text-slate-400">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{maskExportSelection.segmentation && (
|
||||
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
|
||||
<span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationScopeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationScope(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationScope === option.id
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'bg-white text-emerald-700 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-emerald-100 pt-2">
|
||||
<p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationExportModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationExportMode(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationExportMode === option.id
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleMaskBundleExport}
|
||||
disabled={maskExporting}
|
||||
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
导出所选压缩包
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const renderResultOverlaySummary = () => (
|
||||
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-800">Overlay Label Map</p>
|
||||
<p className="mt-1 font-mono text-[11px] font-bold text-cyan-700">
|
||||
{resultOverlayStats.activeModules}/{resultVisibleModuleCount} 构件 · {resultOverlayStats.segmentCount} 边 · {resultOverlayStats.filledPixels} px
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-lg bg-white px-2 py-1 text-[10px] font-bold text-slate-400">当前切片</span>
|
||||
</div>
|
||||
{resultOverlayStats.modules.length ? (
|
||||
<div className="grid max-h-52 grid-cols-1 gap-2 overflow-auto pr-1">
|
||||
{resultOverlayStats.modules.map((item) => (
|
||||
<div key={item.fileName} className="grid grid-cols-[12px_1fr_auto] items-center gap-2 rounded-xl border border-slate-100 bg-white px-3 py-2 text-[10px] font-bold text-slate-600">
|
||||
<span className="h-3 w-3 rounded-full border border-white shadow-sm" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||||
<span className="min-w-0 truncate">{item.name}</span>
|
||||
<span className="font-mono text-cyan-700">ID {item.partId}</span>
|
||||
<span className="col-start-2 font-mono text-slate-400">{item.segmentCount} 边</span>
|
||||
<span className="font-mono text-slate-400">{item.filledPixels} px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-100 bg-white px-3 py-2 text-[10px] font-bold text-slate-400">
|
||||
当前切片暂无可见构件
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-6 overflow-hidden">
|
||||
@@ -1312,7 +1447,19 @@ export default function ProjectLibrary({
|
||||
>
|
||||
<RotateCw size={18} /> 进入逆向工作区
|
||||
</button>
|
||||
{viewMode !== 'mask' && (
|
||||
{viewMode === 'mask' ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMaskExportMenu((value) => !value)}
|
||||
disabled={maskExporting || !latestSegmentationResult}
|
||||
className="bg-emerald-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-emerald-700 transition-all shadow-lg disabled:opacity-50"
|
||||
>
|
||||
<Download size={18} />
|
||||
{maskExporting ? '正在导出' : '导出项目及结果'}
|
||||
</button>
|
||||
{showMaskExportMenu && renderMaskExportMenu('w-80')}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={triggerProjectAssetImport}
|
||||
disabled={assetImporting}
|
||||
@@ -1692,6 +1839,11 @@ export default function ProjectLibrary({
|
||||
displayMode={resultDisplayMode}
|
||||
rotation={resultRotation}
|
||||
variant="library"
|
||||
overlayPlacement="none"
|
||||
onOverlayStatsChange={(stats, visibleCount) => {
|
||||
setResultOverlayStats(stats);
|
||||
setResultVisibleModuleCount(visibleCount);
|
||||
}}
|
||||
toolbar={(
|
||||
<>
|
||||
<div className="flex rounded-xl bg-white/10 p-1">
|
||||
@@ -1733,14 +1885,14 @@ export default function ProjectLibrary({
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-800">逆向分割结果</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
项目库仅保留最新一次保存结果,导出时默认沿用该结果的模型位姿与构件样式。
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-lg px-2 py-1 text-[10px] font-bold ${latestSegmentationResult ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'}`}>
|
||||
<span className={`shrink-0 whitespace-nowrap rounded-lg px-2 py-1 text-[10px] font-bold ${latestSegmentationResult ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'}`}>
|
||||
{latestSegmentationResult ? '已保存' : '未保存'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1764,99 +1916,7 @@ export default function ProjectLibrary({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMaskExportMenu((value) => !value)}
|
||||
disabled={maskExporting || !latestSegmentationResult}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white shadow-lg hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Download size={18} />
|
||||
{maskExporting ? '正在导出' : '导出项目及结果'}
|
||||
</button>
|
||||
{showMaskExportMenu && (
|
||||
<div className="absolute right-0 top-14 z-30 w-full rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="font-bold text-slate-700">导出内容</p>
|
||||
<button
|
||||
onClick={() => setMaskExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
|
||||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{exportOptions.map((option) => (
|
||||
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={maskExportSelection[option.id]}
|
||||
onChange={(event) => setMaskExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
|
||||
className="accent-emerald-600"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block">{option.label}</span>
|
||||
<span className="block text-[10px] text-slate-400">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{maskExportSelection.segmentation && (
|
||||
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
|
||||
<span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationScopeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationScope(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationScope === option.id
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'bg-white text-emerald-700 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-emerald-100 pt-2">
|
||||
<p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationExportModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMaskSegmentationExportMode(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
maskSegmentationExportMode === option.id
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleMaskBundleExport}
|
||||
disabled={maskExporting}
|
||||
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
导出所选压缩包
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{latestSegmentationResult && renderResultOverlaySummary()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1257,7 +1257,7 @@ interface PlaneSegment {
|
||||
b: Point2D;
|
||||
}
|
||||
|
||||
interface OverlayStats {
|
||||
export interface OverlayStats {
|
||||
activeModules: number;
|
||||
filledPixels: number;
|
||||
segmentCount: number;
|
||||
@@ -1881,6 +1881,7 @@ export function VoxelizationMappingView({
|
||||
variant = 'workspace',
|
||||
toolbar,
|
||||
overlayPlacement,
|
||||
onOverlayStatsChange,
|
||||
}: {
|
||||
project: Project | null;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
@@ -1893,7 +1894,8 @@ export function VoxelizationMappingView({
|
||||
rotation: number;
|
||||
variant?: 'workspace' | 'library';
|
||||
toolbar?: React.ReactNode;
|
||||
overlayPlacement?: 'bottom' | 'side';
|
||||
overlayPlacement?: 'bottom' | 'side' | 'none';
|
||||
onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void;
|
||||
}) {
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -1918,6 +1920,10 @@ export function VoxelizationMappingView({
|
||||
const isLibraryVariant = variant === 'library';
|
||||
const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom');
|
||||
|
||||
useEffect(() => {
|
||||
onOverlayStatsChange?.(overlayStats, visibleModuleCount);
|
||||
}, [onOverlayStatsChange, overlayStats, visibleModuleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.dicomCount) {
|
||||
setDicomPreview(null);
|
||||
@@ -2360,6 +2366,7 @@ export default function ReverseWorkspace({
|
||||
const workspaceLoadProjectRef = useRef('');
|
||||
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const visualToolbarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
const savedWorkspaceSnapshotRef = useRef('');
|
||||
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
|
||||
@@ -2767,13 +2774,26 @@ export default function ReverseWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const restoreVisualToolbarScroll = (scrollTop: number | null) => {
|
||||
if (scrollTop === null) {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
if (visualToolbarScrollRef.current) {
|
||||
visualToolbarScrollRef.current.scrollTop = scrollTop;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
|
||||
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
|
||||
setModelPose((current) => ({
|
||||
...current,
|
||||
[key]: clampPoseValue(key, current[key] + delta),
|
||||
}));
|
||||
setSelectedPoseId('custom');
|
||||
setPoseImportStatus('');
|
||||
restoreVisualToolbarScroll(scrollTop);
|
||||
};
|
||||
|
||||
const handlePoseInputChange = (key: ModelPoseKey, value: string) => {
|
||||
@@ -3378,7 +3398,7 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
|
||||
<div className="flex-1 overflow-auto space-y-4 pr-1">
|
||||
<div ref={visualToolbarScrollRef} className="flex-1 overflow-auto space-y-4 pr-1">
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">模型显示</p>
|
||||
<div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
@@ -3511,7 +3531,10 @@ export default function ReverseWorkspace({
|
||||
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
<span>{item.label}</span>
|
||||
<button
|
||||
onMouseDown={() => startPoseRepeat(item.key, -poseStepConfig[item.key].step)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
|
||||
}}
|
||||
onMouseUp={stopPoseRepeat}
|
||||
onMouseLeave={stopPoseRepeat}
|
||||
onTouchStart={(event) => {
|
||||
@@ -3535,7 +3558,10 @@ export default function ReverseWorkspace({
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<button
|
||||
onMouseDown={() => startPoseRepeat(item.key, poseStepConfig[item.key].step)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
startPoseRepeat(item.key, poseStepConfig[item.key].step);
|
||||
}}
|
||||
onMouseUp={stopPoseRepeat}
|
||||
onMouseLeave={stopPoseRepeat}
|
||||
onTouchStart={(event) => {
|
||||
|
||||
@@ -122,9 +122,11 @@
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
direction: rtl;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
@@ -136,6 +138,7 @@
|
||||
.mapping-slice-vertical-input::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin: 0 auto;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
@@ -148,6 +151,7 @@
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
cursor: grab;
|
||||
height: 22px;
|
||||
margin-left: -7px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
@@ -180,9 +184,11 @@
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 30px;
|
||||
direction: rtl;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
@@ -194,6 +200,7 @@
|
||||
.mapping-slice-dark-vertical-input::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin: 0 auto;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
@@ -206,6 +213,7 @@
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 20px;
|
||||
margin-left: -7px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
|
||||
58
工程分析/实现方案-2026-05-20-23-28-51.md
Normal file
58
工程分析/实现方案-2026-05-20-23-28-51.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 实现方案-2026-05-20-23-28-51
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-23-28-51.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 修复位姿按钮首次点击导致中部可视化工具栏滚动上跳。
|
||||
- 统一项目库与逆向工作区竖向 Slice Navigator 滑块居中样式。
|
||||
- 将项目库 Overlay Label Map 摘要从映射视图右侧黑色面板改为导出按钮下方浅色信息格。
|
||||
- 调整逆向分割结果状态徽标与标题同行。
|
||||
- 导入已有 DICOM/STL 时增加覆盖确认。
|
||||
- DICOM 导入时生成并保存详细信息缓存,详情接口优先读取缓存。
|
||||
- 项目库逆向分割页导出按钮移到顶部操作区。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. `VoxelizationMappingView` 新增 Overlay 统计回调和 `overlayPlacement='none'`,项目库使用外置浅色 Overlay 面板。
|
||||
2. 在项目库 mask 视图顶部操作区渲染“导出项目及结果”按钮和下拉菜单,删除下方旧导出入口。
|
||||
3. 调整竖向 range input CSS,使可点击层覆盖轨道但 thumb 视觉中心与轨道中心对齐。
|
||||
4. 为位姿微调按钮增加 `onMouseDown preventDefault`,并在位姿更新时保持工具栏滚动位置。
|
||||
5. 导入入口根据项目现有数据量弹出覆盖确认,再唤起文件选择。
|
||||
6. 后端 DICOM 导入后调用现有 DICOM 信息解析逻辑,将结果写入上传目录缓存文件;`/dicom-info` 优先读取缓存。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 写入本次需求、实现、测试方案。
|
||||
2. 定位映射视图、项目库 mask 页、位姿控件、CSS range 样式和导入接口。
|
||||
3. 修改前端组件结构与样式。
|
||||
4. 修改后端 DICOM 信息缓存逻辑。
|
||||
5. 执行 `npm run lint`、`npm run build`。
|
||||
6. 重启 `revoxelseg-dicom` tmux 服务并验证 `/api/health` 与首页。
|
||||
7. 追加经验记录,提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若外置 Overlay 面板出现异常,可将项目库 `overlayPlacement` 改回 `side`。
|
||||
- DICOM 信息缓存失败时不阻断默认详情接口解析,可回退到实时解析。
|
||||
- 导入覆盖确认只在前端增加,后端接口仍保持原有覆盖写入能力。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 前端组件 2 个,样式 1 个,后端 1 个,工程分析文档 4 个。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次相关文件,忽略历史删除和软著未跟踪文件。
|
||||
- commit message 包含 `2026-05-20-23-28-51` 与简要描述。
|
||||
- 推送 Gitea 后重启 4000 服务。
|
||||
54
工程分析/测试方案-2026-05-20-23-28-51.md
Normal file
54
工程分析/测试方案-2026-05-20-23-28-51.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 测试方案-2026-05-20-23-28-51
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-20-23-28-51.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `cd WebSite && npm run lint`,确保 TypeScript 类型检查通过。
|
||||
- 使用 `rg` 检查项目库是否仍残留不需要的黑色 side Overlay 面板调用。
|
||||
|
||||
执行结果:`npm run lint` 已通过;项目库当前使用 `overlayPlacement="none"` 外置 Overlay 摘要。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 执行 `cd WebSite && npm run build`,确认生产构建通过。
|
||||
|
||||
执行结果:`npm run build` 已通过;Vite 仅提示既有 chunk 体积超过 500 kB。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 逆向工作区位姿平移 Z 的 `-` 首次点击不再造成工具栏滚动条上跳。
|
||||
- 项目库和逆向工作区竖向 Slice Navigator 圆形滑块居中在轨道上。
|
||||
- 项目库逆向分割结果页顶部可点击“导出项目及结果”,菜单不被下方边界裁切。
|
||||
- 项目库 Overlay Label Map 显示在导出按钮下方,使用浅色信息格风格。
|
||||
- “逆向分割结果”标题与 `已保存` 状态同一行展示。
|
||||
- 项目已有 DICOM/STL 时点击导入会提示覆盖风险。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 导入 DICOM 后上传目录生成 DICOM 信息缓存。
|
||||
- `/api/projects/:projectId/dicom-info` 对上传项目优先返回缓存内容。
|
||||
- 覆盖导入仍只写入 `WebSite/data/uploads/<projectId>/...`,不影响 `Head_CT_DICOM/` 和 `Head_CT_ReConstruct/`。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 重启 tmux 会话 `revoxelseg-dicom`:
|
||||
- `cd WebSite`
|
||||
- `npm run serve -- --host 0.0.0.0 --port 4000`
|
||||
- 验证:
|
||||
- `curl http://127.0.0.1:4000/api/health`
|
||||
- `curl -I http://127.0.0.1:4000/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- `git status --short` 确认暂存范围不包含无关历史删除或软著材料。
|
||||
- commit message 包含 `2026-05-20-23-28-51`。
|
||||
- `git push origin main` 成功。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- Overlay 外置后仍要随切片、构件显示、颜色、透明度实时变化。
|
||||
- 位姿按钮防滚动跳动不能破坏长按连续微调。
|
||||
- DICOM 信息缓存不能在项目覆盖导入后返回旧内容。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1387,3 +1387,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是新增医学影像或模型导入能力,都必须区分“默认演示资产”和“项目用户资产”。后端路径解析不能继续硬编码单一目录;导入后要清理前后端缓存并清空旧分割结果,避免新数据套用旧预览或旧位姿结果。
|
||||
|
||||
## 2026-05-20-23-28-51 项目库结果操作要避免遮挡与误覆盖
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户指出位姿微调首次点击会带动可视化工具栏滚动条上跳,竖向 Slice Navigator 圆点没有稳定居中;项目库 Overlay Label Map 仍是黑色面板且位置不利于复核,“已保存”状态竖排;已有 DICOM/STL 再导入时缺少覆盖提醒,DICOM 详情也不应等点击后才解析。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
位姿按钮在可滚动容器内按下时会触发浏览器默认焦点滚动,状态更新后滚动位置没有被恢复。项目库复用映射组件时仍使用组件内部深色 Overlay 摘要,导出入口放在下方导致菜单容易被页面边界裁切。DICOM 详情接口只做按需解析,导入流程没有缓存元数据,也没有在前端导入入口判断现有资产。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
为位姿微调按钮增加 `preventDefault`,并在 `modelPose` 更新前后保持可视化工具栏 `scrollTop`。调整竖向 range input 的轨道和 thumb 偏移,使项目库与逆向工作区滑块居中。`VoxelizationMappingView` 增加 Overlay 统计回调和 `overlayPlacement="none"`,项目库外置浅色 Overlay 摘要;导出按钮移到顶部操作区,结果状态徽标增加不换行约束。项目导入前根据 DICOM/STL 现有数量弹覆盖确认;后端导入 DICOM 时生成 `.revoxelseg-dicom-info.json`,详情接口优先读取缓存。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
可滚动面板内的长按按钮要优先处理焦点滚动和滚动位置恢复;医学视图内的统计面板若影响主画布或菜单可达性,应外置为复核信息块。任何覆盖式资产导入都要在前端显式确认,并在后端同步生成或清理与资产强绑定的缓存,防止新旧 DICOM/STL 信息串用。
|
||||
|
||||
53
工程分析/需求分析-2026-05-20-23-28-51.md
Normal file
53
工程分析/需求分析-2026-05-20-23-28-51.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 需求分析-2026-05-20-23-28-51
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-20-23-28-51
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. 修复逆向工作区点击模型位姿平移 Z 的 `-` 时,可视化工具栏滚动条首次上跳的问题。
|
||||
2. 项目库与逆向工作区中的竖向切片滑块圆点需要居中在滑条中。
|
||||
3. 项目库 Overlay Label Map 放到“导出项目及结果”下方,颜色改为与项目库浅色信息格统一,不再使用黑色面板。
|
||||
4. 项目库“逆向分割结果”右侧 `已保存` 不应竖排,需与标题同行展示。
|
||||
5. 项目已有 DICOM 影像或 3D 模型时再次导入,需要提示新数据会覆盖原数据。
|
||||
6. 项目库 DICOM 详细信息在传入 DICOM 时提前解析,减少后续点击详情时的解析等待。
|
||||
7. 项目库逆向分割页的“导出项目及结果”移到顶部操作区,与导入入口同级,避免下方菜单被页面边界裁切。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 让位姿微调、切片导航和导出菜单的交互更加稳定。
|
||||
- 将项目库逆向分割结果页从“深色工作区组件”调整为“浅色项目库复核信息区”。
|
||||
- 在导入覆盖原有医学影像/模型前给出明确确认,降低误操作风险。
|
||||
- 上传 DICOM 时预解析元数据,提升后续查看 DICOM 详细信息的响应速度。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:用户点击位姿平移按钮、拖动竖向切片滑条、打开项目库逆向分割结果、重新导入 DICOM/STL、查看 DICOM 信息。
|
||||
- 输出:稳定的工具栏滚动位置、居中的竖向滑块、浅色 Overlay 摘要、顶部导出菜单、覆盖确认提示、预缓存 DICOM 信息。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`:位姿按钮滚动稳定、映射视图 Overlay 输出和滑条样式入口。
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`:项目库逆向分割布局、顶部导出、覆盖导入确认、浅色 Overlay 面板。
|
||||
- `WebSite/src/index.css`:竖向 range input 样式。
|
||||
- `WebSite/server.ts`:DICOM 导入时生成信息缓存,DICOM 信息接口优先使用缓存。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 项目库复核页与逆向工作区仍复用同一映射组件,避免维护两套核心渲染逻辑。
|
||||
- 覆盖导入只覆盖项目级上传资产,不覆盖默认演示数据目录。
|
||||
- 位姿按钮防跳动不能影响连续按住调参能力。
|
||||
- Overlay 摘要移动到项目库外部后,逆向工作区仍保留底部 Overlay 摘要。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 将 Overlay 摘要外置后需要把统计数据从映射组件传回项目库,避免重复计算或丢失实时联动。
|
||||
- 竖向 range input 在不同浏览器下样式差异较大,需要保持 WebKit 与 Firefox 都可用。
|
||||
- DICOM 信息缓存必须在导入后与项目路径一致,避免旧数据详情串到新项目。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- “已保存”指项目库逆向分割结果标题右侧状态徽标。
|
||||
- “设置在条中间”指竖向切片导航的圆形滑块需要与轨道中心线对齐。
|
||||
- DICOM 信息缓存优先服务项目级上传数据;默认演示数据仍可按需解析。
|
||||
Reference in New Issue
Block a user