diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 447ec14..e68f596 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -29,7 +29,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL let win: BrowserWindow | null; let tray: Tray | null = null; -const theme: "dark" | "light" = "light"; +const theme: "dark" | "light" = "dark"; const themeTitleBarStyles = { dark: { color: "rgb(32,32,32)", symbolColor: "#fff" }, diff --git a/apps/desktop/src/pages/Datasource/PatientCard.tsx b/apps/desktop/src/pages/Datasource/PatientCard.tsx new file mode 100644 index 0000000..747dc09 --- /dev/null +++ b/apps/desktop/src/pages/Datasource/PatientCard.tsx @@ -0,0 +1,44 @@ +import { Card } from "@/components/ui/card"; +import { PatientInfo } from "./type"; + +interface PatientCardProps { + patient: PatientInfo; + isSelected: boolean; + onSelect: (id: string) => void; +} + +export const PatientCard = ({ + patient, + isSelected, + onSelect, +}: PatientCardProps) => { + return ( + onSelect(patient.ID)} + className={`flex shadow-none flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent hover:cursor-pointer ${ + isSelected ? "bg-accent" : "" + }`} + > +
+
+
+
+ {patient.MainDicomTags.PatientName} +
+ +
+
+ {patient.MainDicomTags.PatientSex} +
+
+
+ {patient.MainDicomTags.PatientBirthDate} +
+
+
+ 上次更新: {patient.LastUpdate} +
+
+ ); +}; diff --git a/apps/desktop/src/pages/Datasource/PatientList.tsx b/apps/desktop/src/pages/Datasource/PatientList.tsx new file mode 100644 index 0000000..2403082 --- /dev/null +++ b/apps/desktop/src/pages/Datasource/PatientList.tsx @@ -0,0 +1,30 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { PatientCard } from "./PatientCard"; +import { PatientInfo } from "./type"; + +interface PatientListProps { + patients: PatientInfo[]; + selectedPatientId: string | null; + onSelectPatient: (id: string) => void; +} + +export const PatientList = ({ + patients, + selectedPatientId, + onSelectPatient, +}: PatientListProps) => { + return ( + +
+ {patients.map((patient) => ( + + ))} +
+
+ ); +}; diff --git a/apps/desktop/src/pages/Datasource/SeriesCard.tsx b/apps/desktop/src/pages/Datasource/SeriesCard.tsx new file mode 100644 index 0000000..9652cf5 --- /dev/null +++ b/apps/desktop/src/pages/Datasource/SeriesCard.tsx @@ -0,0 +1,94 @@ +import { Card } from "@/components/ui/card"; +import { + EllipsisIcon, + FileDown, + Rotate3DIcon, + Sparkles, + Torus, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SeriesInfo } from "./type"; + +interface SeriesCardProps { + series: SeriesInfo; + onAction: (action: string, series: SeriesInfo) => void; +} + +export const SeriesCard = ({ series, onAction }: SeriesCardProps) => { + return ( + +
+
+
+
+ 序号 {series.MainDicomTags.SeriesNumber} +
+
+
+ + + + + + + onAction("viewMpr", series)}> + MPR 阅片 + + + + + onAction("view3D", series)}> + 3D 重建 + + + + + onAction("exportMeasurement", series)} + > + 导出测量 + + + + + onAction("viewReport", series)} + > + 查看报告 + + + + + + + +
+
+
+ {series.MainDicomTags.SeriesDescription} +
+
+
+
+ {series.Instances.length} + {series.MainDicomTags.BodyPartExamined && ( + + {series.MainDicomTags.BodyPartExamined} + + )} +
+
+
+ ); +}; diff --git a/apps/desktop/src/pages/Datasource/SeriesList.tsx b/apps/desktop/src/pages/Datasource/SeriesList.tsx new file mode 100644 index 0000000..4a533c9 --- /dev/null +++ b/apps/desktop/src/pages/Datasource/SeriesList.tsx @@ -0,0 +1,24 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SeriesCard } from "./SeriesCard"; +import { SeriesInfo } from "./type"; + +interface SeriesListProps { + seriesList: SeriesInfo[]; + onAction: (action: string, series: SeriesInfo) => void; +} + +export const SeriesList = ({ seriesList, onAction }: SeriesListProps) => { + return ( + +
+ {seriesList.map((series) => ( + + ))} +
+
+ ); +}; diff --git a/apps/desktop/src/pages/Datasource/StudyCard.tsx b/apps/desktop/src/pages/Datasource/StudyCard.tsx new file mode 100644 index 0000000..b22a43c --- /dev/null +++ b/apps/desktop/src/pages/Datasource/StudyCard.tsx @@ -0,0 +1,37 @@ +// StudyCard.tsx +import { Card } from "@/components/ui/card"; +import { StudyInfo } from "./type"; + +interface StudyCardProps { + study: StudyInfo; + isSelected: boolean; + onSelect: (id: string) => void; +} + +export const StudyCard = ({ study, isSelected, onSelect }: StudyCardProps) => { + return ( + onSelect(study.ID)} + className={`flex shadow-none flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent hover:cursor-pointer ${ + isSelected ? "bg-accent" : "" + }`} + > +
+
+
+
+ {study.MainDicomTags.StudyID ?? "无"} +
+
+
+ {study.MainDicomTags.StudyDate} +
+
+
+
+ {study.MainDicomTags.InstitutionName} +
+
+ ); +}; diff --git a/apps/desktop/src/pages/Datasource/StudyList.tsx b/apps/desktop/src/pages/Datasource/StudyList.tsx new file mode 100644 index 0000000..56f7768 --- /dev/null +++ b/apps/desktop/src/pages/Datasource/StudyList.tsx @@ -0,0 +1,30 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { StudyCard } from "./StudyCard"; +import { StudyInfo } from "./type"; + +interface StudyListProps { + studies: StudyInfo[]; + selectedStudyId: string | null; + onSelectStudy: (id: string) => void; +} + +export const StudyList = ({ + studies, + selectedStudyId, + onSelectStudy, +}: StudyListProps) => { + return ( + +
+ {studies.map((study) => ( + + ))} +
+
+ ); +}; diff --git a/apps/desktop/src/pages/Datasource/index.tsx b/apps/desktop/src/pages/Datasource/index.tsx index e9303ca..85351f5 100644 --- a/apps/desktop/src/pages/Datasource/index.tsx +++ b/apps/desktop/src/pages/Datasource/index.tsx @@ -1,41 +1,26 @@ -import { useEffect, useRef, useState } from "react"; +// Datasource.tsx +import { useEffect, useRef, useState, useMemo } from "react"; import { motion } from "framer-motion"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Card } from "@/components/ui/card"; -import { PatientInfo, SeriesInfo, StudyInfo } from "./type"; import { Input } from "@/components/ui/input"; import { Search } from "lucide-react"; -import { - EllipsisIcon, - FileDown, - Rotate3DIcon, - Sparkles, - Torus, -} from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { useNavigate } from "react-router-dom"; - -interface SeriesClickItem { - action: "viewMpr" | "view3D" | "exportMeaurement" | "viewReport"; - series: SeriesInfo; -} +import { PatientInfo, SeriesInfo } from "./type"; +import { PatientList } from "./PatientList"; +import { StudyList } from "./StudyList"; +import { SeriesList } from "./SeriesList"; export const Datasource = () => { const rawPatientsRef = useRef([]); const [patients, setPatients] = useState([]); + const [selectedPatientId, setSelectedPatientId] = useState( + null + ); + const [selectedStudyId, setSelectedStudyId] = useState(null); const navigate = useNavigate(); useEffect(() => { @@ -44,43 +29,47 @@ export const Datasource = () => { .then((patients: PatientInfo[]) => { console.log(patients); rawPatientsRef.current = patients; - setPatients( - patients.map((p) => ({ - ...p, - active: false, - children: p.children.map((s) => ({ ...s, active: false })), - })) - ); + setPatients(patients); }); }, []); - const onClickItem = (p: SeriesClickItem) => { - const activeStudy = patients - .find((p) => p.active) - ?.children.find((s) => s.active); - const StudyInstanceUID = activeStudy?.MainDicomTags.StudyInstanceUID; - const SeriesInstanceUID = p.series.MainDicomTags.SeriesInstanceUID; + const handlePatientSearch = (filterValue: string) => { + const raw = rawPatientsRef.current; + const filteredPatients = raw.filter((p) => + p.MainDicomTags.PatientName.toUpperCase().includes( + filterValue.toUpperCase() + ) + ); + setPatients(filteredPatients); + if (!filteredPatients.find((p) => p.ID === selectedPatientId)) { + setSelectedPatientId(null); + setSelectedStudyId(null); + } + }; + + const selectedPatient = useMemo(() => { + return patients.find((p) => p.ID === selectedPatientId) || null; + }, [patients, selectedPatientId]); + + const selectedStudy = useMemo(() => { + return ( + selectedPatient?.children.find((s) => s.ID === selectedStudyId) || null + ); + }, [selectedPatient, selectedStudyId]); + + const handleSeriesAction = (action: string, series: SeriesInfo) => { + const StudyInstanceUID = selectedStudy?.MainDicomTags.StudyInstanceUID; + const SeriesInstanceUID = series.MainDicomTags.SeriesInstanceUID; const query = `StudyInstanceUID=${StudyInstanceUID}&SeriesInstanceUID=${SeriesInstanceUID}`; - switch (p.action) { + switch (action) { case "viewMpr": navigate(`/viewer?${query}`, { replace: true }); break; - + // 其他操作逻辑 default: break; } - console.log(StudyInstanceUID, p.action, p.series); - }; - - const handlePatientSearch = (filterValue: string) => { - const raw = rawPatientsRef.current; - setPatients( - raw.filter((p) => - p.MainDicomTags.PatientName.toUpperCase().includes( - filterValue.toUpperCase() - ) - ) - ); + console.log(StudyInstanceUID, action, series); }; return ( @@ -93,7 +82,7 @@ export const Datasource = () => { >
- +
@@ -105,178 +94,37 @@ export const Datasource = () => { />
- -
- {patients.map((patient) => ( - - setPatients((p) => - p.map((i) => ({ ...i, active: i.ID === patient.ID })) - ) - } - className={`flex shadow-none flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent hover:cursor-pointer ${ - patient.active ? "bg-accent" : "" - }`} - > -
-
-
-
- {patient.MainDicomTags.PatientName} -
- -
-
- {patient.MainDicomTags.PatientSex} -
-
-
- {patient.MainDicomTags.PatientBirthDate} -
-
-
- 上次更新: {patient.LastUpdate} -
-
- ))} -
-
+ { + setSelectedPatientId(id); + setSelectedStudyId(null); + }} + />
- +
- -
- {patients.find((p) => p.active) && - patients - .find((p) => p.active) - ?.children.map((study) => ( - - setPatients((p) => - p.map((i) => ({ - ...i, - children: i.children.map((s) => ({ - ...s, - active: s.ID === study.ID, - })), - })) - ) - } - className={`flex shadow-none flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent hover:cursor-pointer ${ - study.active ? "bg-accent" : "" - }`} - > -
-
-
-
- {study.MainDicomTags.StudyID ?? "无"} -
-
-
- {study.MainDicomTags.StudyDate} -
-
-
-
- {study.MainDicomTags.InstitutionName} -
-
- ))} -
-
+ {selectedPatient && ( + setSelectedStudyId(id)} + /> + )}
- +
- -
- {patients.find((p) => p.active) && - patients - .find((p) => p.active) - ?.children.find((study) => study.active) - ?.children.map((series) => ( - -
-
-
-
- 序号 {series.MainDicomTags.SeriesNumber} -
-
-
- - - - - - - - onClickItem({ - series, - action: "viewMpr", - }) - } - > - MPR 阅片 - - - - - - 3D 重建 - - - - - - 导出测量 - - - - - - 查看报告 - - - - - - - -
-
-
- - {series.MainDicomTags.SeriesDescription} - -
-
-
-
- - {series.Instances.length} - - {series.MainDicomTags.BodyPartExamined && ( - - {series.MainDicomTags.BodyPartExamined} - - )} -
-
-
- ))} -
-
+ {selectedStudy && ( + + )}
diff --git a/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx b/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx index ae73eef..3a5f603 100644 --- a/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx +++ b/apps/desktop/src/pages/Viewer/MprViewer/Crosshair.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import * as cornerstoneTools from "@cornerstonejs/tools"; import { PublicViewportInput } from "@cornerstonejs/core/dist/types/types/IViewport.js"; import { createImageIdsAndCacheMetaData } from "./CornerstoneDicomLoader/createImageIdsAndCacheMetaData"; @@ -20,17 +20,12 @@ import { ViewportId, } from "./Crosshair.config"; -import GridLayout from 'react-grid-layout'; -import 'react-grid-layout/css/styles.css'; -import 'react-resizable/css/styles.css'; -import { DraftingCompassIcon, MoveIcon } from "lucide-react"; -import { Card } from "@/components/ui/card"; - const { ToolGroupManager, CrosshairsTool, StackScrollMouseWheelTool, WindowLevelTool, + ZoomTool, Enums: csToolsEnums, } = cornerstoneTools; @@ -72,14 +67,22 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { const viewportRef_SAGITTAL = useRef(null); const viewportRef_CORONAL = useRef(null); const renderingEngine = useRef(); + const toolGroupIdRef = useRef(""); + const toolGroupRef = useRef( + undefined + ); const imageIds = useRef(); const ts = "-" + Date.now(); - useEffect(() => { cornerstoneTools.addTool(StackScrollMouseWheelTool); cornerstoneTools.addTool(CrosshairsTool); cornerstoneTools.addTool(WindowLevelTool); + cornerstoneTools.addTool(ZoomTool); + + const toolGroupId = props.SeriesInstanceUID + ts; + toolGroupIdRef.current = toolGroupId; + toolGroupRef.current = ToolGroupManager.createToolGroup(toolGroupId); const run = async () => { if ( @@ -92,9 +95,6 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { renderingEngine.current = new RenderingEngine( props.SeriesInstanceUID + ts ); - const toolGroup = ToolGroupManager.createToolGroup( - props.SeriesInstanceUID + ts - ); imageIds.current = await createImageIdsAndCacheMetaData({ StudyInstanceUID: props.StudyInstanceUID, @@ -124,7 +124,7 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { element: viewportRef_AXIAL.current, defaultOptions: { orientation: CoreEnums.OrientationAxis.AXIAL, - background: [0, 0, 0], + background: [0, 0, 0], }, }, { @@ -169,38 +169,59 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { [viewportId1, viewportId2, viewportId3] ); - if (toolGroup) { + if (toolGroupRef.current) { // For the crosshairs to operate, the viewports must currently be // added ahead of setting the tool active. This will be improved in the future. - toolGroup.addViewport(viewportId1, props.SeriesInstanceUID + ts); - toolGroup.addViewport(viewportId2, props.SeriesInstanceUID + ts); - toolGroup.addViewport(viewportId3, props.SeriesInstanceUID + ts); + toolGroupRef.current.addViewport( + viewportId1, + props.SeriesInstanceUID + ts + ); + toolGroupRef.current.addViewport( + viewportId2, + props.SeriesInstanceUID + ts + ); + toolGroupRef.current.addViewport( + viewportId3, + props.SeriesInstanceUID + ts + ); + + /** + * zoom影像 + */ + toolGroupRef.current.addTool(ZoomTool.toolName); + toolGroupRef.current.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, // 鼠标中键 + }, + ], + }); // Manipulation Tools - toolGroup.addTool(StackScrollMouseWheelTool.toolName); + toolGroupRef.current.addTool(StackScrollMouseWheelTool.toolName); // Add Crosshairs tool and configure it to link the three viewports // These viewports could use different tool groups. See the PET-CT example // for a more complicated used case. - toolGroup.addTool(CrosshairsTool.toolName, { + toolGroupRef.current.addTool(CrosshairsTool.toolName, { getReferenceLineColor, getReferenceLineControllable, getReferenceLineDraggableRotatable, getReferenceLineSlabThicknessControlsOn, }); - toolGroup.setToolActive(CrosshairsTool.toolName, { + toolGroupRef.current.setToolActive(CrosshairsTool.toolName, { bindings: [{ mouseButton: 1 }], }); // As the Stack Scroll mouse wheel is a tool using the `mouseWheelCallback` // hook instead of mouse buttons, it does not need to assign any mouse button. - toolGroup.setToolActive(StackScrollMouseWheelTool.toolName); + toolGroupRef.current.setToolActive(StackScrollMouseWheelTool.toolName); - toolGroup.addTool(WindowLevelTool.toolName); - toolGroup.setToolActive(WindowLevelTool.toolName, { + toolGroupRef.current.addTool(WindowLevelTool.toolName); + toolGroupRef.current.setToolActive(WindowLevelTool.toolName, { bindings: [ { - mouseButton: MouseBindings.Secondary, + mouseButton: MouseBindings.Auxiliary, }, ], }); @@ -218,37 +239,50 @@ export const CrosshairMpr = (props: CrosshairMprProps) => { } return () => { + // 禁用视口 + renderingEngine.current?.disableElement(viewportId1); + renderingEngine.current?.disableElement(viewportId2); + renderingEngine.current?.disableElement(viewportId3); + + // 销毁渲染引擎 + renderingEngine.current?.destroy(); + + // 从 ToolGroupManager 中移除工具组 + ToolGroupManager.destroyToolGroup(toolGroupIdRef.current); + + // 移出工具注册 cornerstoneTools.removeTool(StackScrollMouseWheelTool); cornerstoneTools.removeTool(CrosshairsTool); cornerstoneTools.removeTool(WindowLevelTool); - renderingEngine.current?.destroy(); - ToolGroupManager.destroyToolGroup(props.SeriesInstanceUID + ts); + cornerstoneTools.removeTool(ZoomTool); }; }, [props, ts]); + /** + * mpr resize + */ useEffect(() => { - const resize = () => renderingEngine.current?.resize() - window.addEventListener("resize", resize); + const container = containerRef.current; + if (!container) return; + let resizeTimeout: NodeJS.Timeout | null = null; + const resizeObserver = new ResizeObserver(() => { + console.log("mpr resize"); + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => renderingEngine.current?.resize(), 100); + }); + resizeObserver.observe(container); return () => { - window.removeEventListener("resize", resize); + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeObserver.unobserve(container); + resizeObserver.disconnect(); }; }, []); - const onDBClickViewport = (viewportRef) => { - console.log("dblcik", viewportRef); - }; - return (
- -
onDBClickViewport(viewportRef_AXIAL)} ref={viewportRef_AXIAL}>
-
- -
-
- -
-
+
+
+
); }; diff --git a/apps/desktop/src/pages/Viewer/MprViewer/ToolBarMenu/index.tsx b/apps/desktop/src/pages/Viewer/MprViewer/ToolBarMenu/index.tsx new file mode 100644 index 0000000..a9faf9f --- /dev/null +++ b/apps/desktop/src/pages/Viewer/MprViewer/ToolBarMenu/index.tsx @@ -0,0 +1,39 @@ +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { MoonIcon, PersonStanding } from "lucide-react"; + +export const ToolBarMenu = () => { + return ( +
+ + + + + + +

窗宽/窗位

+
+
+
+ + + + + + +

MIP

+
+
+
+
+ ); +}; diff --git a/apps/desktop/src/pages/Viewer/index.tsx b/apps/desktop/src/pages/Viewer/index.tsx index d7f5407..f8d7c04 100644 --- a/apps/desktop/src/pages/Viewer/index.tsx +++ b/apps/desktop/src/pages/Viewer/index.tsx @@ -2,7 +2,12 @@ import { useLocation } from "react-router-dom"; import { initCornerstone } from "./MprViewer/CornerstoneDicomLoader/init"; import { CrosshairMpr } from "./MprViewer/Crosshair"; import { useEffect, useState } from "react"; - +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { ToolBarMenu } from "./MprViewer/ToolBarMenu"; export interface CurrentDicom { SeriesInstanceUID: string | null; @@ -27,7 +32,7 @@ export const Viewer = () => { }, [currentDicom]); useEffect(() => { - console.log(window.location.href) + console.log(window.location.href); initCornerstone(() => { setCornerstoneLoaded(true); }); @@ -37,19 +42,42 @@ export const Viewer = () => { console.log(cornerstoneLoaded); }, [cornerstoneLoaded]); - return ( -
-
vr
-
- {cornerstoneLoaded && - currentDicom.StudyInstanceUID && - currentDicom.SeriesInstanceUID && ( - - )}
+
+
+ +
+
+ + +
+ + +
top
+
+ + +
bototm
+
+
+
+
+ + + {cornerstoneLoaded && + currentDicom.StudyInstanceUID && + currentDicom.SeriesInstanceUID && ( + + )} + +
+
); };