2026-05-20-14-53-31 逆向结果复核与用户管理修复
This commit is contained in:
@@ -39,6 +39,7 @@ interface ModelPoseRecord {
|
||||
|
||||
interface SegmentationResultRecord {
|
||||
id: string;
|
||||
schemaVersion?: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
@@ -183,6 +184,16 @@ function publicUser(user: UserRecord) {
|
||||
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) {
|
||||
const user = state.session.account
|
||||
? state.users.find((candidate) => candidate.account === state.session.account)
|
||||
@@ -314,6 +325,7 @@ function normalizeSegmentationResults(
|
||||
);
|
||||
|
||||
return existing
|
||||
.filter((record) => record?.schemaVersion === 2)
|
||||
.map((record, index): SegmentationResultRecord => {
|
||||
const rawStyles = record?.moduleStyles && typeof record.moduleStyles === 'object' && !Array.isArray(record.moduleStyles)
|
||||
? record.moduleStyles
|
||||
@@ -324,6 +336,7 @@ function normalizeSegmentationResults(
|
||||
id: typeof record?.id === 'string' && record.id.trim()
|
||||
? record.id.trim().slice(0, 80)
|
||||
: `segmentation-${index}`,
|
||||
schemaVersion: 2,
|
||||
name: '逆向分割结果',
|
||||
createdAt: typeof record?.createdAt === 'string' && record.createdAt.trim() ? record.createdAt : now(),
|
||||
segmentationScope: record?.segmentationScope === 'all' ? 'all' : 'visible',
|
||||
@@ -2001,6 +2014,86 @@ async function startServer() {
|
||||
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) => {
|
||||
res.json(readState().projects);
|
||||
});
|
||||
@@ -2118,6 +2211,7 @@ async function startServer() {
|
||||
: project.moduleStyles;
|
||||
const record: SegmentationResultRecord = {
|
||||
id: `segmentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
schemaVersion: 2,
|
||||
name: rawName || '逆向分割结果',
|
||||
createdAt: now(),
|
||||
segmentationScope: parseSegmentationScope(req.body?.segmentationScope),
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function App() {
|
||||
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
|
||||
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
||||
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const bootSessionResetRef = useRef(false);
|
||||
|
||||
// Automatically collapse main sidebar when entering Project Library or Workspace
|
||||
useEffect(() => {
|
||||
@@ -42,6 +43,16 @@ export default function App() {
|
||||
|
||||
const syncSession = async () => {
|
||||
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();
|
||||
if (!mounted) {
|
||||
return;
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function Login({ onLogin }: LoginProps) {
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold leading-tight px-4">模型逆向系统</h1>
|
||||
<p className="text-slate-300 mt-2 font-medium">基于模型逆向体素化及DICOM分割标注系统</p>
|
||||
<h1 className="px-2 text-3xl font-bold leading-tight">基于模型逆向体素化及DICOM分割标注系统</h1>
|
||||
<p className="mt-3 text-lg font-semibold text-slate-300">模型逆向系统</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-6">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Box,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
@@ -653,6 +654,10 @@ export default function ProjectLibrary({
|
||||
const [isSliceChanging, setIsSliceChanging] = useState(false);
|
||||
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
|
||||
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 [dicomPreview, setDicomPreview] = 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 savedSegmentationResults = selectedProject?.segmentationResults ?? [];
|
||||
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
|
||||
const latestResultPose = latestSegmentationResult?.pose ?? modelPose;
|
||||
const latestResultPose = latestSegmentationResult ? resultPose : modelPose;
|
||||
const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles;
|
||||
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
|
||||
.map((fileName, index) => ({
|
||||
fileName,
|
||||
@@ -763,7 +769,6 @@ export default function ProjectLibrary({
|
||||
},
|
||||
}))
|
||||
.filter(({ style }) => style.visible !== false);
|
||||
const readonlyPoseChange = useMemo<React.Dispatch<React.SetStateAction<ModelPose>>>(() => () => undefined, []);
|
||||
|
||||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||
visible: fallback?.visible ?? true,
|
||||
@@ -828,6 +833,10 @@ export default function ProjectLibrary({
|
||||
setModuleStyles(next);
|
||||
setSliceIndex(0);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -870,8 +879,8 @@ export default function ProjectLibrary({
|
||||
|
||||
let cancelled = false;
|
||||
const maxSlice = Math.max(selectedProject.dicomCount - 1, 0);
|
||||
const previewSlice = Math.max(0, Math.min(maxSlice, latestSegmentationResult?.mappingSlice ?? maxSlice));
|
||||
api.getDicomPreview(selectedProject.id, previewSlice, 'axial', 'soft')
|
||||
const previewSlice = Math.max(0, Math.min(maxSlice, resultPreviewSlice));
|
||||
api.getDicomPreview(selectedProject.id, previewSlice, 'axial', resultDisplayMode)
|
||||
.then((preview) => {
|
||||
if (!cancelled) {
|
||||
setResultDicomPreview(preview);
|
||||
@@ -886,7 +895,7 @@ export default function ProjectLibrary({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, latestSegmentationResult?.mappingSlice]);
|
||||
}, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultPreviewSlice, resultDisplayMode]);
|
||||
|
||||
useEffect(() => () => {
|
||||
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">
|
||||
<p className="text-sm font-bold">影像与模型融合视角</p>
|
||||
<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>
|
||||
</div>
|
||||
{latestSegmentationResult ? (
|
||||
@@ -1559,10 +1568,10 @@ export default function ProjectLibrary({
|
||||
projectId={selectedProject.id}
|
||||
files={stlFiles}
|
||||
styles={latestResultStyles}
|
||||
detailLimit={selectedSolidity.limit}
|
||||
solidMode={solidityLevel === 'solid'}
|
||||
detailLimit={resultFineDetailLimit}
|
||||
solidMode={false}
|
||||
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">
|
||||
@@ -1571,22 +1580,50 @@ export default function ProjectLibrary({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[520px] overflow-hidden rounded-2xl border border-slate-900 bg-black text-white shadow-sm">
|
||||
<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">
|
||||
BASE DICOM
|
||||
</span>
|
||||
<span className="rounded-lg border border-cyan-300/40 bg-cyan-400/15 px-2.5 py-1 font-mono text-[10px] font-bold text-cyan-100 backdrop-blur">
|
||||
OVERLAY LABEL MAP
|
||||
</span>
|
||||
<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">
|
||||
Z {resultMappingSlice + 1}/{selectedProject.dicomCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-8">
|
||||
<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">
|
||||
<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
|
||||
</span>
|
||||
<span className="rounded-lg border border-cyan-300/40 bg-cyan-400/15 px-2.5 py-1 font-mono text-[10px] font-bold text-cyan-100 backdrop-blur">
|
||||
OVERLAY LABEL MAP
|
||||
</span>
|
||||
<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">
|
||||
Z {resultMappingSlice + 1}/{selectedProject.dicomCount}
|
||||
</span>
|
||||
</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">
|
||||
{latestSegmentationResult && resultDicomPreview ? (
|
||||
<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="mb-2 flex items-center justify-between gap-3 text-[11px] font-bold text-white/70">
|
||||
<span>当前切片构件</span>
|
||||
@@ -1608,6 +1645,43 @@ export default function ProjectLibrary({
|
||||
{latestSegmentationResult ? '正在载入逆向分割映射视图...' : '暂无逆向分割映射视图'}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Settings2,
|
||||
Download,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Rotate3d,
|
||||
AlertCircle,
|
||||
ChevronLeft,
|
||||
@@ -28,6 +30,7 @@ interface ModelPreviewPayload {
|
||||
|
||||
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
type DicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
type MappingDisplayMode = DicomPreview['mode'];
|
||||
type ModelPoseKey = keyof ModelPose;
|
||||
type PoseDraftValues = Record<ModelPoseKey, string>;
|
||||
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: '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 }> = {
|
||||
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 },
|
||||
@@ -207,6 +216,41 @@ function poseValuesMatch(left: ModelPose, right: ModelPose) {
|
||||
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) {
|
||||
const record = isRecord(payload) ? payload : {};
|
||||
const importedModelPoses = normalizeImportedModelPoses(record.modelPoses);
|
||||
@@ -1701,6 +1745,8 @@ function VoxelizationMappingView({
|
||||
slice,
|
||||
totalSlices,
|
||||
onSliceChange,
|
||||
displayMode,
|
||||
rotation,
|
||||
}: {
|
||||
project: Project | null;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
@@ -1709,6 +1755,8 @@ function VoxelizationMappingView({
|
||||
slice: number;
|
||||
totalSlices: number;
|
||||
onSliceChange: (slice: number) => void;
|
||||
displayMode: MappingDisplayMode;
|
||||
rotation: number;
|
||||
}) {
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -1731,7 +1779,7 @@ function VoxelizationMappingView({
|
||||
|
||||
let disposed = false;
|
||||
setDicomStatus('正在载入 DICOM Base Layer...');
|
||||
api.getDicomPreview(project.id, safeSlice, 'axial', 'soft')
|
||||
api.getDicomPreview(project.id, safeSlice, 'axial', displayMode)
|
||||
.then((preview) => {
|
||||
if (disposed) return;
|
||||
setDicomPreview(preview);
|
||||
@@ -1746,7 +1794,7 @@ function VoxelizationMappingView({
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [project?.id, project?.dicomCount, safeSlice]);
|
||||
}, [project?.id, project?.dicomCount, safeSlice, displayMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !stlFiles.length) {
|
||||
@@ -1847,7 +1895,10 @@ function VoxelizationMappingView({
|
||||
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
|
||||
</div>
|
||||
{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={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||||
</div>
|
||||
@@ -1947,6 +1998,8 @@ export default function ReverseWorkspace({
|
||||
const [poseImportStatus, setPoseImportStatus] = useState('');
|
||||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||||
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
|
||||
const [mappingDisplayMode, setMappingDisplayMode] = useState<MappingDisplayMode>('soft');
|
||||
const [mappingRotation, setMappingRotation] = useState(0);
|
||||
const [showBounds, setShowBounds] = useState(true);
|
||||
const [cutEnabled, setCutEnabled] = useState(false);
|
||||
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 poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
const savedWorkspaceSnapshotRef = useRef('');
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
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 } = {}) => {
|
||||
if (!project) {
|
||||
return false;
|
||||
@@ -2016,6 +2094,7 @@ export default function ReverseWorkspace({
|
||||
cutEnabled,
|
||||
});
|
||||
setProject(updated);
|
||||
savedWorkspaceSnapshotRef.current = getCurrentWorkspaceSnapshot();
|
||||
if (options.showToast !== false) {
|
||||
setSaveStatus('已保存至项目库的分割结果区域');
|
||||
}
|
||||
@@ -2040,6 +2119,7 @@ export default function ReverseWorkspace({
|
||||
dicomOpacityLevel,
|
||||
showBounds,
|
||||
cutEnabled,
|
||||
getCurrentWorkspaceSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2072,7 +2152,10 @@ export default function ReverseWorkspace({
|
||||
if (!project) {
|
||||
return true;
|
||||
}
|
||||
const shouldSave = window.confirm('是否保存当前结果至项目库?\\n确定:保存后退出。\\n取消:直接退出,不保存当前结果。');
|
||||
if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) {
|
||||
return true;
|
||||
}
|
||||
const shouldSave = window.confirm('是否保存当前结果至项目库? 确定:保存后退出。取消:直接退出,不保存当前结果。');
|
||||
if (!shouldSave) {
|
||||
return true;
|
||||
}
|
||||
@@ -2080,7 +2163,7 @@ export default function ReverseWorkspace({
|
||||
});
|
||||
|
||||
return () => onLeaveGuardChange(null);
|
||||
}, [handleSaveSegmentationResult, onLeaveGuardChange, project]);
|
||||
}, [getCurrentWorkspaceSnapshot, handleSaveSegmentationResult, onLeaveGuardChange, project]);
|
||||
|
||||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||
visible: fallback?.visible ?? true,
|
||||
@@ -2149,11 +2232,26 @@ export default function ReverseWorkspace({
|
||||
setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible');
|
||||
setDisplayLevel(latestResult?.displayLevel ?? 'standard');
|
||||
setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low');
|
||||
setMappingDisplayMode('soft');
|
||||
setMappingRotation(0);
|
||||
setShowBounds(latestResult?.showBounds ?? true);
|
||||
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(() => {
|
||||
setProject(null);
|
||||
setFusionVolume(null);
|
||||
savedWorkspaceSnapshotRef.current = '';
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
@@ -2890,11 +2988,40 @@ export default function ReverseWorkspace({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Layers size={18} className="text-cyan-500" />
|
||||
逆向分割映射视图
|
||||
</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>
|
||||
|
||||
<VoxelizationMappingView
|
||||
@@ -2905,6 +3032,8 @@ export default function ReverseWorkspace({
|
||||
slice={safeMappingSlice}
|
||||
totalSlices={project?.dicomCount ?? 0}
|
||||
onSliceChange={setMappingSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={mappingRotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,176 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Search,
|
||||
MoreVertical,
|
||||
Shield,
|
||||
Calendar,
|
||||
RotateCcw,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Key
|
||||
Key,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
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() {
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步');
|
||||
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 = () => {
|
||||
api.getUsers().then(setUsers).catch(() => setMessage('用户列表同步失败'));
|
||||
const refreshUsers = async () => {
|
||||
try {
|
||||
const [items, currentSession] = await Promise.all([api.getUsers(), api.getSession()]);
|
||||
setUsers(items);
|
||||
setSession(currentSession);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : '用户列表同步失败');
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
setResetting(true);
|
||||
setMessage('正在恢复演示环境...');
|
||||
try {
|
||||
const result = await api.resetDemo();
|
||||
setUsers(result.users);
|
||||
setSession(await api.getSession());
|
||||
setMessage('演示环境已恢复:默认用户、Head_CT_DICOM 与 Head_CT_ReConstruct 项目已重新载入');
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : '恢复失败');
|
||||
@@ -44,44 +181,52 @@ export default function UserManagement() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<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 className="flex gap-3">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
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} />
|
||||
{resetting ? '正在恢复' : '恢复演示环境出厂设置'}
|
||||
</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} />
|
||||
添加用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-slate-100 flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<div className="flex flex-col overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-4 border-b border-slate-100 p-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
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>
|
||||
<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 className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<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>
|
||||
@@ -90,69 +235,141 @@ export default function UserManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{users.map((user, i) => (
|
||||
<motion.tr
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="hover:bg-slate-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<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">
|
||||
{user.name[0]}
|
||||
{filteredUsers.map((user, index) => {
|
||||
const isCurrentUser = user.account === currentAccount;
|
||||
return (
|
||||
<motion.tr
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
className="transition-colors hover:bg-slate-50/50"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 font-bold text-blue-600">
|
||||
{user.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800">{user.name}</p>
|
||||
{isCurrentUser && <p className="text-[10px] font-bold text-blue-600">当前登录</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-800">{user.name}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-mono text-sm text-slate-500">{user.account}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="rounded-lg bg-slate-100 px-2 py-1 text-sm font-medium text-slate-600">
|
||||
{user.department}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Calendar size={14} />
|
||||
{user.date}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<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} />
|
||||
编辑
|
||||
</button>
|
||||
<div className="h-4 w-[1px] bg-slate-200" />
|
||||
<button
|
||||
onClick={() => openPasswordForm(user)}
|
||||
className="text-slate-400 transition-colors hover:text-amber-600"
|
||||
title="修改密码"
|
||||
>
|
||||
<Key size={16} />
|
||||
</button>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 px-6 text-sm font-mono text-slate-500">
|
||||
{user.account}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-slate-600 font-medium px-2 py-1 bg-slate-100 rounded-lg">
|
||||
{user.department}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
{user.date}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="text-slate-400 hover:text-blue-600 transition-colors flex items-center gap-1 text-xs" title="编辑信息">
|
||||
<Edit2 size={16} />
|
||||
编辑
|
||||
</button>
|
||||
<div className="h-4 w-[1px] bg-slate-200" />
|
||||
<button className="text-slate-400 hover:text-amber-600 transition-colors" title="修改密码">
|
||||
<Key size={16} />
|
||||
</button>
|
||||
<button className="text-slate-400 hover:text-rose-600 transition-colors" title="删除用户">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button className="text-slate-300 hover:text-slate-600 transition-colors">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-slate-50 flex items-center justify-between text-sm text-slate-500 bg-slate-50/50">
|
||||
<p>共 {users.length} 条数据</p>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1 border border-blue-500 rounded bg-blue-500 text-white">1</button>
|
||||
<div className="flex items-center justify-between border-t border-slate-50 bg-slate-50/50 p-4 text-sm text-slate-500">
|
||||
<p>共 {filteredUsers.length} / {users.length} 条数据</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={16} />
|
||||
用户管理操作实时写入后端状态
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,20 @@ export const api = {
|
||||
request<DicomFusionVolume>(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`),
|
||||
getDicomInfo: (projectId: string) => request<DicomInfo>(`/api/projects/${projectId}/dicom-info`),
|
||||
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: () =>
|
||||
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -54,6 +54,7 @@ export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface SegmentationResult {
|
||||
id: string;
|
||||
schemaVersion?: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
segmentationScope: SegmentationExportScope;
|
||||
|
||||
83
工程分析/实现方案-2026-05-20-14-53-31.md
Normal file
83
工程分析/实现方案-2026-05-20-14-53-31.md
Normal 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`,过滤旧演示结果,默认项目初始结果为空;新保存结果继续持久化。
|
||||
- 逆向工作区映射视图新增默认/骨窗/软组织/高对比和左转/右转控制。
|
||||
- 逆向工作区新增保存快照判断,仅在保存相关状态变化后离开才弹窗。
|
||||
- 根页面启动时清理共享会话,显示起始登录页;登录页标题改为全称在上、简称在下。
|
||||
- 系统管理补齐用户新增、编辑、改密、删除接口和前端表单,并禁止删除当前登录用户。
|
||||
62
工程分析/测试方案-2026-05-20-14-53-31.md
Normal file
62
工程分析/测试方案-2026-05-20-14-53-31.md
Normal 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。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1261,3 +1261,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是把“历史记录”改为“当前唯一状态”的需求,都要同步检查数据模型、归一化逻辑、保存接口、列表 UI、导出默认参数和重新进入页面的恢复逻辑。提交前用 `rg` 搜索旧文案和旧入口,避免残留重复下载按钮或旧命名造成用户误解。
|
||||
|
||||
## 2026-05-20-14-53-31 保存提示与管理操作需要区分状态语义
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求逆向工作区在未修改或刚保存后离开时不再弹出保存确认,同时系统管理工作区的添加、编辑、删除用户按钮必须真正可用,并且不能删除当前登录用户。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
上一轮实现的离开守卫只知道“正在离开逆向工作区”,不知道当前可视化状态是否已经保存,因此无条件弹窗;系统管理页原先只实现了用户列表和重置演示环境,操作按钮没有对应 API 和表单逻辑。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
为逆向工作区建立保存快照,快照覆盖位姿、构件样式、切片范围、映射切片、模型显示、DICOM 融合强度、边界和切分状态;项目加载和保存成功后刷新快照,离开时只有当前快照与保存快照不同才弹确认。后端补齐 `/api/users` 的新增、编辑和删除接口,删除时拒绝当前登录用户;前端系统管理页改为真实表单和操作流。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是“离开前确认保存”类交互,都要先定义脏状态快照,不能只按页面离开事件弹窗。后台管理按钮必须有明确接口、成功/失败反馈和权限边界;至少要覆盖新增、编辑、删除自己、删除其他用户四个验证场景。
|
||||
|
||||
72
工程分析/需求分析-2026-05-20-14-53-31.md
Normal file
72
工程分析/需求分析-2026-05-20-14-53-31.md
Normal 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 预览叠加保存结果构件提示呈现,若未保存结果则显示空状态。
|
||||
- 系统管理工作区至少需要新增用户、删除非当前用户、编辑用户基础信息可用。
|
||||
Reference in New Issue
Block a user