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"
|
||||
|
||||
Reference in New Issue
Block a user