2026-05-24-15-55-48 增加项目锁定与切片控件修正

This commit is contained in:
2026-05-24 16:15:52 +08:00
parent e9f0823281
commit 3bedf204c8
14 changed files with 586 additions and 62 deletions

View File

@@ -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"