From fd7f3387f7ca7b9f74dc14e7c722ff579bfce908 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 20 May 2026 15:08:20 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-20-14-53-31=20=E9=80=86=E5=90=91?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=A4=8D=E6=A0=B8=E4=B8=8E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 94 +++++ WebSite/src/App.tsx | 11 + WebSite/src/components/Login.tsx | 4 +- WebSite/src/components/ProjectLibrary.tsx | 122 +++++-- WebSite/src/components/ReverseWorkspace.tsx | 141 +++++++- WebSite/src/components/UserManagement.tsx | 375 +++++++++++++++----- WebSite/src/lib/api.ts | 14 + WebSite/src/types.ts | 1 + 工程分析/实现方案-2026-05-20-14-53-31.md | 83 +++++ 工程分析/测试方案-2026-05-20-14-53-31.md | 62 ++++ 工程分析/经验记录.md | 18 + 工程分析/需求分析-2026-05-20-14-53-31.md | 72 ++++ 12 files changed, 886 insertions(+), 111 deletions(-) create mode 100644 工程分析/实现方案-2026-05-20-14-53-31.md create mode 100644 工程分析/测试方案-2026-05-20-14-53-31.md create mode 100644 工程分析/需求分析-2026-05-20-14-53-31.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 20eb11e..3c2a834 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -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 : {}; + 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), diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 2e537cc..8074cf8 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -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) | 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; diff --git a/WebSite/src/components/Login.tsx b/WebSite/src/components/Login.tsx index ebd14d1..8456628 100644 --- a/WebSite/src/components/Login.tsx +++ b/WebSite/src/components/Login.tsx @@ -43,8 +43,8 @@ export default function Login({ onLogin }: LoginProps) { className="h-full w-full object-contain" /> -

模型逆向系统

-

基于模型逆向体素化及DICOM分割标注系统

+

基于模型逆向体素化及DICOM分割标注系统

+

模型逆向系统

diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index a758250..a12735c 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -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('standard'); const [modelPose, setModelPose] = useState(defaultModelPose); + const [resultPose, setResultPose] = useState(defaultModelPose); + const [resultPreviewSlice, setResultPreviewSlice] = useState(0); + const [resultDisplayMode, setResultDisplayMode] = useState('soft'); + const [resultRotation, setResultRotation] = useState(0); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); const [resultDicomPreview, setResultDicomPreview] = useState(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>>(() => () => undefined, []); const makeDefaultModuleStyle = (index: number, fallback?: Partial): 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({

影像与模型融合视角

- {latestSegmentationResult ? `Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'} + {latestSegmentationResult ? `模型显示 精细 · 融合显示 DICOM 高 · Z ${resultMappingSlice + 1}/${selectedProject.dicomCount}` : '等待保存结果'}

{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} /> ) : (
@@ -1571,22 +1580,50 @@ export default function ProjectLibrary({ )}
-
-
- - BASE DICOM - - - OVERLAY LABEL MAP - - - Z {resultMappingSlice + 1}/{selectedProject.dicomCount} - -
-
+
+
+
+ + BASE DICOM + + + OVERLAY LABEL MAP + + + Z {resultMappingSlice + 1}/{selectedProject.dicomCount} + +
+
+
+ {displayModes.map((mode) => ( + + ))} +
+ + +
+
{latestSegmentationResult && resultDicomPreview ? (
- +
当前切片构件 @@ -1608,6 +1645,43 @@ export default function ProjectLibrary({ {latestSegmentationResult ? '正在载入逆向分割映射视图...' : '暂无逆向分割映射视图'}

)} +
+
+
+
+

Slice Navigator

+ + {resultMappingSlice + 1} / {Math.max(selectedProject.dicomCount, 1)} + +
+
+ + setResultPreviewSlice(Number(event.target.value))} + className="h-2 w-full accent-cyan-400 disabled:opacity-35" + aria-label="项目库逆向分割结果切片导航" + /> + +
diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index ac93717..d996f84 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -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; 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 = { 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) { + return Object.keys(styles) + .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN')) + .reduce>((accumulator, key) => { + accumulator[key] = styles[key]; + return accumulator; + }, {}); +} + +function createWorkspaceSnapshot(input: { + modelPose: ModelPose; + segmentationExportScope: SegmentationExportScope; + moduleStyles: Record; + 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; @@ -1709,6 +1755,8 @@ function VoxelizationMappingView({ slice: number; totalSlices: number; onSliceChange: (slice: number) => void; + displayMode: MappingDisplayMode; + rotation: number; }) { const baseCanvasRef = useRef(null); const overlayCanvasRef = useRef(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)}
{dicomPreview ? ( -
+
@@ -1947,6 +1998,8 @@ export default function ReverseWorkspace({ const [poseImportStatus, setPoseImportStatus] = useState(''); const [displayLevel, setDisplayLevel] = useState('standard'); const [dicomOpacityLevel, setDicomOpacityLevel] = useState('low'); + const [mappingDisplayMode, setMappingDisplayMode] = useState('soft'); + const [mappingRotation, setMappingRotation] = useState(0); const [showBounds, setShowBounds] = useState(true); const [cutEnabled, setCutEnabled] = useState(false); const [moduleStyles, setModuleStyles] = useState>({}); @@ -1969,6 +2022,7 @@ export default function ReverseWorkspace({ const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); const poseImportInputRef = useRef(null); const saveToastTimerRef = useRef(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 => ({ 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({
-
+

逆向分割映射视图

+
+
+ {mappingDisplayModes.map((mode) => ( + + ))} +
+ + +
diff --git a/WebSite/src/components/UserManagement.tsx b/WebSite/src/components/UserManagement.tsx index 77b7004..10405a4 100644 --- a/WebSite/src/components/UserManagement.tsx +++ b/WebSite/src/components/UserManagement.tsx @@ -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, +import { + Users, + UserPlus, + Search, + 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([]); + const [session, setSession] = useState(null); const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步'); const [resetting, setResetting] = useState(false); + const [search, setSearch] = useState(''); + const [formMode, setFormMode] = useState(null); + const [form, setForm] = useState(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 (
-
+

系统管理工作区

-

{message}

+

{message}

-
-
-
-
+
+
+
- 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" />
- +
+ + 当前账号:{currentAccount || '未同步'} +
- + @@ -90,69 +235,141 @@ export default function UserManagement() { - {users.map((user, i) => ( - - + + + + - - - - - - ))} + + + ); + })}
用户名 账号 所在科室
-
-
- {user.name[0]} + {filteredUsers.map((user, index) => { + const isCurrentUser = user.account === currentAccount; + return ( + +
+
+
+ {user.name[0]} +
+
+

{user.name}

+ {isCurrentUser &&

当前登录

} +
-
-

{user.name}

+
{user.account} + + {user.department} + + +

+ + {user.date} +

+
+
+ +
+ +
-
-
- {user.account} - - - {user.department} - - -

- - {user.date} -

-
-
- -
- - - -
-
- -
-

共 {users.length} 条数据

-
- + +
+

共 {filteredUsers.length} / {users.length} 条数据

+
+ + 用户管理操作实时写入后端状态
+ + {formMode && ( +
+
+
+

+ {formMode === 'create' && '添加用户'} + {formMode === 'edit' && '编辑用户'} + {formMode === 'password' && '修改密码'} +

+ +
+
+ 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" + /> + 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" + /> + 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" + /> + 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" + /> +
+
+ + +
+
+
+ )}
); } diff --git a/WebSite/src/lib/api.ts b/WebSite/src/lib/api.ts index 86273a3..d102f3a 100644 --- a/WebSite/src/lib/api.ts +++ b/WebSite/src/lib/api.ts @@ -89,6 +89,20 @@ export const api = { request(`/api/projects/${projectId}/dicom-fusion-volume?start=${start}&end=${end}&mode=${mode}`), getDicomInfo: (projectId: string) => request(`/api/projects/${projectId}/dicom-info`), getUsers: () => request('/api/users'), + createUser: (payload: { name: string; account: string; department: string; password: string }) => + request('/api/users', { + method: 'POST', + body: JSON.stringify(payload), + }), + updateUser: (userId: number, payload: { name: string; account: string; department: string; password?: string }) => + request(`/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', diff --git a/WebSite/src/types.ts b/WebSite/src/types.ts index da87ed8..3bae540 100644 --- a/WebSite/src/types.ts +++ b/WebSite/src/types.ts @@ -54,6 +54,7 @@ export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high'; export interface SegmentationResult { id: string; + schemaVersion?: number; name: string; createdAt: string; segmentationScope: SegmentationExportScope; diff --git a/工程分析/实现方案-2026-05-20-14-53-31.md b/工程分析/实现方案-2026-05-20-14-53-31.md new file mode 100644 index 0000000..748a8ed --- /dev/null +++ b/工程分析/实现方案-2026-05-20-14-53-31.md @@ -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`,过滤旧演示结果,默认项目初始结果为空;新保存结果继续持久化。 +- 逆向工作区映射视图新增默认/骨窗/软组织/高对比和左转/右转控制。 +- 逆向工作区新增保存快照判断,仅在保存相关状态变化后离开才弹窗。 +- 根页面启动时清理共享会话,显示起始登录页;登录页标题改为全称在上、简称在下。 +- 系统管理补齐用户新增、编辑、改密、删除接口和前端表单,并禁止删除当前登录用户。 diff --git a/工程分析/测试方案-2026-05-20-14-53-31.md b/工程分析/测试方案-2026-05-20-14-53-31.md new file mode 100644 index 0000000..cc1e456 --- /dev/null +++ b/工程分析/测试方案-2026-05-20-14-53-31.md @@ -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。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index f84b8d0..ab9dc5d 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1261,3 +1261,21 @@ C. 解决问题方案 D. 后续如何避免问题 凡是把“历史记录”改为“当前唯一状态”的需求,都要同步检查数据模型、归一化逻辑、保存接口、列表 UI、导出默认参数和重新进入页面的恢复逻辑。提交前用 `rg` 搜索旧文案和旧入口,避免残留重复下载按钮或旧命名造成用户误解。 + +## 2026-05-20-14-53-31 保存提示与管理操作需要区分状态语义 + +A. 具体问题 + +用户要求逆向工作区在未修改或刚保存后离开时不再弹出保存确认,同时系统管理工作区的添加、编辑、删除用户按钮必须真正可用,并且不能删除当前登录用户。 + +B. 产生问题原因 + +上一轮实现的离开守卫只知道“正在离开逆向工作区”,不知道当前可视化状态是否已经保存,因此无条件弹窗;系统管理页原先只实现了用户列表和重置演示环境,操作按钮没有对应 API 和表单逻辑。 + +C. 解决问题方案 + +为逆向工作区建立保存快照,快照覆盖位姿、构件样式、切片范围、映射切片、模型显示、DICOM 融合强度、边界和切分状态;项目加载和保存成功后刷新快照,离开时只有当前快照与保存快照不同才弹确认。后端补齐 `/api/users` 的新增、编辑和删除接口,删除时拒绝当前登录用户;前端系统管理页改为真实表单和操作流。 + +D. 后续如何避免问题 + +凡是“离开前确认保存”类交互,都要先定义脏状态快照,不能只按页面离开事件弹窗。后台管理按钮必须有明确接口、成功/失败反馈和权限边界;至少要覆盖新增、编辑、删除自己、删除其他用户四个验证场景。 diff --git a/工程分析/需求分析-2026-05-20-14-53-31.md b/工程分析/需求分析-2026-05-20-14-53-31.md new file mode 100644 index 0000000..c3f8cae --- /dev/null +++ b/工程分析/需求分析-2026-05-20-14-53-31.md @@ -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 预览叠加保存结果构件提示呈现,若未保存结果则显示空状态。 +- 系统管理工作区至少需要新增用户、删除非当前用户、编辑用户基础信息可用。