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

@@ -83,6 +83,11 @@ interface ProjectRecord {
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, ModuleStyleRecord>;
modelPoses: ModelPoseRecord[];
segmentationResults: SegmentationResultRecord[];
@@ -121,6 +126,7 @@ const uploadTempDir = path.join(dataDir, 'upload-tmp');
const statePath = path.join(dataDir, 'state.json');
const dicomDir = path.join(repoRoot, 'Head_CT_DICOM');
const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct');
const lockedResultDir = path.join(repoRoot, '项目数据', '锁定结果');
const dicomPreviewCache = new Map<string, unknown>();
const dicomVolumeCache = new Map<string, {
frames: Buffer[];
@@ -214,6 +220,60 @@ function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
function timestampMillis(value: unknown) {
if (typeof value !== 'string' || !value.trim()) {
return 0;
}
const time = Date.parse(value);
return Number.isFinite(time) ? time : 0;
}
function normalizeOptionalTimestamp(value: unknown) {
const time = timestampMillis(value);
return time > 0 ? new Date(time).toISOString() : null;
}
function latestProjectTimestamp(project: Partial<ProjectRecord>, fallback: string) {
const latestResult = Array.isArray(project.segmentationResults)
? project.segmentationResults[project.segmentationResults.length - 1]
: undefined;
const candidates = [
project.lastProcessedAt,
project.lockedAt,
project.unlockedAt,
latestResult?.createdAt,
project.createTime,
fallback,
];
const latest = Math.max(...candidates.map(timestampMillis));
return new Date(latest || timestampMillis(fallback) || Date.now()).toISOString();
}
function normalizeProjectLockFields(project: Partial<ProjectRecord>, fallback: string) {
const snapshotPath = typeof project.lockedPoseSnapshotPath === 'string' && project.lockedPoseSnapshotPath.trim()
? project.lockedPoseSnapshotPath.trim().slice(0, 240)
: null;
return {
locked: project.locked === true,
lockedAt: normalizeOptionalTimestamp(project.lockedAt),
unlockedAt: normalizeOptionalTimestamp(project.unlockedAt),
lastProcessedAt: latestProjectTimestamp(project, fallback),
lockedPoseSnapshotPath: snapshotPath,
};
}
function projectActivityTime(project: ProjectRecord) {
return timestampMillis(project.lastProcessedAt) || timestampMillis(project.createTime);
}
function sortProjectsByLastProcessed(projects: ProjectRecord[]) {
return [...projects].sort((a, b) => projectActivityTime(b) - projectActivityTime(a));
}
function touchProject(project: ProjectRecord, at = now()) {
project.lastProcessedAt = at;
}
function listFiles(dir: string, extension: string) {
if (!fs.existsSync(dir)) {
return [];
@@ -488,6 +548,7 @@ function normalizeSegmentationResults(
function buildDefaultProject(): ProjectRecord {
const stlFiles = listFiles(modelDir, '.stl');
const createdAt = now();
return {
id: 'head-ct-demo',
@@ -503,6 +564,11 @@ function buildDefaultProject(): ProjectRecord {
maskFormats: ['nii', 'nii.gz'],
exportedMaskCount: 0,
isDefault: true,
locked: false,
lockedAt: null,
unlockedAt: null,
lastProcessedAt: createdAt,
lockedPoseSnapshotPath: null,
moduleStyles: buildModuleStyles(stlFiles),
modelPoses: defaultModelPoses(),
segmentationResults: [],
@@ -510,6 +576,7 @@ function buildDefaultProject(): ProjectRecord {
}
function buildEmptyProject(name: string): ProjectRecord {
const createdAt = now();
return {
id: `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
name,
@@ -523,6 +590,11 @@ function buildEmptyProject(name: string): ProjectRecord {
stlFiles: [],
maskFormats: ['nii', 'nii.gz'],
exportedMaskCount: 0,
locked: false,
lockedAt: null,
unlockedAt: null,
lastProcessedAt: createdAt,
lockedPoseSnapshotPath: null,
moduleStyles: {},
modelPoses: defaultModelPoses(),
segmentationResults: [],
@@ -555,8 +627,12 @@ function normalizeState(state: AppState): AppState {
? listFiles(resolveStoredAssetDir(modelPath, ''), '.stl')
: Array.isArray(project.stlFiles) ? project.stlFiles : [];
const moduleStyles = buildModuleStyles(stlFiles, project.moduleStyles);
const modelPoses = normalizeModelPoses(project.modelPoses);
const segmentationResults = normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount);
const lockFields = normalizeProjectLockFields({ ...project, segmentationResults }, typeof project.createTime === 'string' ? project.createTime : now());
return {
...project,
...lockFields,
dicomPath,
modelPath,
dicomCount,
@@ -566,8 +642,8 @@ function normalizeState(state: AppState): AppState {
exportedMaskCount: project.exportedMaskCount ?? 0,
maskFormats: project.maskFormats ?? ['nii', 'nii.gz'],
moduleStyles,
modelPoses: normalizeModelPoses(project.modelPoses),
segmentationResults: normalizeSegmentationResults(project.segmentationResults, stlFiles, moduleStyles, dicomCount),
modelPoses,
segmentationResults,
};
})
: [];
@@ -576,12 +652,23 @@ function normalizeState(state: AppState): AppState {
const defaultDicomCount = listFiles(resolveStoredAssetDir(defaultDicomPath, dicomDir), '.dcm').length;
const defaultStlFiles = listFiles(resolveStoredAssetDir(defaultModelPath, modelDir), '.stl');
const defaultModuleStyles = buildModuleStyles(defaultStlFiles, savedDefaultProject?.moduleStyles);
const defaultSegmentationResults = normalizeSegmentationResults(
savedDefaultProject?.segmentationResults,
defaultStlFiles,
defaultModuleStyles,
defaultDicomCount,
);
const defaultLockFields = normalizeProjectLockFields(
{ ...savedDefaultProject, segmentationResults: defaultSegmentationResults },
savedDefaultProject?.createTime ?? defaultProject.createTime,
);
return {
...state,
projects: [
{
...defaultProject,
...defaultLockFields,
name: savedDefaultProject?.name ?? defaultProject.name,
dicomPath: defaultDicomPath,
modelPath: defaultModelPath,
@@ -592,12 +679,7 @@ function normalizeState(state: AppState): AppState {
exportedMaskCount: savedDefaultProject?.exportedMaskCount ?? 0,
moduleStyles: defaultModuleStyles,
modelPoses: normalizeModelPoses(savedDefaultProject?.modelPoses),
segmentationResults: normalizeSegmentationResults(
savedDefaultProject?.segmentationResults,
defaultStlFiles,
defaultModuleStyles,
defaultDicomCount,
),
segmentationResults: defaultSegmentationResults,
},
...customProjects,
],
@@ -1726,6 +1808,44 @@ function createPoseExport(project: ProjectRecord, activePose?: ModelPoseValue) {
}, null, 2), 'utf8');
}
function createProjectLockSnapshot(project: ProjectRecord, lockedAt: string) {
const latestResult = latestSegmentationResult(project);
const activePose = latestResult?.pose
?? project.modelPoses.find((pose) => pose.id === 'default')?.pose
?? project.modelPoses[0]?.pose
?? defaultModelPose;
return {
schemaVersion: 1,
lockedAt,
project: {
id: project.id,
name: project.name,
status: project.status,
createTime: project.createTime,
dicomCount: project.dicomCount,
modelCount: project.modelCount,
dicomPath: project.dicomPath,
modelPath: project.modelPath,
stlFiles: project.stlFiles,
},
activePose,
modelPoses: project.modelPoses,
moduleStyles: project.moduleStyles,
latestSegmentationResult: latestResult ?? null,
note: 'This snapshot is written when a project is locked from the project list. It stores ReVoxelSeg pose and style data, not raw DICOM or STL files.',
};
}
function writeProjectLockSnapshot(project: ProjectRecord, lockedAt: string) {
ensureDir(lockedResultDir);
const filename = `${sanitizeFilenamePart(project.name, project.id)}-${timestampForFilename(new Date(lockedAt))}-pose-lock.json`;
const filePath = path.join(lockedResultDir, filename);
const exportProject = projectWithSegmentationResultStyles(project);
fs.writeFileSync(filePath, JSON.stringify(createProjectLockSnapshot(exportProject, lockedAt), null, 2), 'utf8');
return toRepoRelativePath(filePath);
}
function createProjectExportBundle({
project,
files,
@@ -2652,7 +2772,7 @@ async function startServer() {
});
app.get('/api/projects', (_req, res) => {
res.json(readState().projects);
res.json(sortProjectsByLastProcessed(readState().projects));
});
app.post('/api/projects', (req, res) => {
@@ -2693,6 +2813,7 @@ async function startServer() {
}
project.name = name;
touchProject(project);
writeState(state);
res.json(project);
});
@@ -2710,6 +2831,36 @@ async function startServer() {
res.json({ ok: true, deletedId: deleted.id });
});
app.patch('/api/projects/:projectId/lock', (req, res) => {
const state = readState();
const project = findProject(state, req.params.projectId);
if (!project) {
res.status(404).json({ message: '项目不存在' });
return;
}
const shouldLock = req.body?.locked === true;
const changedAt = now();
try {
if (shouldLock) {
project.locked = true;
project.lockedAt = changedAt;
project.unlockedAt = null;
project.lockedPoseSnapshotPath = writeProjectLockSnapshot(project, changedAt);
touchProject(project, changedAt);
} else {
project.locked = false;
project.unlockedAt = changedAt;
touchProject(project, changedAt);
}
writeState(state);
res.json(project);
} catch (error) {
res.status(500).json({ message: error instanceof Error ? error.message : '项目锁定状态更新失败' });
}
});
app.patch('/api/projects/:projectId/module-styles', (req, res) => {
const incoming = req.body?.moduleStyles;
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
@@ -2728,6 +2879,7 @@ async function startServer() {
...(project.moduleStyles ?? {}),
...(incoming as Record<string, Partial<ModuleStyleRecord>>),
});
touchProject(project);
writeState(state);
res.json(project);
});
@@ -2747,6 +2899,7 @@ async function startServer() {
}
project.modelPoses = normalizeModelPoses(incoming as Partial<ModelPoseRecord>[]);
touchProject(project);
writeState(state);
res.json(project);
});
@@ -2822,6 +2975,7 @@ async function startServer() {
}
project.status = project.dicomCount > 0 && project.hasModel ? 'completed' : 'pending';
touchProject(project);
clearProjectRuntimeCaches(project.id);
writeState(state);
res.json(project);
@@ -2878,6 +3032,7 @@ async function startServer() {
record.moduleStyles,
project.dicomCount,
);
touchProject(project, record.createdAt);
writeState(state);
res.status(201).json(project);
});
@@ -3105,6 +3260,7 @@ async function startServer() {
const filename = `${project.id}-${suffix}.${format}`;
fs.writeFileSync(path.join(exportDir, filename), payload);
project.exportedMaskCount += target === 'segmentation' ? 1 : 0;
touchProject(project);
writeState(state);
res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream');
@@ -3156,6 +3312,7 @@ async function startServer() {
const filename = `${exportBase}.tar.gz`;
fs.writeFileSync(path.join(exportDir, filename), payload);
project.exportedMaskCount += targets.includes('segmentation') ? 1 : 0;
touchProject(project);
writeState(state);
res.setHeader('Content-Type', 'application/gzip');

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"

View File

@@ -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) => {

View File

@@ -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',

View File

@@ -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[];