2026-05-20-14-53-31 逆向结果复核与用户管理修复

This commit is contained in:
2026-05-20 15:08:20 +08:00
parent 2a599695e9
commit fd7f3387f7
12 changed files with 886 additions and 111 deletions

View File

@@ -39,6 +39,7 @@ interface ModelPoseRecord {
interface SegmentationResultRecord { interface SegmentationResultRecord {
id: string; id: string;
schemaVersion?: number;
name: string; name: string;
createdAt: string; createdAt: string;
segmentationScope: SegmentationExportScope; segmentationScope: SegmentationExportScope;
@@ -183,6 +184,16 @@ function publicUser(user: UserRecord) {
return rest; return rest;
} }
function parseUserPayload(body: unknown, existing?: UserRecord) {
const source = body && typeof body === 'object' ? body as Record<string, unknown> : {};
const name = typeof source.name === 'string' ? source.name.trim() : existing?.name ?? '';
const account = typeof source.account === 'string' ? source.account.trim() : existing?.account ?? '';
const department = typeof source.department === 'string' ? source.department.trim() : existing?.department ?? '';
const password = typeof source.password === 'string' ? source.password : existing?.password ?? '';
return { name, account, department, password };
}
function publicSession(state: AppState) { function publicSession(state: AppState) {
const user = state.session.account const user = state.session.account
? state.users.find((candidate) => candidate.account === state.session.account) ? state.users.find((candidate) => candidate.account === state.session.account)
@@ -314,6 +325,7 @@ function normalizeSegmentationResults(
); );
return existing return existing
.filter((record) => record?.schemaVersion === 2)
.map((record, index): SegmentationResultRecord => { .map((record, index): SegmentationResultRecord => {
const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles) const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles)
? record.moduleStyles ? record.moduleStyles
@@ -324,6 +336,7 @@ function normalizeSegmentationResults(
id: typeof record?.id === 'string' && record.id.trim() id: typeof record?.id === 'string' && record.id.trim()
? record.id.trim().slice(0, 80) ? record.id.trim().slice(0, 80)
: `segmentation-${index}`, : `segmentation-${index}`,
schemaVersion: 2,
name: '逆向分割结果', name: '逆向分割结果',
createdAt: typeof record?.createdAt === 'string' && record.createdAt.trim() ? record.createdAt : now(), createdAt: typeof record?.createdAt === 'string' && record.createdAt.trim() ? record.createdAt : now(),
segmentationScope: record?.segmentationScope === 'all' ? 'all' : 'visible', segmentationScope: record?.segmentationScope === 'all' ? 'all' : 'visible',
@@ -2001,6 +2014,86 @@ async function startServer() {
res.json(readState().users.map(publicUser)); res.json(readState().users.map(publicUser));
}); });
app.post('/api/users', (req, res) => {
const state = readState();
const payload = parseUserPayload(req.body);
if (!payload.name || !payload.account || !payload.department || !payload.password) {
res.status(400).json({ message: '姓名、账号、科室和密码不能为空' });
return;
}
if (state.users.some((user) => user.account === payload.account)) {
res.status(409).json({ message: '账号已存在' });
return;
}
const nextId = Math.max(0, ...state.users.map((user) => user.id)) + 1;
const user: UserRecord = {
id: nextId,
name: payload.name,
account: payload.account,
password: payload.password,
department: payload.department,
date: today(),
};
state.users.push(user);
writeState(state);
res.status(201).json(publicUser(user));
});
app.patch('/api/users/:userId', (req, res) => {
const state = readState();
const userId = Number.parseInt(req.params.userId, 10);
const user = state.users.find((candidate) => candidate.id === userId);
if (!user) {
res.status(404).json({ message: '用户不存在' });
return;
}
const payload = parseUserPayload(req.body, user);
if (!payload.name || !payload.account || !payload.department || !payload.password) {
res.status(400).json({ message: '姓名、账号、科室和密码不能为空' });
return;
}
if (state.users.some((candidate) => candidate.id !== user.id && candidate.account === payload.account)) {
res.status(409).json({ message: '账号已存在' });
return;
}
const previousAccount = user.account;
user.name = payload.name;
user.account = payload.account;
user.department = payload.department;
user.password = payload.password;
if (state.session.account === previousAccount) {
state.session = { authenticated: true, account: user.account, lastUpdated: now() };
}
writeState(state);
res.json(publicUser(user));
});
app.delete('/api/users/:userId', (req, res) => {
const state = readState();
const userId = Number.parseInt(req.params.userId, 10);
const index = state.users.findIndex((candidate) => candidate.id === userId);
if (index === -1) {
res.status(404).json({ message: '用户不存在' });
return;
}
const user = state.users[index];
if (state.session.account === user.account) {
res.status(400).json({ message: '不能删除当前登录用户' });
return;
}
if (state.users.length <= 1) {
res.status(400).json({ message: '至少保留一个用户' });
return;
}
state.users.splice(index, 1);
writeState(state);
res.json({ ok: true, deletedId: user.id });
});
app.get('/api/projects', (_req, res) => { app.get('/api/projects', (_req, res) => {
res.json(readState().projects); res.json(readState().projects);
}); });
@@ -2118,6 +2211,7 @@ async function startServer() {
: project.moduleStyles; : project.moduleStyles;
const record: SegmentationResultRecord = { const record: SegmentationResultRecord = {
id: `segmentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, id: `segmentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
schemaVersion: 2,
name: rawName || '逆向分割结果', name: rawName || '逆向分割结果',
createdAt: now(), createdAt: now(),
segmentationScope: parseSegmentationScope(req.body?.segmentationScope), segmentationScope: parseSegmentationScope(req.body?.segmentationScope),

View File

@@ -27,6 +27,7 @@ export default function App() {
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo'); const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom'); const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null); const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
const bootSessionResetRef = useRef(false);
// Automatically collapse main sidebar when entering Project Library or Workspace // Automatically collapse main sidebar when entering Project Library or Workspace
useEffect(() => { useEffect(() => {
@@ -42,6 +43,16 @@ export default function App() {
const syncSession = async () => { const syncSession = async () => {
try { try {
if (!bootSessionResetRef.current) {
bootSessionResetRef.current = true;
const session = await api.logout();
if (!mounted) {
return;
}
setIsAuthenticated(session.authenticated);
setActiveView(ViewType.OVERVIEW);
return;
}
const session = await api.getSession(); const session = await api.getSession();
if (!mounted) { if (!mounted) {
return; return;

View File

@@ -43,8 +43,8 @@ export default function Login({ onLogin }: LoginProps) {
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
</div> </div>
<h1 className="text-2xl font-bold leading-tight px-4"></h1> <h1 className="px-2 text-3xl font-bold leading-tight">DICOM分割标注系统</h1>
<p className="text-slate-300 mt-2 font-medium">DICOM分割标注系</p> <p className="mt-3 text-lg font-semibold text-slate-300"></p>
</div> </div>
<form onSubmit={handleSubmit} className="p-8 space-y-6"> <form onSubmit={handleSubmit} className="p-8 space-y-6">

View File

@@ -9,6 +9,7 @@ import {
Box, Box,
Image as ImageIcon, Image as ImageIcon,
Info, Info,
ChevronLeft,
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
@@ -653,6 +654,10 @@ export default function ProjectLibrary({
const [isSliceChanging, setIsSliceChanging] = useState(false); const [isSliceChanging, setIsSliceChanging] = useState(false);
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard'); const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose); const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [resultPose, setResultPose] = useState<ModelPose>(defaultModelPose);
const [resultPreviewSlice, setResultPreviewSlice] = useState(0);
const [resultDisplayMode, setResultDisplayMode] = useState<DisplayMode>('soft');
const [resultRotation, setResultRotation] = useState(0);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({}); const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null); const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [resultDicomPreview, setResultDicomPreview] = useState<DicomPreview | null>(null); const [resultDicomPreview, setResultDicomPreview] = useState<DicomPreview | null>(null);
@@ -747,10 +752,11 @@ export default function ProjectLibrary({
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0]; const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
const savedSegmentationResults = selectedProject?.segmentationResults ?? []; const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1]; const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
const latestResultPose = latestSegmentationResult?.pose ?? modelPose; const latestResultPose = latestSegmentationResult ? resultPose : modelPose;
const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles; const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles;
const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0); const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.mappingSlice ?? resultMaxSlice)); const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice));
const resultFineDetailLimit = solidityOptions.find((option) => option.id === 'fine')?.limit ?? 36000;
const resultVisibleModules = stlFiles const resultVisibleModules = stlFiles
.map((fileName, index) => ({ .map((fileName, index) => ({
fileName, fileName,
@@ -763,7 +769,6 @@ export default function ProjectLibrary({
}, },
})) }))
.filter(({ style }) => style.visible !== false); .filter(({ style }) => style.visible !== false);
const readonlyPoseChange = useMemo<React.Dispatch<React.SetStateAction<ModelPose>>>(() => () => undefined, []);
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({ const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
visible: fallback?.visible ?? true, visible: fallback?.visible ?? true,
@@ -828,6 +833,10 @@ export default function ProjectLibrary({
setModuleStyles(next); setModuleStyles(next);
setSliceIndex(0); setSliceIndex(0);
setModelPose(latestResult?.pose ?? defaultModelPose); setModelPose(latestResult?.pose ?? defaultModelPose);
setResultPose(latestResult?.pose ?? defaultModelPose);
setResultPreviewSlice(Math.max(0, Math.min(Math.max((selectedProject?.dicomCount ?? 1) - 1, 0), latestResult?.mappingSlice ?? 0)));
setResultDisplayMode('soft');
setResultRotation(0);
}, [selectedProject?.id]); }, [selectedProject?.id]);
useEffect(() => { useEffect(() => {
@@ -870,8 +879,8 @@ export default function ProjectLibrary({
let cancelled = false; let cancelled = false;
const maxSlice = Math.max(selectedProject.dicomCount - 1, 0); const maxSlice = Math.max(selectedProject.dicomCount - 1, 0);
const previewSlice = Math.max(0, Math.min(maxSlice, latestSegmentationResult?.mappingSlice ?? maxSlice)); const previewSlice = Math.max(0, Math.min(maxSlice, resultPreviewSlice));
api.getDicomPreview(selectedProject.id, previewSlice, 'axial', 'soft') api.getDicomPreview(selectedProject.id, previewSlice, 'axial', resultDisplayMode)
.then((preview) => { .then((preview) => {
if (!cancelled) { if (!cancelled) {
setResultDicomPreview(preview); setResultDicomPreview(preview);
@@ -886,7 +895,7 @@ export default function ProjectLibrary({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, latestSegmentationResult?.mappingSlice]); }, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultPreviewSlice, resultDisplayMode]);
useEffect(() => () => { useEffect(() => () => {
if (sliceRepeatRef.current !== null) { if (sliceRepeatRef.current !== null) {
@@ -1551,7 +1560,7 @@ export default function ProjectLibrary({
<div className="absolute left-4 top-4 z-10 rounded-xl border border-white/10 bg-black/45 px-3 py-2 backdrop-blur"> <div className="absolute left-4 top-4 z-10 rounded-xl border border-white/10 bg-black/45 px-3 py-2 backdrop-blur">
<p className="text-sm font-bold"></p> <p className="text-sm font-bold"></p>
<p className="mt-1 font-mono text-[10px] text-white/45"> <p className="mt-1 font-mono text-[10px] text-white/45">
{latestSegmentationResult ? `Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'} {latestSegmentationResult ? `模型显示 精细 · 融合显示 DICOM 高 · Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'}
</p> </p>
</div> </div>
{latestSegmentationResult ? ( {latestSegmentationResult ? (
@@ -1559,10 +1568,10 @@ export default function ProjectLibrary({
projectId={selectedProject.id} projectId={selectedProject.id}
files={stlFiles} files={stlFiles}
styles={latestResultStyles} styles={latestResultStyles}
detailLimit={selectedSolidity.limit} detailLimit={resultFineDetailLimit}
solidMode={solidityLevel === 'solid'} solidMode={false}
pose={latestResultPose} pose={latestResultPose}
onPoseChange={readonlyPoseChange} onPoseChange={setResultPose}
/> />
) : ( ) : (
<div className="flex h-full items-center justify-center px-8 text-center text-sm font-bold text-white/35"> <div className="flex h-full items-center justify-center px-8 text-center text-sm font-bold text-white/35">
@@ -1571,7 +1580,8 @@ export default function ProjectLibrary({
)} )}
</div> </div>
<div className="relative min-h-[520px] overflow-hidden rounded-2xl border border-slate-900 bg-black text-white shadow-sm"> <div className="relative flex min-h-[520px] flex-col overflow-hidden rounded-2xl border border-slate-900 bg-black text-white shadow-sm">
<div className="relative min-h-0 flex-1">
<div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2"> <div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2">
<span className="rounded-lg border border-white/10 bg-black/50 px-2.5 py-1 font-mono text-[10px] font-bold text-white/70 backdrop-blur"> <span className="rounded-lg border border-white/10 bg-black/50 px-2.5 py-1 font-mono text-[10px] font-bold text-white/70 backdrop-blur">
BASE DICOM BASE DICOM
@@ -1583,10 +1593,37 @@ export default function ProjectLibrary({
Z {resultMappingSlice + 1}/{selectedProject.dicomCount} Z {resultMappingSlice + 1}/{selectedProject.dicomCount}
</span> </span>
</div> </div>
<div className="absolute right-4 top-4 z-10 flex flex-wrap justify-end gap-1.5">
<div className="flex rounded-lg border border-white/10 bg-black/50 p-1 backdrop-blur">
{displayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setResultDisplayMode(mode.id)}
className={`rounded-md px-2 py-1 text-[9px] font-bold transition ${resultDisplayMode === mode.id ? 'bg-cyan-400 text-slate-950' : 'text-white/55 hover:text-white'}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setResultRotation((value) => (value + 270) % 360)}
className="rounded-lg border border-white/10 bg-black/50 px-2 py-1 text-[9px] font-bold text-white/70 backdrop-blur hover:text-white"
title="左转 90°"
>
<RotateCcw size={12} />
</button>
<button
onClick={() => setResultRotation((value) => (value + 90) % 360)}
className="rounded-lg border border-white/10 bg-black/50 px-2 py-1 text-[9px] font-bold text-white/70 backdrop-blur hover:text-white"
title="右转 90°"
>
<RotateCw size={12} />
</button>
</div>
<div className="absolute inset-0 flex items-center justify-center p-8"> <div className="absolute inset-0 flex items-center justify-center p-8">
{latestSegmentationResult && resultDicomPreview ? ( {latestSegmentationResult && resultDicomPreview ? (
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<DicomCanvas preview={resultDicomPreview} rotation={0} /> <DicomCanvas preview={resultDicomPreview} rotation={resultRotation} />
<div className="pointer-events-none absolute inset-x-10 bottom-10 rounded-2xl border border-white/10 bg-black/55 p-3 backdrop-blur"> <div className="pointer-events-none absolute inset-x-10 bottom-10 rounded-2xl border border-white/10 bg-black/55 p-3 backdrop-blur">
<div className="mb-2 flex items-center justify-between gap-3 text-[11px] font-bold text-white/70"> <div className="mb-2 flex items-center justify-between gap-3 text-[11px] font-bold text-white/70">
<span></span> <span></span>
@@ -1610,6 +1647,43 @@ export default function ProjectLibrary({
)} )}
</div> </div>
</div> </div>
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Slice Navigator</p>
<span className="font-mono text-[10px] font-bold text-cyan-100">
{resultMappingSlice + 1} / {Math.max(selectedProject.dicomCount, 1)}
</span>
</div>
<div className="grid grid-cols-[28px_1fr_28px] items-center gap-3">
<button
onClick={() => setResultPreviewSlice((value) => Math.max(0, value - 1))}
disabled={!latestSegmentationResult || resultMappingSlice <= 0}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="上一层"
>
<ChevronLeft size={15} />
</button>
<input
type="range"
min="0"
max={resultMaxSlice}
value={resultMappingSlice}
disabled={!latestSegmentationResult}
onChange={(event) => setResultPreviewSlice(Number(event.target.value))}
className="h-2 w-full accent-cyan-400 disabled:opacity-35"
aria-label="项目库逆向分割结果切片导航"
/>
<button
onClick={() => setResultPreviewSlice((value) => Math.min(resultMaxSlice, value + 1))}
disabled={!latestSegmentationResult || resultMappingSlice >= resultMaxSlice}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="下一层"
>
<ChevronRight size={15} />
</button>
</div>
</div>
</div>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">

View File

@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Settings2, Settings2,
Download, Download,
RotateCcw,
RotateCw,
Rotate3d, Rotate3d,
AlertCircle, AlertCircle,
ChevronLeft, ChevronLeft,
@@ -28,6 +30,7 @@ interface ModelPreviewPayload {
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
type DicomOpacityLevel = 'low' | 'medium' | 'high'; type DicomOpacityLevel = 'low' | 'medium' | 'high';
type MappingDisplayMode = DicomPreview['mode'];
type ModelPoseKey = keyof ModelPose; type ModelPoseKey = keyof ModelPose;
type PoseDraftValues = Record<ModelPoseKey, string>; type PoseDraftValues = Record<ModelPoseKey, string>;
type AxisKey = 'x' | 'y' | 'z'; type AxisKey = 'x' | 'y' | 'z';
@@ -54,6 +57,12 @@ const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOp
{ id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 }, { id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 },
{ id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 }, { id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 },
]; ];
const mappingDisplayModes: Array<{ id: MappingDisplayMode; label: string }> = [
{ id: 'default', label: '默认' },
{ id: 'bone', label: '骨窗' },
{ id: 'soft', label: '软组织' },
{ id: 'contrast', label: '高对比' },
];
const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = { const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = {
rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
@@ -207,6 +216,41 @@ function poseValuesMatch(left: ModelPose, right: ModelPose) {
return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6); return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6);
} }
function stableModuleStyles(styles: Record<string, ModuleStyle>) {
return Object.keys(styles)
.sort((left, right) => left.localeCompare(right, 'zh-Hans-CN'))
.reduce<Record<string, ModuleStyle>>((accumulator, key) => {
accumulator[key] = styles[key];
return accumulator;
}, {});
}
function createWorkspaceSnapshot(input: {
modelPose: ModelPose;
segmentationExportScope: SegmentationExportScope;
moduleStyles: Record<string, ModuleStyle>;
sliceStart: number;
sliceEnd: number;
mappingSlice: number;
displayLevel: DisplayLevel;
dicomOpacityLevel: DicomOpacityLevel;
showBounds: boolean;
cutEnabled: boolean;
}) {
return JSON.stringify({
modelPose: input.modelPose,
segmentationExportScope: input.segmentationExportScope,
moduleStyles: stableModuleStyles(input.moduleStyles),
sliceStart: input.sliceStart,
sliceEnd: input.sliceEnd,
mappingSlice: input.mappingSlice,
displayLevel: input.displayLevel,
dicomOpacityLevel: input.dicomOpacityLevel,
showBounds: input.showBounds,
cutEnabled: input.cutEnabled,
});
}
function parseImportedPosePayload(payload: unknown) { function parseImportedPosePayload(payload: unknown) {
const record = isRecord(payload) ? payload : {}; const record = isRecord(payload) ? payload : {};
const importedModelPoses = normalizeImportedModelPoses(record.modelPoses); const importedModelPoses = normalizeImportedModelPoses(record.modelPoses);
@@ -1701,6 +1745,8 @@ function VoxelizationMappingView({
slice, slice,
totalSlices, totalSlices,
onSliceChange, onSliceChange,
displayMode,
rotation,
}: { }: {
project: Project | null; project: Project | null;
moduleStyles: Record<string, ModuleStyle>; moduleStyles: Record<string, ModuleStyle>;
@@ -1709,6 +1755,8 @@ function VoxelizationMappingView({
slice: number; slice: number;
totalSlices: number; totalSlices: number;
onSliceChange: (slice: number) => void; onSliceChange: (slice: number) => void;
displayMode: MappingDisplayMode;
rotation: number;
}) { }) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null); const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null); const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -1731,7 +1779,7 @@ function VoxelizationMappingView({
let disposed = false; let disposed = false;
setDicomStatus('正在载入 DICOM Base Layer...'); setDicomStatus('正在载入 DICOM Base Layer...');
api.getDicomPreview(project.id, safeSlice, 'axial', 'soft') api.getDicomPreview(project.id, safeSlice, 'axial', displayMode)
.then((preview) => { .then((preview) => {
if (disposed) return; if (disposed) return;
setDicomPreview(preview); setDicomPreview(preview);
@@ -1746,7 +1794,7 @@ function VoxelizationMappingView({
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [project?.id, project?.dicomCount, safeSlice]); }, [project?.id, project?.dicomCount, safeSlice, displayMode]);
useEffect(() => { useEffect(() => {
if (!project || !stlFiles.length) { if (!project || !stlFiles.length) {
@@ -1847,7 +1895,10 @@ function VoxelizationMappingView({
Z {safeSlice + 1}/{Math.max(totalSlices, 1)} Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
</div> </div>
{dicomPreview ? ( {dicomPreview ? (
<div className="absolute inset-0 flex items-center justify-center"> <div
className="absolute inset-0 flex items-center justify-center"
style={{ transform: `rotate(${rotation}deg)` }}
>
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" /> <canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" /> <canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
</div> </div>
@@ -1947,6 +1998,8 @@ export default function ReverseWorkspace({
const [poseImportStatus, setPoseImportStatus] = useState(''); const [poseImportStatus, setPoseImportStatus] = useState('');
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard'); const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low'); const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
const [mappingDisplayMode, setMappingDisplayMode] = useState<MappingDisplayMode>('soft');
const [mappingRotation, setMappingRotation] = useState(0);
const [showBounds, setShowBounds] = useState(true); const [showBounds, setShowBounds] = useState(true);
const [cutEnabled, setCutEnabled] = useState(false); const [cutEnabled, setCutEnabled] = useState(false);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({}); const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
@@ -1969,6 +2022,7 @@ export default function ReverseWorkspace({
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const poseImportInputRef = useRef<HTMLInputElement | null>(null); const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const saveToastTimerRef = useRef<number | null>(null); const saveToastTimerRef = useRef<number | null>(null);
const savedWorkspaceSnapshotRef = useRef('');
const handleExportSelected = async () => { const handleExportSelected = async () => {
const selectedItems = exportOptions const selectedItems = exportOptions
@@ -1994,6 +2048,30 @@ export default function ReverseWorkspace({
} }
}; };
const getCurrentWorkspaceSnapshot = useCallback(() => createWorkspaceSnapshot({
modelPose,
segmentationExportScope,
moduleStyles,
sliceStart,
sliceEnd,
mappingSlice,
displayLevel,
dicomOpacityLevel,
showBounds,
cutEnabled,
}), [
modelPose,
segmentationExportScope,
moduleStyles,
sliceStart,
sliceEnd,
mappingSlice,
displayLevel,
dicomOpacityLevel,
showBounds,
cutEnabled,
]);
const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => { const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
if (!project) { if (!project) {
return false; return false;
@@ -2016,6 +2094,7 @@ export default function ReverseWorkspace({
cutEnabled, cutEnabled,
}); });
setProject(updated); setProject(updated);
savedWorkspaceSnapshotRef.current = getCurrentWorkspaceSnapshot();
if (options.showToast !== false) { if (options.showToast !== false) {
setSaveStatus('已保存至项目库的分割结果区域'); setSaveStatus('已保存至项目库的分割结果区域');
} }
@@ -2040,6 +2119,7 @@ export default function ReverseWorkspace({
dicomOpacityLevel, dicomOpacityLevel,
showBounds, showBounds,
cutEnabled, cutEnabled,
getCurrentWorkspaceSnapshot,
]); ]);
useEffect(() => { useEffect(() => {
@@ -2072,7 +2152,10 @@ export default function ReverseWorkspace({
if (!project) { if (!project) {
return true; return true;
} }
const shouldSave = window.confirm('是否保存当前结果至项目库?\\n确定保存后退出。\\n取消直接退出不保存当前结果。'); if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) {
return true;
}
const shouldSave = window.confirm('是否保存当前结果至项目库? 确定:保存后退出。取消:直接退出,不保存当前结果。');
if (!shouldSave) { if (!shouldSave) {
return true; return true;
} }
@@ -2080,7 +2163,7 @@ export default function ReverseWorkspace({
}); });
return () => onLeaveGuardChange(null); return () => onLeaveGuardChange(null);
}, [handleSaveSegmentationResult, onLeaveGuardChange, project]); }, [getCurrentWorkspaceSnapshot, handleSaveSegmentationResult, onLeaveGuardChange, project]);
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({ const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
visible: fallback?.visible ?? true, visible: fallback?.visible ?? true,
@@ -2149,11 +2232,26 @@ export default function ReverseWorkspace({
setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible'); setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible');
setDisplayLevel(latestResult?.displayLevel ?? 'standard'); setDisplayLevel(latestResult?.displayLevel ?? 'standard');
setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low'); setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low');
setMappingDisplayMode('soft');
setMappingRotation(0);
setShowBounds(latestResult?.showBounds ?? true); setShowBounds(latestResult?.showBounds ?? true);
setCutEnabled(latestResult?.cutEnabled ?? false); setCutEnabled(latestResult?.cutEnabled ?? false);
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
modelPose: restoredPose,
segmentationExportScope: latestResult?.segmentationScope ?? 'visible',
moduleStyles: nextStyles,
sliceStart: restoredSliceStart,
sliceEnd: restoredSliceEnd,
mappingSlice: restoredMappingSlice,
displayLevel: latestResult?.displayLevel ?? 'standard',
dicomOpacityLevel: latestResult?.dicomOpacityLevel ?? 'low',
showBounds: latestResult?.showBounds ?? true,
cutEnabled: latestResult?.cutEnabled ?? false,
});
}).catch(() => { }).catch(() => {
setProject(null); setProject(null);
setFusionVolume(null); setFusionVolume(null);
savedWorkspaceSnapshotRef.current = '';
}); });
}, [projectId]); }, [projectId]);
@@ -2890,11 +2988,40 @@ export default function ReverseWorkspace({
</div> </div>
<div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden"> <div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0"> <div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2"> <h3 className="font-bold text-slate-700 flex items-center gap-2">
<Layers size={18} className="text-cyan-500" /> <Layers size={18} className="text-cyan-500" />
</h3> </h3>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<div className="flex rounded-xl bg-slate-100 p-1">
{mappingDisplayModes.map((mode) => (
<button
key={mode.id}
onClick={() => setMappingDisplayMode(mode.id)}
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
mappingDisplayMode === mode.id ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{mode.label}
</button>
))}
</div>
<button
onClick={() => setMappingRotation((value) => (value + 270) % 360)}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
title="左转 90°"
>
<RotateCcw size={14} />
</button>
<button
onClick={() => setMappingRotation((value) => (value + 90) % 360)}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
title="右转 90°"
>
<RotateCw size={14} />
</button>
</div>
</div> </div>
<VoxelizationMappingView <VoxelizationMappingView
@@ -2905,6 +3032,8 @@ export default function ReverseWorkspace({
slice={safeMappingSlice} slice={safeMappingSlice}
totalSlices={project?.dicomCount ?? 0} totalSlices={project?.dicomCount ?? 0}
onSliceChange={setMappingSlice} onSliceChange={setMappingSlice}
displayMode={mappingDisplayMode}
rotation={mappingRotation}
/> />
</div> </div>
</div> </div>

View File

@@ -1,39 +1,176 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { import {
Users, Users,
UserPlus, UserPlus,
Search, Search,
MoreVertical,
Shield, Shield,
Calendar, Calendar,
RotateCcw, RotateCcw,
Edit2, Edit2,
Trash2, Trash2,
Key Key,
X,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { UserRecord } from '../types'; import { SessionState, UserRecord } from '../types';
type UserFormMode = 'create' | 'edit' | 'password';
interface UserFormState {
id?: number;
name: string;
account: string;
department: string;
password: string;
}
const emptyUserForm: UserFormState = {
name: '',
account: '',
department: '',
password: '',
};
export default function UserManagement() { export default function UserManagement() {
const [users, setUsers] = useState<UserRecord[]>([]); const [users, setUsers] = useState<UserRecord[]>([]);
const [session, setSession] = useState<SessionState | null>(null);
const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步'); const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步');
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [search, setSearch] = useState('');
const [formMode, setFormMode] = useState<UserFormMode | null>(null);
const [form, setForm] = useState<UserFormState>(emptyUserForm);
const [saving, setSaving] = useState(false);
const refreshUsers = () => { const refreshUsers = async () => {
api.getUsers().then(setUsers).catch(() => setMessage('用户列表同步失败')); try {
const [items, currentSession] = await Promise.all([api.getUsers(), api.getSession()]);
setUsers(items);
setSession(currentSession);
} catch (error) {
setMessage(error instanceof Error ? error.message : '用户列表同步失败');
}
}; };
useEffect(() => { useEffect(() => {
refreshUsers(); void refreshUsers();
}, []); }, []);
const filteredUsers = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return users;
}
return users.filter((user) => (
user.name.toLowerCase().includes(keyword)
|| user.account.toLowerCase().includes(keyword)
|| user.department.toLowerCase().includes(keyword)
));
}, [users, search]);
const currentAccount = session?.currentUser?.account ?? '';
const openCreateForm = () => {
setForm(emptyUserForm);
setFormMode('create');
setMessage('正在新增系统用户');
};
const openEditForm = (user: UserRecord) => {
setForm({
id: user.id,
name: user.name,
account: user.account,
department: user.department,
password: '',
});
setFormMode('edit');
setMessage(`正在编辑用户:${user.name}`);
};
const openPasswordForm = (user: UserRecord) => {
setForm({
id: user.id,
name: user.name,
account: user.account,
department: user.department,
password: '',
});
setFormMode('password');
setMessage(`正在修改密码:${user.name}`);
};
const closeForm = () => {
setFormMode(null);
setForm(emptyUserForm);
setSaving(false);
};
const handleSaveUser = async () => {
if (!formMode) {
return;
}
const name = form.name.trim();
const account = form.account.trim();
const department = form.department.trim();
const password = form.password.trim();
if (!name || !account || !department || (formMode === 'create' && !password)) {
setMessage('姓名、账号、科室不能为空;新增用户必须填写密码');
return;
}
if (formMode === 'password' && !password) {
setMessage('请输入新密码');
return;
}
setSaving(true);
try {
if (formMode === 'create') {
await api.createUser({ name, account, department, password });
setMessage(`已添加用户:${name}`);
} else if (form.id) {
await api.updateUser(form.id, {
name,
account,
department,
...(password ? { password } : {}),
});
setMessage(formMode === 'password' ? `已更新 ${name} 的密码` : `已更新用户:${name}`);
}
closeForm();
await refreshUsers();
} catch (error) {
setMessage(error instanceof Error ? error.message : '用户保存失败');
setSaving(false);
}
};
const handleDeleteUser = async (user: UserRecord) => {
if (user.account === currentAccount) {
setMessage('不能删除当前登录用户');
return;
}
const confirmed = window.confirm(`确认删除用户 ${user.name}?该操作不可恢复。`);
if (!confirmed) {
return;
}
try {
await api.deleteUser(user.id);
setMessage(`已删除用户:${user.name}`);
await refreshUsers();
} catch (error) {
setMessage(error instanceof Error ? error.message : '删除用户失败');
}
};
const handleReset = async () => { const handleReset = async () => {
setResetting(true); setResetting(true);
setMessage('正在恢复演示环境...'); setMessage('正在恢复演示环境...');
try { try {
const result = await api.resetDemo(); const result = await api.resetDemo();
setUsers(result.users); setUsers(result.users);
setSession(await api.getSession());
setMessage('演示环境已恢复默认用户、Head_CT_DICOM 与 Head_CT_ReConstruct 项目已重新载入'); setMessage('演示环境已恢复默认用户、Head_CT_DICOM 与 Head_CT_ReConstruct 项目已重新载入');
} catch (err) { } catch (err) {
setMessage(err instanceof Error ? err.message : '恢复失败'); setMessage(err instanceof Error ? err.message : '恢复失败');
@@ -44,44 +181,52 @@ export default function UserManagement() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h2 className="text-2xl font-bold text-slate-800"></h2> <h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="text-slate-500 mt-1">{message}</p> <p className="mt-1 text-slate-500">{message}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={handleReset} onClick={handleReset}
disabled={resetting} disabled={resetting}
className="bg-amber-100 text-amber-700 px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-amber-200 transition-all flex items-center gap-2 border border-amber-200 disabled:opacity-50" className="flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-100 px-5 py-2.5 text-sm font-semibold text-amber-700 transition-all hover:bg-amber-200 disabled:opacity-50"
> >
<RotateCcw size={18} /> <RotateCcw size={18} />
{resetting ? '正在恢复' : '恢复演示环境出厂设置'} {resetting ? '正在恢复' : '恢复演示环境出厂设置'}
</button> </button>
<button className="bg-blue-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-blue-700 transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2"> <button
onClick={openCreateForm}
className="flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-blue-500/20 transition-all hover:bg-blue-700"
>
<UserPlus size={18} /> <UserPlus size={18} />
</button> </button>
</div> </div>
</div> </div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden flex flex-col"> <div className="flex flex-col overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm">
<div className="p-4 border-b border-slate-100 flex items-center gap-4"> <div className="flex items-center gap-4 border-b border-slate-100 p-4">
<div className="flex-1 relative"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input <input
type="text" type="text"
placeholder="搜索用户名、账号、科室..." placeholder="搜索用户名、账号、科室..."
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 transition-all outline-none text-sm" value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full rounded-xl border border-slate-200 py-2 pl-10 pr-4 text-sm outline-none transition-all focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
<button className="text-slate-500 hover:text-slate-700 p-2"><Shield size={20} /></button> <div className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-2 text-xs font-bold text-slate-500">
<Shield size={16} />
{currentAccount || '未同步'}
</div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="bg-slate-50 text-slate-500 text-xs font-bold uppercase tracking-wider"> <tr className="bg-slate-50 text-xs font-bold uppercase tracking-wider text-slate-500">
<th className="px-6 py-4"></th> <th className="px-6 py-4"></th>
<th className="px-6 py-4"></th> <th className="px-6 py-4"></th>
<th className="px-6 py-4"></th> <th className="px-6 py-4"></th>
@@ -90,69 +235,141 @@ export default function UserManagement() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
{users.map((user, i) => ( {filteredUsers.map((user, index) => {
const isCurrentUser = user.account === currentAccount;
return (
<motion.tr <motion.tr
key={user.id} key={user.id}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }} transition={{ delay: index * 0.03 }}
className="hover:bg-slate-50/50 transition-colors" className="transition-colors hover:bg-slate-50/50"
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 font-bold text-blue-600">
{user.name[0]} {user.name[0]}
</div> </div>
<div> <div>
<p className="font-bold text-slate-800">{user.name}</p> <p className="font-bold text-slate-800">{user.name}</p>
{isCurrentUser && <p className="text-[10px] font-bold text-blue-600"></p>}
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 px-6 text-sm font-mono text-slate-500"> <td className="px-6 py-4 font-mono text-sm text-slate-500">{user.account}</td>
{user.account}
</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="text-sm text-slate-600 font-medium px-2 py-1 bg-slate-100 rounded-lg"> <span className="rounded-lg bg-slate-100 px-2 py-1 text-sm font-medium text-slate-600">
{user.department} {user.department}
</span> </span>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="text-sm text-slate-500 flex items-center gap-2"> <p className="flex items-center gap-2 text-sm text-slate-500">
<Calendar size={14} /> <Calendar size={14} />
{user.date} {user.date}
</p> </p>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<button className="text-slate-400 hover:text-blue-600 transition-colors flex items-center gap-1 text-xs" title="编辑信息"> <button
onClick={() => openEditForm(user)}
className="flex items-center gap-1 text-xs text-slate-500 transition-colors hover:text-blue-600"
title="编辑信息"
>
<Edit2 size={16} /> <Edit2 size={16} />
</button> </button>
<div className="h-4 w-[1px] bg-slate-200" /> <div className="h-4 w-[1px] bg-slate-200" />
<button className="text-slate-400 hover:text-amber-600 transition-colors" title="修改密码"> <button
onClick={() => openPasswordForm(user)}
className="text-slate-400 transition-colors hover:text-amber-600"
title="修改密码"
>
<Key size={16} /> <Key size={16} />
</button> </button>
<button className="text-slate-400 hover:text-rose-600 transition-colors" title="删除用户"> <button
onClick={() => handleDeleteUser(user)}
disabled={isCurrentUser}
className="text-slate-400 transition-colors hover:text-rose-600 disabled:cursor-not-allowed disabled:opacity-35"
title={isCurrentUser ? '不能删除当前登录用户' : '删除用户'}
>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
<button className="text-slate-300 hover:text-slate-600 transition-colors">
<MoreVertical size={18} />
</button>
</div> </div>
</td> </td>
</motion.tr> </motion.tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="p-4 border-t border-slate-50 flex items-center justify-between text-sm text-slate-500 bg-slate-50/50"> <div className="flex items-center justify-between border-t border-slate-50 bg-slate-50/50 p-4 text-sm text-slate-500">
<p> {users.length} </p> <p> {filteredUsers.length} / {users.length} </p>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<button className="px-3 py-1 border border-blue-500 rounded bg-blue-500 text-white">1</button> <Users size={16} />
</div> </div>
</div> </div>
</div> </div>
{formMode && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h3 className="font-bold text-slate-900">
{formMode === 'create' && '添加用户'}
{formMode === 'edit' && '编辑用户'}
{formMode === 'password' && '修改密码'}
</h3>
<button onClick={closeForm} className="text-slate-400 hover:text-slate-700" title="关闭">
<X size={18} />
</button>
</div>
<div className="space-y-3">
<input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
disabled={formMode === 'password'}
placeholder="姓名"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
/>
<input
value={form.account}
onChange={(event) => setForm((current) => ({ ...current, account: event.target.value }))}
disabled={formMode === 'password'}
placeholder="账号"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
/>
<input
value={form.department}
onChange={(event) => setForm((current) => ({ ...current, department: event.target.value }))}
disabled={formMode === 'password'}
placeholder="科室"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
/>
<input
type="password"
value={form.password}
onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))}
placeholder={formMode === 'edit' ? '新密码,留空则不修改' : '密码'}
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mt-6 flex justify-end gap-3">
<button onClick={closeForm} className="rounded-xl px-4 py-2 text-sm font-bold text-slate-600 hover:bg-slate-100">
</button>
<button
onClick={handleSaveUser}
disabled={saving}
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? '保存中' : '保存'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -89,6 +89,20 @@ export const api = {
request<DicomFusionVolume>(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`), request<DicomFusionVolume>(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`),
getDicomInfo: (projectId: string) => request<DicomInfo>(`/api/projects/${projectId}/dicom-info`), getDicomInfo: (projectId: string) => request<DicomInfo>(`/api/projects/${projectId}/dicom-info`),
getUsers: () => request<UserRecord[]>('/api/users'), getUsers: () => request<UserRecord[]>('/api/users'),
createUser: (payload: { name: string; account: string; department: string; password: string }) =>
request<UserRecord>('/api/users', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateUser: (userId: number, payload: { name: string; account: string; department: string; password?: string }) =>
request<UserRecord>(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteUser: (userId: number) =>
request<{ ok: boolean; deletedId: number }>(`/api/users/${userId}`, {
method: 'DELETE',
}),
resetDemo: () => resetDemo: () =>
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', { request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {
method: 'POST', method: 'POST',

View File

@@ -54,6 +54,7 @@ export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
export interface SegmentationResult { export interface SegmentationResult {
id: string; id: string;
schemaVersion?: number;
name: string; name: string;
createdAt: string; createdAt: string;
segmentationScope: SegmentationExportScope; segmentationScope: SegmentationExportScope;

View File

@@ -0,0 +1,83 @@
# 实现方案:逆向结果复核增强与管理功能修复
实现方案文档路径:`工程分析/实现方案-2026-05-20-14-53-31.md`
## 修改目标
增强项目库逆向分割结果的复核交互,补齐逆向工作区映射视图 DICOM 控制,优化离开保存提示逻辑,修复起始页标题和系统管理用户操作。
## 涉及路径
- `WebSite/src/App.tsx`
- `WebSite/src/components/Login.tsx`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/UserManagement.tsx`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/server.ts`
- `工程分析/需求分析-2026-05-20-14-53-31.md`
- `工程分析/实现方案-2026-05-20-14-53-31.md`
- `工程分析/测试方案-2026-05-20-14-53-31.md`
- `工程分析/经验记录.md`
## 技术路线
1. 检查登录页、主入口、项目库、逆向工作区和用户管理现有实现。
2. 后端项目状态归一化时默认项目不再继承旧保存结果,确保初始逆向分割结果为空。
3. 项目库逆向分割结果页复用 `NativeStlViewer` 提供可拖拽融合视图,固定精细模型显示和 DICOM 高融合语义DICOM+分割卡片加入横向 Slice Navigator、DICOM 显示模式和旋转控制。
4. 逆向工作区给映射视图加入 DICOM 显示模式与旋转状态,并用该状态请求/渲染预览。
5. 逆向工作区保存后建立状态快照,只有当前可视化工具栏状态与快照不同才注册离开确认;确认文本改成单行语义。
6. 起始页标题顺序调整;根地址访问时按最初起始页处理共享会话。
7. 用户管理前后端补齐新增/更新/删除接口能力,前端禁止删除当前用户。
8. 运行类型检查、构建、服务部署与接口验证。
## 执行步骤
- 阅读相关源码与 API 封装。
- 编写项目状态、API 与用户管理后端补丁。
- 编写项目库和逆向工作区 UI/交互补丁。
- 修复登录页标题与主入口初始会话策略。
- 更新测试方案实际执行记录和经验记录。
- 精确暂存本轮文件,提交并推送 Gitea。
## 兼容性与回滚方案
- 旧项目若已有保存结果,保留用户保存结果;默认演示项目初始状态按空结果处理。
- 用户管理新增接口只操作 `WebSite/data/state.json` 中用户列表,不影响 DICOM/STL 原始数据。
- 若项目库预览异常,仍可进入逆向工作区重新保存结果并导出。
## 预计文件变更
程序文件:
- `WebSite/src/App.tsx`
- `WebSite/src/components/Login.tsx`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/UserManagement.tsx`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/server.ts`
工程分析:
- 本轮三份分析文档。
- `工程分析/经验记录.md`
## 提交与部署策略
- 暂存本轮程序改动与工程分析文档。
- 避免提交软著材料、历史删除状态和运行态导出文件。
- commit message 包含 `2026-05-20-14-53-31`
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`,验证 4000 端口服务。
## 实际实现记录
- 项目库逆向分割结果页新增结果 DICOM 显示模式、旋转控制和横向 Slice Navigator。
- 项目库融合视角改为可拖拽结果位姿预览,并固定使用精细模型语义。
- 后端分割结果记录增加 `schemaVersion`,过滤旧演示结果,默认项目初始结果为空;新保存结果继续持久化。
- 逆向工作区映射视图新增默认/骨窗/软组织/高对比和左转/右转控制。
- 逆向工作区新增保存快照判断,仅在保存相关状态变化后离开才弹窗。
- 根页面启动时清理共享会话,显示起始登录页;登录页标题改为全称在上、简称在下。
- 系统管理补齐用户新增、编辑、改密、删除接口和前端表单,并禁止删除当前登录用户。

View File

@@ -0,0 +1,62 @@
# 测试方案:逆向结果复核、起始页与用户管理验证
测试方案文档路径:`工程分析/测试方案-2026-05-20-14-53-31.md`
## 静态检查
- 搜索确认项目库逆向分割结果含横向 Slice Navigator、DICOM 模式和旋转控制。
- 搜索确认逆向工作区映射视图含 DICOM 模式和左转/右转控制。
- 搜索确认离开确认文本不含裸 `\\n`
- 检查用户管理前端按钮与后端接口路径一致。
- 检查默认项目状态不会自动带入旧逆向分割结果。
## 构建检查
-`WebSite/` 执行 `npm run lint`
-`WebSite/` 执行 `npm run build`
## 关键业务场景验证
- 访问根地址,确认显示起始页而非直接进入工作台。
- 起始页标题全称在上且更醒目,简称在下。
- 项目库“逆向分割结果”未保存时为空状态;保存后显示可拖拽融合视角和 DICOM+分割预览。
- 项目库 DICOM+分割预览可切换默认/骨窗/软组织/高对比,并可左转右转、拖动 Slice Navigator。
- 逆向工作区映射视图可切换 DICOM 模式和旋转。
- 未修改或刚保存后离开逆向工作区不弹保存确认;修改可视化工具栏后离开才弹确认。
- 系统管理中新增用户可用,删除当前用户被阻止,删除其他用户可用。
## 医学影像数据相关边界验证
- 不修改 DICOM/STL 原始文件。
- 不伪造未保存项目的逆向分割结果。
- DICOM 显示模式只影响预览和映射底图,不改变导出数据。
## 部署验证
- 验证 `http://127.0.0.1:4000/api/health`
- 验证 `http://127.0.0.1:4000/` 返回 200。
- 验证 `/api/projects` 默认项目逆向分割结果为空或仅在用户保存后存在。
## Git/Gitea 备份验证
- commit message 包含 `2026-05-20-14-53-31`
- 推送 Gitea 成功后记录 commit。
- 确认未暂存软著材料、历史删除状态和无关运行态文件。
## 风险与回归关注点
- 根地址强制显示起始页可能影响共享会话自动进入工作台的习惯,需要以用户本轮需求为准。
- 用户管理删除接口必须保护当前用户和最后一个管理员。
- 保存脏状态需要覆盖位姿、构件样式、切片范围、DICOM 显示模式等关键状态。
## 实际执行记录
- 已执行 `npm run lint`TypeScript 检查通过。
- 已执行 `npm run build`,生产构建通过;仅保留 Vite 大 chunk 体积提示。
- 已执行 `git diff --check`,未发现空白错误。
- 已执行静态搜索,确认旧文案 `导出全部`、裸 `\\n` 保存提示、单独 NII 下载入口未在目标源码中残留。
- 已重启 `tmux` 会话 `revoxelseg-dicom`,服务监听 `http://0.0.0.0:4000/`
- 已验证 `http://127.0.0.1:4000/api/health` 返回 `ok: true`
- 已验证 `http://127.0.0.1:4000/` 返回 HTTP 200。
- 已验证默认项目 `head-ct-demo``segmentationResults` 返回 0 条,初始逆向分割结果为空。
- 已通过接口验证用户管理:新增临时用户、编辑科室、删除临时用户成功;删除当前登录用户返回 400。

View File

@@ -1261,3 +1261,21 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
凡是把“历史记录”改为“当前唯一状态”的需求,都要同步检查数据模型、归一化逻辑、保存接口、列表 UI、导出默认参数和重新进入页面的恢复逻辑。提交前用 `rg` 搜索旧文案和旧入口,避免残留重复下载按钮或旧命名造成用户误解。 凡是把“历史记录”改为“当前唯一状态”的需求,都要同步检查数据模型、归一化逻辑、保存接口、列表 UI、导出默认参数和重新进入页面的恢复逻辑。提交前用 `rg` 搜索旧文案和旧入口,避免残留重复下载按钮或旧命名造成用户误解。
## 2026-05-20-14-53-31 保存提示与管理操作需要区分状态语义
A. 具体问题
用户要求逆向工作区在未修改或刚保存后离开时不再弹出保存确认,同时系统管理工作区的添加、编辑、删除用户按钮必须真正可用,并且不能删除当前登录用户。
B. 产生问题原因
上一轮实现的离开守卫只知道“正在离开逆向工作区”,不知道当前可视化状态是否已经保存,因此无条件弹窗;系统管理页原先只实现了用户列表和重置演示环境,操作按钮没有对应 API 和表单逻辑。
C. 解决问题方案
为逆向工作区建立保存快照快照覆盖位姿、构件样式、切片范围、映射切片、模型显示、DICOM 融合强度、边界和切分状态;项目加载和保存成功后刷新快照,离开时只有当前快照与保存快照不同才弹确认。后端补齐 `/api/users` 的新增、编辑和删除接口,删除时拒绝当前登录用户;前端系统管理页改为真实表单和操作流。
D. 后续如何避免问题
凡是“离开前确认保存”类交互,都要先定义脏状态快照,不能只按页面离开事件弹窗。后台管理按钮必须有明确接口、成功/失败反馈和权限边界;至少要覆盖新增、编辑、删除自己、删除其他用户四个验证场景。

View File

@@ -0,0 +1,72 @@
# 需求分析:逆向分割结果交互、起始页与用户管理修复
开始时间:`2026-05-20-14-53-31`
## 原始需求摘要
用户要求修改程序:
1. 项目库“逆向分割结果”中的结果视图支持拖拽查看左侧融合视角复刻逆向工作区“影像与模型融合视角”的模型显示精细、DICOM 高融合场景;中间 DICOM+分割视图新增横向 Slice Navigator项目初始状态下逆向分割结果为空。
2. 逆向工作区“逆向分割映射视图”旁边新增 DICOM 可视化方式选择:默认、骨窗、软组织、高对比,并支持左转、右转。
3. 若用户未修改,或保存至项目库后未再修改可视化工具栏内容,则退出逆向工作区或跳转其他页面时不再弹出保存确认;弹窗文本中的 `\n` 不应以字符形式出现。
4. 访问 `http://192.168.3.11:4000/` 应进入最初起始页。
5. 起始页中“基于模型逆向体素化及DICOM分割标注系统”和“模型逆向系统”文字顺序调换全称放大置上简称放下。
6. 修复系统管理工作区用户操作:不能删除自己,添加用户等操作需要可用。
## 业务目标
- 让项目库的逆向分割结果更接近工作区复核体验,保存后可继续拖拽观察融合模型,并可横向浏览 DICOM+分割切片。
- 避免无修改状态下反复弹出保存确认,提高逆向工作区切换效率。
- 统一访问根地址时的入口体验,确保回到最初起始页。
- 修复用户管理后台的新增、删除等核心管理能力。
## 输入与输出
输入:
- `WebSite/src/App.tsx`
- `WebSite/src/components/Login.tsx`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/UserManagement.tsx`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/server.ts`
输出:
- 项目库逆向分割结果支持拖拽融合视角、DICOM 模式/旋转、横向 Slice Navigator。
- 初始项目无保存结果时,逆向分割结果显示空状态,不伪造旧结果。
- 逆向工作区映射视图支持 DICOM 模式选择和旋转。
- 离开工作区仅在存在未保存修改时弹窗,弹窗文本无裸 `\n`
- 根地址访问回到起始页,起始页标题顺序调整。
- 用户管理的新增/删除/权限限制修复。
## 影响范围
- 前端主入口与登录/起始页体验。
- 项目库逆向分割结果页。
- 逆向工作区保存状态追踪与映射视图控制。
- 用户管理 API 与前端交互。
- 项目状态归一化逻辑。
## 关键约束
- 不伪造医学分割结果;未保存时必须明确为空。
- DICOM 可视化模式与旋转应复用已有 `dicom-preview` 能力。
- 保存确认只基于逆向工作区可视化工具栏关键状态变化,不应因普通页面切换重复打扰。
- 不能删除当前登录用户。
- 文档、程序和部署仍需按工程工作流提交备份。
## 风险点
- 项目库预览使用 Three.js 和 DICOM Canvas布局与拖拽需避免遮挡导出面板。
- 保存脏状态判断若过宽会误弹窗,过窄会漏保存关键位姿/样式。
- 清空默认保存结果会影响当前演示项目已有状态,需要后端默认项目状态归一化兼容。
- 用户管理若后端仅有列表接口,需要补齐新增/删除/更新接口并同步前端。
## 默认假设
- “最初的起始页”指登录/欢迎页;访问根地址时应清理共享会话并显示该页。
- 项目库逆向分割结果中“DICOM+分割”以现有 DICOM 预览叠加保存结果构件提示呈现,若未保存结果则显示空状态。
- 系统管理工作区至少需要新增用户、删除非当前用户、编辑用户基础信息可用。