2026-05-24-15-55-48 增加项目锁定与切片控件修正
This commit is contained in:
@@ -22,7 +22,9 @@ import {
|
||||
RefreshCcw,
|
||||
FlipHorizontal2,
|
||||
FlipVertical2,
|
||||
Move3d
|
||||
Move3d,
|
||||
Lock,
|
||||
Unlock
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
|
||||
@@ -176,6 +178,35 @@ function formatPoseCompactValue(value: number, digits = 2) {
|
||||
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
|
||||
}
|
||||
|
||||
function getProjectActivityTime(project: Project) {
|
||||
const latestResult = project.segmentationResults?.[project.segmentationResults.length - 1];
|
||||
const candidates = [
|
||||
project.lastProcessedAt,
|
||||
project.lockedAt,
|
||||
project.unlockedAt,
|
||||
latestResult?.createdAt,
|
||||
project.createTime,
|
||||
];
|
||||
return Math.max(...candidates.map((value) => (value ? Date.parse(value) : 0)).filter((value) => Number.isFinite(value)), 0);
|
||||
}
|
||||
|
||||
function sortProjectsByActivity(projects: Project[]) {
|
||||
return [...projects].sort((a, b) => getProjectActivityTime(b) - getProjectActivityTime(a));
|
||||
}
|
||||
|
||||
function formatProjectActivity(project: Project) {
|
||||
const time = getProjectActivityTime(project);
|
||||
if (!time) {
|
||||
return project.createTime;
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(time));
|
||||
}
|
||||
|
||||
interface AssetImportProgressState {
|
||||
kind: ProjectAssetImportKind;
|
||||
fileCount: number;
|
||||
@@ -824,6 +855,7 @@ export default function ProjectLibrary({
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [showUnlockedOnly, setShowUnlockedOnly] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>(initialViewMode);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [sliceIndex, setSliceIndex] = useState(0);
|
||||
@@ -865,6 +897,7 @@ export default function ProjectLibrary({
|
||||
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||||
const [maskExporting, setMaskExporting] = useState(false);
|
||||
const [assetImporting, setAssetImporting] = useState(false);
|
||||
const [lockChangingProjectId, setLockChangingProjectId] = useState('');
|
||||
const [assetImportProgress, setAssetImportProgress] = useState<AssetImportProgressState | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const importKindRef = useRef<ProjectAssetImportKind>('dicom');
|
||||
@@ -891,13 +924,14 @@ export default function ProjectLibrary({
|
||||
setLoading(true);
|
||||
return api.getProjects()
|
||||
.then((items) => {
|
||||
setProjects(items);
|
||||
items.slice(0, 2).forEach(preloadProjectAssets);
|
||||
const orderedItems = sortProjectsByActivity(items);
|
||||
setProjects(orderedItems);
|
||||
orderedItems.slice(0, 2).forEach(preloadProjectAssets);
|
||||
setSelectedProject((current) => {
|
||||
if (!current) {
|
||||
return items[0] ?? null;
|
||||
return orderedItems[0] ?? null;
|
||||
}
|
||||
return items.find((item) => item.id === current.id) ?? items[0] ?? null;
|
||||
return orderedItems.find((item) => item.id === current.id) ?? orderedItems[0] ?? null;
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
@@ -917,11 +951,12 @@ export default function ProjectLibrary({
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return projects;
|
||||
}
|
||||
return projects.filter((project) => project.name.toLowerCase().includes(keyword));
|
||||
}, [projects, search]);
|
||||
return sortProjectsByActivity(projects).filter((project) => {
|
||||
const matchesKeyword = !keyword || project.name.toLowerCase().includes(keyword);
|
||||
const matchesLockFilter = !showUnlockedOnly || project.locked !== true;
|
||||
return matchesKeyword && matchesLockFilter;
|
||||
});
|
||||
}, [projects, search, showUnlockedOnly]);
|
||||
|
||||
const stlFiles = selectedProject?.stlFiles ?? [];
|
||||
const planeOptions: Array<{ id: Plane; label: string }> = [
|
||||
@@ -941,8 +976,7 @@ export default function ProjectLibrary({
|
||||
const dicomMaxSlice = Math.max(dicomSliceTotal - 1, 0);
|
||||
const safeDicomSlice = Math.max(0, Math.min(dicomMaxSlice, sliceIndex));
|
||||
const dicomDisplaySlice = getDicomDisplaySliceNumber(safeDicomSlice, dicomSliceTotal);
|
||||
const dicomSliderValue = dicomMaxSlice - safeDicomSlice;
|
||||
const dicomSlicePercent = dicomMaxSlice > 0 ? (dicomSliderValue / dicomMaxSlice) * 100 : 0;
|
||||
const dicomSliderValue = safeDicomSlice;
|
||||
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
||||
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
|
||||
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
|
||||
@@ -969,7 +1003,7 @@ export default function ProjectLibrary({
|
||||
api.updateProjectModuleStyles(selectedProject.id, next)
|
||||
.then((updated) => {
|
||||
setSelectedProject(updated);
|
||||
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||||
setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item))));
|
||||
})
|
||||
.catch((error) => {
|
||||
setActionMessage(error instanceof Error ? error.message : '构件样式保存失败');
|
||||
@@ -1079,7 +1113,7 @@ export default function ProjectLibrary({
|
||||
clearCachedProjectAssets(updated.id);
|
||||
preloadedProjectIdsRef.current.delete(updated.id);
|
||||
setSelectedProject(updated);
|
||||
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||||
setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item))));
|
||||
const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1];
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(updated.stlFiles ?? []).forEach((fileName, index) => {
|
||||
@@ -1399,6 +1433,31 @@ export default function ProjectLibrary({
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const handleToggleProjectLock = async (project: Project) => {
|
||||
setLockChangingProjectId(project.id);
|
||||
setActionMessage('');
|
||||
try {
|
||||
const updated = await api.updateProjectLock(project.id, project.locked !== true);
|
||||
setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item))));
|
||||
setSelectedProject((current) => (current?.id === updated.id ? updated : current));
|
||||
setActionMessage(updated.locked
|
||||
? `已锁定,位姿数据已保存到 ${updated.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}`
|
||||
: '项目已解锁,可以进入逆向工作区');
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : '项目锁定状态更新失败');
|
||||
} finally {
|
||||
setLockChangingProjectId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterReverseWorkspace = (project: Project) => {
|
||||
if (project.locked) {
|
||||
setActionMessage('项目已锁定,请先点击「解锁项目」后再进入逆向工作区');
|
||||
return;
|
||||
}
|
||||
onReverse(project.id);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon },
|
||||
{ id: 'model' as const, label: '3D 模型', icon: Box },
|
||||
@@ -1555,6 +1614,26 @@ export default function ProjectLibrary({
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setShowUnlockedOnly(false)}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg text-[10px] font-bold transition ${
|
||||
!showUnlockedOnly ? 'bg-slate-900 text-white shadow-sm' : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<FolderRoot size={12} />
|
||||
全部项目
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUnlockedOnly(true)}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg text-[10px] font-bold transition ${
|
||||
showUnlockedOnly ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Unlock size={12} />
|
||||
未上锁
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||||
{loading && <p className="text-xs text-slate-400 px-2">正在从后端载入项目...</p>}
|
||||
{filteredProjects.map((proj) => (
|
||||
@@ -1581,9 +1660,19 @@ export default function ProjectLibrary({
|
||||
className="w-full rounded-md px-2 py-1 text-xs text-slate-900 outline-none ring-1 ring-blue-200"
|
||||
/>
|
||||
) : (
|
||||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className={`truncate text-xs font-bold ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
{proj.locked && (
|
||||
<span className={`flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[8px] font-black ${
|
||||
selectedProject?.id === proj.id ? 'bg-white/15 text-blue-50' : 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
<Lock size={9} />
|
||||
锁
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingProjectId !== proj.id && (
|
||||
@@ -1613,7 +1702,7 @@ export default function ProjectLibrary({
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
|
||||
{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||||
处理 {formatProjectActivity(proj)} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -1628,11 +1717,12 @@ export default function ProjectLibrary({
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => setSelectedProject(p)}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${
|
||||
className={`relative w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${
|
||||
selectedProject?.id === p.id ? 'bg-blue-600 text-white shadow-md' : 'bg-slate-50 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<FolderRoot size={16} />
|
||||
{p.locked && <Lock size={9} className="absolute -right-1 -top-1 rounded-full bg-amber-100 p-0.5 text-amber-600" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1664,9 +1754,28 @@ export default function ProjectLibrary({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => void handleToggleProjectLock(selectedProject)}
|
||||
disabled={lockChangingProjectId === selectedProject.id}
|
||||
className={`px-5 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-lg disabled:opacity-60 ${
|
||||
selectedProject.locked
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-white text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
title={selectedProject.locked ? '解除项目锁定' : '锁定项目并保存位姿数据'}
|
||||
>
|
||||
{selectedProject.locked ? <Unlock size={18} /> : <Lock size={18} />}
|
||||
{lockChangingProjectId === selectedProject.id
|
||||
? '处理中'
|
||||
: selectedProject.locked ? '解锁项目' : '锁定项目'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReverse(selectedProject.id)}
|
||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-blue-700 transition-all shadow-lg"
|
||||
onClick={() => handleEnterReverseWorkspace(selectedProject)}
|
||||
className={`px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-lg ${
|
||||
selectedProject.locked
|
||||
? 'bg-slate-300 text-slate-500 hover:bg-slate-300'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
<RotateCw size={18} /> 进入逆向工作区
|
||||
</button>
|
||||
@@ -1846,12 +1955,12 @@ export default function ProjectLibrary({
|
||||
{dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
|
||||
</span>
|
||||
<button
|
||||
onMouseDown={() => startSliceStep(-1)}
|
||||
onMouseDown={() => startSliceStep(1)}
|
||||
onMouseUp={stopSliceStep}
|
||||
onMouseLeave={stopSliceStep}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
startSliceStep(-1);
|
||||
startSliceStep(1);
|
||||
}}
|
||||
onTouchEnd={stopSliceStep}
|
||||
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
||||
@@ -1861,27 +1970,23 @@ export default function ProjectLibrary({
|
||||
</button>
|
||||
<div className="relative min-h-[260px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-800/70" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-cyan-400"
|
||||
style={{ height: `${dicomSlicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={dicomMaxSlice}
|
||||
value={dicomSliderValue}
|
||||
onChange={(event) => setSliceIndex(dicomMaxSlice - Number(event.target.value))}
|
||||
onChange={(event) => setSliceIndex(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库 DICOM 切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onMouseDown={() => startSliceStep(1)}
|
||||
onMouseDown={() => startSliceStep(-1)}
|
||||
onMouseUp={stopSliceStep}
|
||||
onMouseLeave={stopSliceStep}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
startSliceStep(1);
|
||||
startSliceStep(-1);
|
||||
}}
|
||||
onTouchEnd={stopSliceStep}
|
||||
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
Lock,
|
||||
Maximize2,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
@@ -2283,8 +2284,7 @@ export function VoxelizationMappingView({
|
||||
const stepSlice = (delta: number) => {
|
||||
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
||||
};
|
||||
const sliderSliceValue = maxSlice - safeSlice;
|
||||
const slicePercent = maxSlice > 0 ? (sliderSliceValue / maxSlice) * 100 : 0;
|
||||
const sliderSliceValue = safeSlice;
|
||||
const displaySliceNumber = getDicomDisplaySliceNumber(safeSlice, Math.max(totalSlices, 1));
|
||||
const resetMappingViewport = () => {
|
||||
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
@@ -2423,16 +2423,12 @@ export function VoxelizationMappingView({
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||||
<div className="relative min-h-[220px] w-8 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-cyan-400"
|
||||
style={{ height: `${slicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库逆向分割映射视图切片导航"
|
||||
/>
|
||||
@@ -2533,8 +2529,8 @@ export function VoxelizationMappingView({
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="上一层"
|
||||
>
|
||||
@@ -2542,32 +2538,28 @@ export function VoxelizationMappingView({
|
||||
</button>
|
||||
<div className="relative min-h-[240px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-blue-600"
|
||||
style={{ height: `${slicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-vertical-input"
|
||||
aria-label="逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||||
title="下一层"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
||||
<span>顶层 {Math.max(totalSlices, 1)}</span>
|
||||
<span>顶层 1</span>
|
||||
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||||
<span>底层 1</span>
|
||||
<span>底层 {Math.max(totalSlices, 1)}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -2759,6 +2751,9 @@ export default function ReverseWorkspace({
|
||||
if (!project) {
|
||||
return true;
|
||||
}
|
||||
if (project.locked) {
|
||||
return true;
|
||||
}
|
||||
if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) {
|
||||
return true;
|
||||
}
|
||||
@@ -2918,6 +2913,19 @@ export default function ReverseWorkspace({
|
||||
});
|
||||
api.getProject(projectId).then((item) => {
|
||||
setProject(item);
|
||||
if (item.locked) {
|
||||
setFusionVolume(null);
|
||||
setWorkspaceLoadState({
|
||||
ready: false,
|
||||
phase: '项目已锁定',
|
||||
loaded: 1,
|
||||
total: 1,
|
||||
startedAt: Date.now(),
|
||||
error: '项目已锁定,请在项目库解锁后再进入逆向工作区。',
|
||||
});
|
||||
savedWorkspaceSnapshotRef.current = '';
|
||||
return;
|
||||
}
|
||||
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
|
||||
const latestResult = item.segmentationResults?.[item.segmentationResults.length - 1];
|
||||
const restoredSliceStart = clamp(latestResult?.sliceStart ?? 0, 0, maxIndex);
|
||||
@@ -3167,6 +3175,21 @@ export default function ReverseWorkspace({
|
||||
restoreVisualToolbarScroll(scrollTop);
|
||||
};
|
||||
|
||||
const allModulesVisible = Boolean(project?.stlFiles?.length) && (project?.stlFiles ?? []).every((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||
|
||||
const toggleAllModules = () => {
|
||||
const stlFiles = project?.stlFiles ?? [];
|
||||
const nextVisible = !allModulesVisible;
|
||||
const next = { ...moduleStyles };
|
||||
stlFiles.forEach((fileName, index) => {
|
||||
next[fileName] = makeDefaultModuleStyle(index, {
|
||||
...(next[fileName] ?? project?.moduleStyles?.[fileName]),
|
||||
visible: nextVisible,
|
||||
});
|
||||
});
|
||||
commitModuleStyles(next);
|
||||
};
|
||||
|
||||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||
const stlFiles = project?.stlFiles ?? [];
|
||||
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||||
@@ -3280,7 +3303,7 @@ export default function ReverseWorkspace({
|
||||
const workspaceLoadSpeed = workspaceLoadState.loaded / workspaceElapsedSeconds;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.dicomCount) {
|
||||
if (!project?.dicomCount || project.locked) {
|
||||
return undefined;
|
||||
}
|
||||
if (workspaceLoadProjectRef.current === project.id) {
|
||||
@@ -3371,6 +3394,7 @@ export default function ReverseWorkspace({
|
||||
}, [
|
||||
project?.id,
|
||||
project?.dicomCount,
|
||||
project?.locked,
|
||||
project?.stlFiles?.join('|'),
|
||||
displayStart,
|
||||
displayEnd,
|
||||
@@ -3398,6 +3422,27 @@ export default function ReverseWorkspace({
|
||||
modelPose.rotateZ,
|
||||
]);
|
||||
|
||||
if (project?.locked) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
|
||||
<div className="w-full max-w-2xl rounded-3xl border border-amber-100 bg-white p-8 text-center shadow-sm">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
|
||||
<Lock size={24} />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-black text-slate-900">项目已锁定</h2>
|
||||
<p className="mx-auto mt-3 max-w-lg text-sm font-semibold leading-6 text-slate-500">
|
||||
请先在项目库点击“解锁项目”,再进入逆向工作区继续修改位姿和分割结果。
|
||||
</p>
|
||||
<div className="mt-6 rounded-2xl bg-slate-50 px-4 py-3 text-left text-xs font-bold text-slate-500">
|
||||
<p>项目:{project.name}</p>
|
||||
<p className="mt-1">锁定时间:{project.lockedAt ? new Date(project.lockedAt).toLocaleString('zh-CN') : '未记录'}</p>
|
||||
<p className="mt-1">锁定结果:{project.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceLoadState.ready) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
|
||||
@@ -3943,7 +3988,17 @@ export default function ReverseWorkspace({
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">构件层级</p>
|
||||
<span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
|
||||
<button
|
||||
onClick={toggleAllModules}
|
||||
disabled={!project?.stlFiles?.length}
|
||||
className={`rounded p-1 transition ${allModulesVisible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white disabled:opacity-40`}
|
||||
title={allModulesVisible ? '隐藏所有构件' : '显示所有构件'}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(project?.stlFiles ?? []).map((fileName, index) => {
|
||||
|
||||
@@ -114,6 +114,11 @@ export const api = {
|
||||
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
updateProjectLock: (projectId: string, locked: boolean) =>
|
||||
request<Project>(`/api/projects/${projectId}/lock`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ locked }),
|
||||
}),
|
||||
updateProjectModuleStyles: (projectId: string, moduleStyles: Record<string, ModuleStyle>) =>
|
||||
request<Project>(`/api/projects/${projectId}/module-styles`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface Project {
|
||||
maskFormats?: Array<'nii' | 'nii.gz'>;
|
||||
exportedMaskCount?: number;
|
||||
isDefault?: boolean;
|
||||
locked?: boolean;
|
||||
lockedAt?: string | null;
|
||||
unlockedAt?: string | null;
|
||||
lastProcessedAt?: string;
|
||||
lockedPoseSnapshotPath?: string | null;
|
||||
moduleStyles?: Record<string, ModuleStyle>;
|
||||
modelPoses?: SavedModelPose[];
|
||||
segmentationResults?: SegmentationResult[];
|
||||
|
||||
Reference in New Issue
Block a user