diff --git a/.gitignore b/.gitignore index 50410da..d5cc49a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ WebSite/node_modules/ dist/ WebSite/dist/ +# Runtime backend state and generated exports +WebSite/data/ +WebSite/exports/ + # Local env .env .env.* diff --git a/WebSite/package.json b/WebSite/package.json index 496abd9..5ab037b 100644 --- a/WebSite/package.json +++ b/WebSite/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", + "serve": "tsx server.ts", "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist", diff --git a/WebSite/server.ts b/WebSite/server.ts new file mode 100644 index 0000000..a26deec --- /dev/null +++ b/WebSite/server.ts @@ -0,0 +1,350 @@ +import express from 'express'; +import { createServer as createViteServer } from 'vite'; +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; + +type ProjectStatus = 'pending' | 'completed' | 'processing'; + +interface UserRecord { + id: number; + name: string; + account: string; + password: string; + department: string; + date: string; +} + +interface ProjectRecord { + id: string; + name: string; + createTime: string; + status: ProjectStatus; + dicomCount: number; + hasModel: boolean; + dicomPath: string; + modelPath: string; + modelCount: number; + stlFiles: string[]; + maskFormats: Array<'nii' | 'nii.gz'>; +} + +interface SessionRecord { + authenticated: boolean; + account: string | null; + lastUpdated: string; +} + +interface AppState { + users: UserRecord[]; + projects: ProjectRecord[]; + session: SessionRecord; + updatedAt: string; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const dataDir = path.join(__dirname, 'data'); +const exportDir = path.join(__dirname, 'exports'); +const statePath = path.join(dataDir, 'state.json'); +const dicomDir = path.join(repoRoot, 'Head_CT_DICOM'); +const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct'); + +function today() { + return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date()); +} + +function now() { + return new Date().toISOString(); +} + +function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +function listFiles(dir: string, extension: string) { + if (!fs.existsSync(dir)) { + return []; + } + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension)) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); +} + +function publicUser(user: UserRecord) { + const { password: _password, ...rest } = user; + return rest; +} + +function publicSession(state: AppState) { + const user = state.session.account + ? state.users.find((candidate) => candidate.account === state.session.account) + : null; + + return { + authenticated: state.session.authenticated && Boolean(user), + currentUser: user + ? { + id: user.id, + name: user.name, + account: user.account, + department: user.department, + } + : null, + lastUpdated: state.session.lastUpdated, + }; +} + +function buildDefaultProject(): ProjectRecord { + const stlFiles = listFiles(modelDir, '.stl'); + + return { + id: 'head-ct-demo', + name: '头部 CT 模型逆向体素化演示', + createTime: today(), + status: 'completed', + dicomCount: listFiles(dicomDir, '.dcm').length, + hasModel: stlFiles.length > 0, + dicomPath: 'Head_CT_DICOM', + modelPath: 'Head_CT_ReConstruct', + modelCount: stlFiles.length, + stlFiles, + maskFormats: ['nii', 'nii.gz'], + }; +} + +function defaultState(): AppState { + return { + users: [ + { id: 1, name: 'Admin', account: 'admin', password: '123456', department: 'admin', date: today() }, + { id: 2, name: 'Doctor Li', account: 'doctor_li', password: '123456', department: '肝胆外科', date: today() }, + ], + projects: [buildDefaultProject()], + session: { authenticated: false, account: null, lastUpdated: now() }, + updatedAt: now(), + }; +} + +function normalizeState(state: AppState): AppState { + return { + ...state, + projects: [buildDefaultProject()], + }; +} + +function readState(): AppState { + ensureDir(dataDir); + + if (!fs.existsSync(statePath)) { + const initialState = defaultState(); + writeState(initialState); + return initialState; + } + + try { + const raw = fs.readFileSync(statePath, 'utf8'); + return normalizeState(JSON.parse(raw) as AppState); + } catch { + const recoveredState = defaultState(); + writeState(recoveredState); + return recoveredState; + } +} + +function writeState(state: AppState) { + ensureDir(dataDir); + fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2)); +} + +function createNiftiMask(project: ProjectRecord, compressed: boolean) { + const width = 64; + const height = 64; + const depth = 64; + const headerSize = 348; + const voxOffset = 352; + const voxelCount = width * height * depth; + const data = Buffer.alloc(voxelCount); + const center = [width / 2, height / 2, depth / 2]; + + for (let z = 0; z < depth; z += 1) { + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const dx = (x - center[0]) / 18; + const dy = (y - center[1]) / 15; + const dz = (z - center[2]) / 20; + const index = z * width * height + y * width + x; + const radius = dx * dx + dy * dy + dz * dz; + + if (radius < 1) { + data[index] = 1; + } + + const tumorDx = (x - 42) / 8; + const tumorDy = (y - 30) / 7; + const tumorDz = (z - 34) / 7; + if (tumorDx * tumorDx + tumorDy * tumorDy + tumorDz * tumorDz < 1) { + data[index] = 2; + } + } + } + } + + const header = Buffer.alloc(voxOffset); + header.writeInt32LE(headerSize, 0); + header.writeInt16LE(3, 40); + header.writeInt16LE(width, 42); + header.writeInt16LE(height, 44); + header.writeInt16LE(depth, 46); + header.writeInt16LE(1, 48); + header.writeInt16LE(1, 50); + header.writeInt16LE(1, 52); + header.writeInt16LE(1, 54); + header.writeInt16LE(2, 70); + header.writeInt16LE(8, 72); + header.writeFloatLE(1, 76); + header.writeFloatLE(1, 80); + header.writeFloatLE(1, 84); + header.writeFloatLE(1, 88); + header.writeFloatLE(voxOffset, 108); + header.writeFloatLE(1, 112); + header.write('ReVoxelSeg demo mask', 148, 'ascii'); + header.write(`Project ${project.id}`, 228, 'ascii'); + header.write('n+1\0', 344, 'ascii'); + + const nifti = Buffer.concat([header, data]); + return compressed ? zlib.gzipSync(nifti) : nifti; +} + +async function startServer() { + const app = express(); + const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0'; + const portArg = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : process.env.PORT; + const port = Number(portArg ?? 4000); + + ensureDir(exportDir); + app.use(express.json()); + + app.get('/api/health', (_req, res) => { + res.json({ ok: true, service: 'revoxelseg-dicom', time: now() }); + }); + + app.get('/api/session', (_req, res) => { + res.json(publicSession(readState())); + }); + + app.post('/api/login', (req, res) => { + const { account, password } = req.body as { account?: string; password?: string }; + const state = readState(); + const user = state.users.find((candidate) => candidate.account === account && candidate.password === password); + + if (!user) { + res.status(401).json({ message: '账号或密码错误' }); + return; + } + + state.session = { authenticated: true, account: user.account, lastUpdated: now() }; + writeState(state); + res.json(publicSession(state)); + }); + + app.post('/api/logout', (_req, res) => { + const state = readState(); + state.session = { authenticated: false, account: null, lastUpdated: now() }; + writeState(state); + res.json(publicSession(state)); + }); + + app.get('/api/users', (_req, res) => { + res.json(readState().users.map(publicUser)); + }); + + app.get('/api/projects', (_req, res) => { + res.json(readState().projects); + }); + + app.get('/api/projects/:projectId', (req, res) => { + const project = readState().projects.find((candidate) => candidate.id === req.params.projectId); + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + res.json(project); + }); + + app.get('/api/overview', (_req, res) => { + const state = readState(); + const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0); + const modelCount = state.projects.reduce((sum, project) => sum + project.modelCount, 0); + + res.json({ + totalProjects: state.projects.length, + processedProjects: state.projects.filter((project) => project.status === 'completed').length, + dicomCount, + modelCount, + chartData: [ + { name: 'Mon', projects: 1, processing: 12 }, + { name: 'Tue', projects: 1, processing: 28 }, + { name: 'Wed', projects: 1, processing: 44 }, + { name: 'Thu', projects: 1, processing: 58 }, + { name: 'Fri', projects: 1, processing: 76 }, + { name: 'Sat', projects: 1, processing: 90 }, + { name: 'Sun', projects: state.projects.length, processing: 100 }, + ], + }); + }); + + app.post('/api/demo/reset', (_req, res) => { + const state = defaultState(); + writeState(state); + res.json({ ok: true, projects: state.projects, users: state.users.map(publicUser) }); + }); + + app.post('/api/projects/:projectId/export-mask', (req, res) => { + const state = readState(); + const project = state.projects.find((candidate) => candidate.id === req.params.projectId); + + if (!project) { + res.status(404).json({ message: '项目不存在' }); + return; + } + + const format = req.query.format === 'nii' ? 'nii' : 'nii.gz'; + const compressed = format === 'nii.gz'; + const mask = createNiftiMask(project, compressed); + const filename = `${project.id}-segmentation-mask.${format}`; + const outputPath = path.join(exportDir, filename); + fs.writeFileSync(outputPath, mask); + + res.setHeader('Content-Type', compressed ? 'application/gzip' : 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(mask); + }); + + if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(__dirname, 'dist'))); + app.get('*', (_req, res) => { + res.sendFile(path.join(__dirname, 'dist', 'index.html')); + }); + } else { + const vite = await createViteServer({ + server: { middlewareMode: true, hmr: { port: 24679 } }, + appType: 'spa', + }); + app.use(vite.middlewares); + } + + app.listen(port, host, () => { + console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`); + }); +} + +startServer().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 2caf47b..bc62e14 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -17,12 +17,14 @@ import ProjectLibrary from './components/ProjectLibrary'; import ReverseWorkspace from './components/ReverseWorkspace'; import UserManagement from './components/UserManagement'; import { ViewType } from './types'; -import { } from 'lucide-react'; +import { api } from './lib/api'; export default function App() { const [isAuthenticated, setIsAuthenticated] = useState(false); + const [sessionLoading, setSessionLoading] = useState(true); const [activeView, setActiveView] = useState(ViewType.OVERVIEW); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [activeProjectId, setActiveProjectId] = useState('head-ct-demo'); // Automatically collapse main sidebar when entering Project Library or Workspace useEffect(() => { @@ -33,12 +35,56 @@ export default function App() { } }, [activeView]); - const handleLogin = () => setIsAuthenticated(true); - const handleLogout = () => { + useEffect(() => { + let mounted = true; + + const syncSession = async () => { + try { + const session = await api.getSession(); + if (!mounted) { + return; + } + setIsAuthenticated(session.authenticated); + if (!session.authenticated) { + setActiveView(ViewType.OVERVIEW); + } + } catch { + if (mounted) { + setIsAuthenticated(false); + } + } finally { + if (mounted) { + setSessionLoading(false); + } + } + }; + + syncSession(); + const interval = window.setInterval(syncSession, 2500); + return () => { + mounted = false; + window.clearInterval(interval); + }; + }, []); + + const handleLogin = () => { + setIsAuthenticated(true); + }; + + const handleLogout = async () => { + await api.logout(); setIsAuthenticated(false); setActiveView(ViewType.OVERVIEW); }; + if (sessionLoading) { + return ( +
+ 正在同步登录状态... +
+ ); + } + if (!isAuthenticated) { return ; } @@ -83,10 +129,13 @@ export default function App() { {activeView === ViewType.OVERVIEW && } {activeView === ViewType.PROJECTS && ( setActiveView(ViewType.WORKSPACE)} + onReverse={(projectId) => { + setActiveProjectId(projectId); + setActiveView(ViewType.WORKSPACE); + }} /> )} - {activeView === ViewType.WORKSPACE && } + {activeView === ViewType.WORKSPACE && } {activeView === ViewType.SYSTEM && } @@ -95,4 +144,3 @@ export default function App() { ); } - diff --git a/WebSite/src/components/Login.tsx b/WebSite/src/components/Login.tsx index 3fdd9f7..ebd14d1 100644 --- a/WebSite/src/components/Login.tsx +++ b/WebSite/src/components/Login.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { motion } from 'motion/react'; import { Lock, User } from 'lucide-react'; +import { api } from '../lib/api'; interface LoginProps { onLogin: () => void; @@ -10,14 +11,20 @@ export default function Login({ onLogin }: LoginProps) { const [username, setUsername] = useState('admin'); const [password, setPassword] = useState('123456'); const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); - setTimeout(() => { + setError(''); + try { + await api.login(username, password); onLogin(); + } catch (err) { + setError(err instanceof Error ? err.message : '登录失败'); + } finally { setLoading(false); - }, 800); + } }; return ( @@ -85,6 +92,9 @@ export default function Login({ onLogin }: LoginProps) { '立即登录' )} + {error && ( +

{error}

+ )} diff --git a/WebSite/src/components/Overview.tsx b/WebSite/src/components/Overview.tsx index c4cc9e7..499f698 100644 --- a/WebSite/src/components/Overview.tsx +++ b/WebSite/src/components/Overview.tsx @@ -1,28 +1,31 @@ +import { useEffect, useState } from 'react'; import { motion } from 'motion/react'; import { FolderRoot, CheckCircle2, Activity, - Database + Database, + Box } from 'lucide-react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; - -const stats = [ - { label: '项目总数', value: '128', icon: FolderRoot, color: 'bg-blue-500', trend: '+12%' }, - { label: '已处理项目总数', value: '15,420', icon: Database, color: 'bg-indigo-500', trend: '+5%' }, -]; - -const chartData = [ - { name: 'Mon', projects: 4, processing: 40 }, - { name: 'Tue', projects: 7, processing: 30 }, - { name: 'Wed', projects: 5, processing: 60 }, - { name: 'Thu', projects: 8, processing: 45 }, - { name: 'Fri', projects: 12, processing: 80 }, - { name: 'Sat', projects: 9, processing: 50 }, - { name: 'Sun', projects: 6, processing: 35 }, -]; +import { api } from '../lib/api'; +import { OverviewSummary } from '../types'; export default function Overview() { + const [summary, setSummary] = useState(null); + + useEffect(() => { + api.getOverview().then(setSummary).catch(() => setSummary(null)); + }, []); + + const stats = [ + { label: '项目总数', value: String(summary?.totalProjects ?? '-'), icon: FolderRoot, color: 'bg-blue-500', trend: '同步' }, + { label: '已处理项目总数', value: String(summary?.processedProjects ?? '-'), icon: CheckCircle2, color: 'bg-emerald-500', trend: '实时' }, + { label: 'DICOM 切片数', value: String(summary?.dicomCount ?? '-'), icon: Database, color: 'bg-indigo-500', trend: '默认' }, + { label: 'STL 模型数', value: String(summary?.modelCount ?? '-'), icon: Box, color: 'bg-cyan-500', trend: '默认' }, + ]; + const chartData = summary?.chartData ?? []; + return (
@@ -32,7 +35,7 @@ export default function Overview() {
-
+
{stats.map((stat, i) => (
- + {stat.trend}
diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 014bda0..5ecfda4 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Plus, @@ -19,6 +19,7 @@ import { import { Canvas } from '@react-three/fiber'; import { OrbitControls, Stage, Gltf, useGLTF, Environment, PerspectiveCamera } from '@react-three/drei'; import { Project } from '../types'; +import { api } from '../lib/api'; // Mock 3D Model component function ModelPreview() { @@ -32,15 +33,42 @@ function ModelPreview() { export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) { const [search, setSearch] = useState(''); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); const [selectedProject, setSelectedProject] = useState(null); const [viewMode, setViewMode] = useState<'dicom' | 'model'>('dicom'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [sliceIndex, setSliceIndex] = useState(42); - const [visibleModules, setVisibleModules] = useState>({ - '颅骨整体': true, '牙弓': true, '左颧骨': true, '右颧骨': true, '下颌骨': true, '蝶骨': true, '筛骨': true - }); + const [visibleModules, setVisibleModules] = useState>({}); - const subModules = ['颅骨整体', '牙弓', '左颧骨', '右颧骨', '下颌骨', '蝶骨', '筛骨']; + useEffect(() => { + api.getProjects() + .then((items) => { + setProjects(items); + setSelectedProject(items[0] ?? null); + }) + .finally(() => setLoading(false)); + }, []); + + const filteredProjects = useMemo(() => { + const keyword = search.trim().toLowerCase(); + if (!keyword) { + return projects; + } + return projects.filter((project) => project.name.toLowerCase().includes(keyword)); + }, [projects, search]); + + const subModules = selectedProject?.stlFiles?.length + ? selectedProject.stlFiles.map((file) => file.replace(/\.stl$/i, '')) + : []; + + useEffect(() => { + const next: Record = {}; + subModules.forEach((module) => { + next[module] = visibleModules[module] ?? true; + }); + setVisibleModules(next); + }, [selectedProject?.id]); const toggleModule = (name: string) => { setVisibleModules(prev => ({ ...prev, [name]: !prev[name] })); @@ -53,13 +81,6 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri setVisibleModules(newState); }; - const projects: Project[] = [ - { id: '1', name: '大腿骨折复位三维重建', createTime: '2024-03-20', status: 'completed', dicomCount: 156, hasModel: true }, - { id: '2', name: '牙齿正畸扫描数据', createTime: '2024-03-21', status: 'pending', dicomCount: 42, hasModel: true }, - { id: '3', name: '测试项目_胸腔扫描', createTime: '2024-03-22', status: 'processing', dicomCount: 210, hasModel: false }, - { id: '4', name: '脑膜瘤切除规划', createTime: '2024-03-23', status: 'completed', dicomCount: 320, hasModel: true }, - ]; - return (
{/* Project Sidebar - Collapsible */} @@ -89,7 +110,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri />
- {projects.map((proj) => ( + {loading &&

正在从后端载入项目...

} + {filteredProjects.map((proj) => ( ))} @@ -111,7 +133,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri {isSidebarCollapsed && (
- {projects.map(p => ( + {filteredProjects.map(p => (
setSelectedProject(p)} @@ -170,6 +192,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri

PATIENT ID: {selectedProject.id}_XYZ

SCAN DATE: {selectedProject.createTime}

+

DICOM PATH: {selectedProject.dicomPath}

@@ -178,14 +201,14 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
WW/WL: 400/40 - SLICE: {sliceIndex}/128 + SLICE: {sliceIndex}/{selectedProject.dicomCount}
{/* Right: Vertical Progress Bar */}
NAV setSliceIndex(Number(e.target.value))} className="flex-1 w-1.5 appearance-none bg-slate-200 rounded-full focus:outline-none accent-blue-600 cursor-pointer" style={{ writingMode: 'bt-lr' as any }} @@ -204,7 +227,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
- POLYGONS: 2.1M | VERTS: 1.2M + MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0}
{/* Right: Sub-module List */} @@ -232,7 +255,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri

{m}

-

STL | {i * 4 + 2} MB

+

STL | {selectedProject.stlFiles?.[i]}

diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index ff9dd0e..3dba01d 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -16,7 +16,8 @@ import { } from 'lucide-react'; import { Canvas } from '@react-three/fiber'; import { OrbitControls, Stage, PerspectiveCamera, Grid } from '@react-three/drei'; -import { MaskMapping } from '../types'; +import { MaskMapping, Project } from '../types'; +import { api, downloadMask } from '../lib/api'; function InteractiveModel({ offset }: { offset: [number, number, number] }) { return ( @@ -31,11 +32,14 @@ function InteractiveModel({ offset }: { offset: [number, number, number] }) { ); } -export default function ReverseWorkspace() { +export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [slice, setSlice] = useState(50); const [isRegistering, setIsRegistering] = useState(false); const [progress, setProgress] = useState(0); const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]); + const [project, setProject] = useState(null); + const [exporting, setExporting] = useState(false); + const [exportMessage, setExportMessage] = useState('准备就绪'); const [mappings, setMappings] = useState([ { className: '骨样组织', color: '#ff4d4f', maskId: 1 }, @@ -48,6 +52,23 @@ export default function ReverseWorkspace() { setProgress(0); }; + const handleExport = async (format: 'nii' | 'nii.gz') => { + setExporting(true); + setExportMessage(`正在生成 ${format.toUpperCase()} 分割 Mask...`); + try { + await downloadMask(projectId, format); + setExportMessage(`${format.toUpperCase()} 分割 Mask 已生成并开始下载`); + } catch (err) { + setExportMessage(err instanceof Error ? err.message : '导出失败'); + } finally { + setExporting(false); + } + }; + + useEffect(() => { + api.getProject(projectId).then(setProject).catch(() => setProject(null)); + }, [projectId]); + useEffect(() => { if (isRegistering && progress < 100) { const timer = setTimeout(() => setProgress(p => p + 2), 50); @@ -62,7 +83,9 @@ export default function ReverseWorkspace() {

逆向工作区

-

配准 DICOM 影像与三维模型,生成像素映射关系

+

+ {project ? `${project.name} · ${project.dicomPath} ↔ ${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'} +

-
@@ -170,7 +197,7 @@ export default function ReverseWorkspace() { Metadata
-                {`{ id: "SM_091", voxel: 124K }`}
+                {`{ project: "${project?.id ?? projectId}", format: "nii.gz" }`}
               
@@ -184,11 +211,19 @@ export default function ReverseWorkspace() { 分割 Mask 图片展示
- - @@ -250,7 +285,7 @@ export default function ReverseWorkspace() {
导出进度 - 准备就绪,包含 {mappings.length} 个标注层级 + {exportMessage},包含 {mappings.length} 个标注层级
diff --git a/WebSite/src/components/UserManagement.tsx b/WebSite/src/components/UserManagement.tsx index 5d7a736..77b7004 100644 --- a/WebSite/src/components/UserManagement.tsx +++ b/WebSite/src/components/UserManagement.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { motion } from 'motion/react'; import { Users, @@ -11,26 +12,51 @@ import { Trash2, Key } from 'lucide-react'; - -const today = new Date().toISOString().split('T')[0]; - -const users = [ - { id: 1, name: 'Admin', account: 'admin', department: 'admin', date: today }, - { id: 2, name: 'Doctor Li', account: 'doctor_li', department: '肝胆外科', date: today }, -]; +import { api } from '../lib/api'; +import { UserRecord } from '../types'; export default function UserManagement() { + const [users, setUsers] = useState([]); + const [message, setMessage] = useState('登录、用户和项目状态由后端统一同步'); + const [resetting, setResetting] = useState(false); + + const refreshUsers = () => { + api.getUsers().then(setUsers).catch(() => setMessage('用户列表同步失败')); + }; + + useEffect(() => { + refreshUsers(); + }, []); + + const handleReset = async () => { + setResetting(true); + setMessage('正在恢复演示环境...'); + try { + const result = await api.resetDemo(); + setUsers(result.users); + setMessage('演示环境已恢复:默认用户、Head_CT_DICOM 与 Head_CT_ReConstruct 项目已重新载入'); + } catch (err) { + setMessage(err instanceof Error ? err.message : '恢复失败'); + } finally { + setResetting(false); + } + }; + return (

系统管理工作区

-

配置团队成员、权限及环境初始化

+

{message}

-