diff --git a/apps/desktop/electron/core/pacs.ts b/apps/desktop/electron/core/pacs.ts index 50f24ea..03bd332 100644 --- a/apps/desktop/electron/core/pacs.ts +++ b/apps/desktop/electron/core/pacs.ts @@ -1,26 +1,104 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import log from 'electron-log' +import log from "electron-log"; import path from "node:path"; +import FormData from "form-data"; +import { readFile } from "fs/promises"; +import axios from "axios"; +import { PatientInfo, SeriesInfo, StudyInfo } from "./pacs.type"; -export const getPacsPath = (platform: "macos" | "windows", isDevelopment: boolean): string => { +export const OrthancServerRoot = "http://localhost:8042"; + +export const getPacsPath = ( + platform: "macos" | "windows", + isDevelopment: boolean +): string => { const orthancExecFile = { macos: "orthanc-mac-24.8.1/Orthanc", - windows: "orthanc-win64-1.12.4/Orthanc.exe" - } + windows: "orthanc-win64-1.12.4/Orthanc.exe", + }; const basePath = isDevelopment ? path.join(process.env.VITE_PUBLIC, "../extraResources") - : path.join(process.resourcesPath, 'lib'); - return path.join(basePath, orthancExecFile[platform]) -} + : path.join(process.resourcesPath, "lib"); + return path.join(basePath, orthancExecFile[platform]); +}; export const runOrthancServer = (pacsPath: string) => { if (existsSync(pacsPath)) { - const child_process = spawn(pacsPath) - child_process.stdout.on('data', data => log.info(data)) + const child_process = spawn(pacsPath); + child_process.stdout.on("data", (data) => log.info(data)); // child_process.stderr.on('data', data => log.error(data)) } else { - console.error('pacsPath is a not exist') - log.error('pacsPath is a not exist') + console.error("pacsPath is a not exist"); + log.error("pacsPath is a not exist"); } -} \ No newline at end of file +}; + +/** + * 上传文件到到pacs + * @param {string} filePath 文件地址 + * @param {string} orthancUrl orthanc的服务地址 + * @returns + */ +export const uploadDicomFile = async ( + filePath: string, + orthancUrl: string = OrthancServerRoot +): Promise => { + try { + const buffer = await readFile(filePath); + const fd = new FormData(); + fd.append("files", buffer); + const url = `${orthancUrl}/instances`; + const headers = { "Content-Type": "multipart/form-data" }; + const { status } = await axios.post(url, fd, { headers }); + return status === 200; + } catch (error) { + log.error("Failed to upload DICOM file:", error); + console.error("Failed to upload DICOM file:", error); + return false; + } +}; + +export const selectStructuredDicom = async ( + orthancUrl: string = OrthancServerRoot +) => { + try { + const response = await axios.get(`${orthancUrl}/patients`); + const patientIds: string[] = response.data; + const patients: PatientInfo[] = []; + + for (const patientId of patientIds) { + const patientDetailsResponse = await axios.get( + `${orthancUrl}/patients/${patientId}` + ); + const patientDetails = patientDetailsResponse.data; + + const studyIds = patientDetails.Studies; + const studies: StudyInfo[] = []; + + for (const studyId of studyIds) { + const studyDetailsResponse = await axios.get( + `${orthancUrl}/studies/${studyId}` + ); + const studyDetails = studyDetailsResponse.data; + + const seriesIds = studyDetails.Series; + const series: SeriesInfo[] = []; + + for (const seriesId of seriesIds) { + const seriesDetailsResponse = await axios.get( + `${orthancUrl}/series/${seriesId}` + ); + const seriesDetails = seriesDetailsResponse.data; + series.push({ ...seriesDetails }); + } + studies.push({ ...studyDetails, children: series }); + } + patients.push({ ...patientDetails, children: studies }); + } + return patients; + } catch (error) { + console.error("Error fetching detailed patient information:", error); + throw error; // or handle it accordingly + } +}; diff --git a/apps/desktop/electron/core/pacs.type.ts b/apps/desktop/electron/core/pacs.type.ts new file mode 100644 index 0000000..8ef1593 --- /dev/null +++ b/apps/desktop/electron/core/pacs.type.ts @@ -0,0 +1,68 @@ +export interface PatientInfo { + ID: string; + LastUpdate: string; + MainDicomTags: { + OtherPatientIDs: string; + PatientBirthDate: string; + PatientID: string; + PatientName: string; + PatientSex: string; + }; + Studies: string[]; + Type: string; + children: StudyInfo[]; +} + +export interface StudyInfo { + ID: string; + MainDicomTags: { + AccessionNumber: string; + StudyDate: string; + StudyDescription: string; + StudyID: string; + StudyInstanceUID: string; + StudyTime: string; + }; + ParentPatient: string; + Series: string[]; + Type: string; + children: SeriesInfo[]; +} + +export interface SeriesInfo { + ExpectedNumberOfInstances: number; + ID: string; + Instances: string[]; + MainDicomTags: { + Manufacturer: string; + Modality: string; + NumberOfSlices: string; + ProtocolName: string; + SeriesDate: string; + SeriesDescription: string; + SeriesInstanceUID: string; + SeriesNumber: string; + SeriesTime: string; + StationName: string; + }; + ParentStudy: string; + Status: string; + Type: string; + children: []; +} + +export interface InstanceInfo { + FileSize: number; + FileUuid: string; + ID: string; + IndexInSeries: number; + MainDicomTags: { + ImageIndex: string; + InstanceCreationDate: string; + InstanceCreationTime: string; + InstanceNumber: string; + SOPInstanceUID: string; + }; + ParentSeries: string; + Type: string; +} diff --git a/apps/desktop/electron/ipcEvent/common/index.ts b/apps/desktop/electron/ipcEvent/common/index.ts new file mode 100644 index 0000000..9e3aaf7 --- /dev/null +++ b/apps/desktop/electron/ipcEvent/common/index.ts @@ -0,0 +1,33 @@ +import { ipcMain, shell } from "electron"; +import { mkdir, stat } from "fs/promises"; +import { db } from "../../core/db"; +import log from "electron-log"; +import path from "node:path"; + +export const registerCommonHandler = () => { + ipcMain.handle("output:open", async () => { + await db.read(); + const optPath = db.data.setting.outputPath; + const resolvedPath = path.resolve(optPath); + try { + // 检查路径是否存在 + const stats = await stat(resolvedPath); + if (stats.isDirectory()) shell.openPath(resolvedPath); + } catch (error) { + log.error(error); + await mkdir(resolvedPath, { recursive: true }); + shell.openPath(resolvedPath); + } + }); + + ipcMain.handle("device:infer:set", async (_event, inferDevice) => { + try { + await db.update(({ setting }) => ({ ...setting, inferDevice })); + return { success: true, msg: `推理硬件修改为${inferDevice}` }; + } catch (error) { + await db.update(({ setting }) => ({ ...setting, inferDevice })); + log.error(error); + return { success: false, msg: `操作失败` }; + } + }); +}; diff --git a/apps/desktop/electron/ipcEvent/dicom/handler.ts b/apps/desktop/electron/ipcEvent/dicom/handler.ts index 0053c35..36529f0 100644 --- a/apps/desktop/electron/ipcEvent/dicom/handler.ts +++ b/apps/desktop/electron/ipcEvent/dicom/handler.ts @@ -1,12 +1,32 @@ import { dialog, ipcMain } from "electron"; import { filterDicoms, uploadFilesInBatches } from "./util"; +import { selectStructuredDicom } from "../../core/pacs"; +import dayjs from "dayjs"; export const registerDicomHandler = () => { - ipcMain.handle("dicom:upload", async () => { + ipcMain.on("dicom:upload", async (event) => { const dia = await dialog.showOpenDialog({ properties: ["openDirectory"] }); if (dia.canceled) return null; const dcmPaths = await filterDicoms(dia.filePaths[0]); - uploadFilesInBatches(dcmPaths, 5); - // return dia.filePaths[0]; + uploadFilesInBatches({ + filePaths: dcmPaths, + batchSize: 6, + feedback: (d) => event.reply("dicom:upload:detail", d), + }); + }); + + ipcMain.handle("dicom:select", async () => { + const patients = await selectStructuredDicom(); + const sortedPatients = [...patients] + .map((patient) => ({ + ...patient, + LastUpdate: dayjs(patient.LastUpdate).format("YYYY-MM-DD HH:mm:ss"), // 格式化日期 + })) + .sort((a, b) => { + return ( + new Date(b.LastUpdate).getTime() - new Date(a.LastUpdate).getTime() + ); + }); + return sortedPatients; }); }; diff --git a/apps/desktop/electron/ipcEvent/dicom/util.ts b/apps/desktop/electron/ipcEvent/dicom/util.ts index e088b73..bcf4fea 100644 --- a/apps/desktop/electron/ipcEvent/dicom/util.ts +++ b/apps/desktop/electron/ipcEvent/dicom/util.ts @@ -1,8 +1,6 @@ import path from "node:path"; import fs from "node:fs"; -import axios from "axios"; -import FormData from "form-data"; -import log from "electron-log"; +import { uploadDicomFile } from "../../core/pacs"; /** * 检查文件是否为 DICOM 文件(通过 Magic Number 判断) @@ -52,49 +50,69 @@ export const filterDicoms = async ( return fileList; }; -export const uploadDicomFile = async ( - filePath: string, - orthancUrl: string = "http://localhost:8042" -) => { - try { - const buffer = await fs.promises.readFile(filePath); - const fd = new FormData(); - fd.append("files", buffer); - const url = `${orthancUrl}/instances`; - const headers = { "Content-Type": "multipart/form-data" }; - const { status } = await axios.post(url, fd, { headers }); - return status === 200; - } catch (error) { - log.error("Failed to upload DICOM file:", error); - console.error("Failed to upload DICOM file:", error); - } -}; +interface UploadFilesInBatchesParams { + filePaths: string[]; + batchSize: number; + orthancUrl?: string; + feedback?: (detail: { + progress: number; + totalSuccess: number; + totalFailed: number; + }) => void; +} -export const uploadFilesInBatches = async ( - filePaths: string[], - batchSize: number, - orthancUrl: string = "http://localhost:8042" -) => { - const results = []; - const totalStartTime = Date.now(); // 记录总体开始时间 - for (let i = 0; i < filePaths.length; i += batchSize) { +/** + * 批量上传文件,并提供进度更新。 + * @param filePaths 文件路径数组。 + * @param batchSize 每批上传的文件数量。 + * @param orthancUrl Orthanc 服务器的 URL。 + * @param feedback 每批处理完成后调用的回调函数。 + */ +export const uploadFilesInBatches = async ({ + filePaths, + batchSize, + feedback, +}: UploadFilesInBatchesParams) => { + const totalStartTime = Date.now(); + let totalSuccess = 0; + let totalFailed = 0; + const totalFiles = filePaths.length; + + for (let i = 0; i < totalFiles; i += batchSize) { const batch = filePaths.slice(i, i + batchSize); const batchResults = await Promise.allSettled( - batch.map((filePath) => uploadDicomFile(filePath, orthancUrl)) + batch.map((filePath) => uploadDicomFile(filePath)) ); - // 提取状态为 'fulfilled' 的结果的 value - const fulfilledResults = batchResults - .filter((result) => result.status === "fulfilled") - .map((result) => (result as PromiseFulfilledResult).value); - results.push(...fulfilledResults); + + batchResults.forEach((result) => { + if (result.status === "fulfilled" && result.value) { + totalSuccess++; + } else { + totalFailed++; + } + }); + + const actualProcessed = i + batch.length; + const progress = Math.round((actualProcessed / totalFiles) * 100); + const detail = { + progress: Math.min(progress, 100), + totalSuccess, + totalFailed, + }; + if (feedback) feedback(detail); } - const totalEndTime = Date.now(); // 记录总体结束时间 - const totalSuccess = results.filter(Boolean).length; - const totalFailed = results.length - totalSuccess; - log.info( + + const totalEndTime = Date.now(); + console.log( `[上传序列] Success: ${totalSuccess}, Failed: ${totalFailed}, Total upload time: ${ totalEndTime - totalStartTime } ms` ); - return results; + + return { + totalSuccess, + totalFailed, + totalTime: totalEndTime - totalStartTime, + progress: 100, + }; }; diff --git a/apps/desktop/electron/ipcEvent/index.ts b/apps/desktop/electron/ipcEvent/index.ts index 5d1a2ce..bffe811 100644 --- a/apps/desktop/electron/ipcEvent/index.ts +++ b/apps/desktop/electron/ipcEvent/index.ts @@ -1,5 +1,6 @@ import { ipcMain } from "electron"; import { registerDicomHandler } from "./dicom/handler"; +import { registerCommonHandler } from "./common"; export const registerIpcMainHandlers = (mainWindow: Electron.BrowserWindow) => { ipcMain.removeAllListeners(); @@ -9,5 +10,6 @@ export const registerIpcMainHandlers = (mainWindow: Electron.BrowserWindow) => { */ ipcMain.on("ipc-loaded", () => mainWindow.show()); + registerCommonHandler(); registerDicomHandler(); }; diff --git a/apps/desktop/src/components/base/MenuBar/index.tsx b/apps/desktop/src/components/base/MenuBar/index.tsx index addb1dd..397aeeb 100644 --- a/apps/desktop/src/components/base/MenuBar/index.tsx +++ b/apps/desktop/src/components/base/MenuBar/index.tsx @@ -13,12 +13,11 @@ import { MenubarSubTrigger, MenubarTrigger, } from "@/components/ui/menubar"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { inferDeviceType } from "./type"; import { inferDevices } from "./constant"; import { useToast } from "@/components/ui/use-toast"; -import { ToastAction } from "@/components/ui/toast"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Dialog, @@ -29,7 +28,6 @@ import { } from "@/components/ui/dialog"; import { Progress } from "@/components/ui/progress"; import { RocketIcon } from "@radix-ui/react-icons"; -import axios from "axios"; interface ScanProgress { percentage: number; @@ -45,73 +43,29 @@ export const MenuBar = () => { const [progress, setProgress] = useState(defaultProgress); const [inferOption, setInferOption] = useState(inferDevices); - const [importDialogVisible, setImportDialogVisible] = useState(false); - const [, setResult] = useState<[]>([]); - useEffect(() => { - const handleScanProgress = ( + const handleUploadFeedback = ( _event: Electron.IpcRendererEvent, - data: any + data: { progress: number; totalSuccess: number; totalFailed: number } ) => { - setProgress(data); - if (data.error) return; + setProgress({ percentage: data.progress }); }; - window.ipcRenderer.on("scan-progress", handleScanProgress); + window.ipcRenderer.on("dicom:upload:detail", handleUploadFeedback); return () => { - window.ipcRenderer.off("scan-progress", handleScanProgress); + window.ipcRenderer.off("dicom:upload:detail", handleUploadFeedback); }; }, []); useEffect(() => { - const handleScanFinished = ( - _event: Electron.IpcRendererEvent, - data: any - ) => { - const { scanDuration, structDicom } = data; - console.log(data); - setResult(structDicom); - - if (data.error) { - return toast({ - variant: "destructive", - title: "Uh oh! Something went wrong.", - description: "There was a problem with your request.", - action: 重试, - }); - } else { - toast({ - variant: "default", - title: "完成", - description: `导入${structDicom.length}组序列数据,耗时:${( - scanDuration / 1000 - ).toFixed(2)} s`, - action: ( - handleTasks(structDicom)} - > - 分析 - - ), - duration: 30 * 1000, - }); - window.ipcRenderer.send("db:series:select"); - } - }; - window.ipcRenderer.on("scan-progress-done", handleScanFinished); - return () => { - window.ipcRenderer.off("scan-progress-done", handleScanFinished); - }; - }, [toast]); + const visible = ![0, 100].includes(progress.percentage); + setImportDialogVisible(visible); + }, [progress.percentage]); const handleImportDicom = () => { navigate("datasource"); - // window.ipcRenderer.send("import-dicom-dialog-visible"); - window.ipcRenderer.invoke("dicom:upload").then((value) => { - console.log(value); - }); + window.ipcRenderer.send("dicom:upload"); }; /** @@ -121,33 +75,13 @@ export const MenuBar = () => { setInferOption((p) => p.map((i) => ({ ...i, checked: i.key === item.key })) ); - window.ipcRenderer.send("setInferDevice", item.key); + window.ipcRenderer.invoke("device:infer:set", item.key).then((res) => { + const { success, msg } = res; + if (success) toast({ title: "操作成功", description: msg }); + }); }; - useEffect(() => { - const actionToast = ( - _event: Electron.IpcRendererEvent, - response: string - ) => { - toast({ - title: "操作成功", - description: response, - }); - }; - window.ipcRenderer.on("setInferDevice:response", actionToast); - return () => { - window.ipcRenderer.off("setInferDevice:response", actionToast); - }; - }, [toast]); - - const handleOpenOutputPath = () => { - window.ipcRenderer.send("openOutputPath"); - }; - - const handleTasks = (structDicoms: any) => { - localStorage.setItem("selectDicoms", JSON.stringify(structDicoms)); - navigate("/", { state: { selectDicoms: structDicoms } }); - }; + const handleOpenOutputPath = () => window.ipcRenderer.invoke("output:open"); return ( <> @@ -158,7 +92,7 @@ export const MenuBar = () => { 文件 - 批量导入Dicom⌘T + 导入Dicom⌘T 打开输出文件夹⌘N diff --git a/apps/desktop/src/pages/Boot/index.tsx b/apps/desktop/src/pages/Boot/index.tsx index ea6d912..df5b43a 100644 --- a/apps/desktop/src/pages/Boot/index.tsx +++ b/apps/desktop/src/pages/Boot/index.tsx @@ -77,7 +77,7 @@ const Boot = () => {
-
+
{tasks.map((dicom: Series, index: number) => ( diff --git a/apps/desktop/src/pages/Datasource/index.tsx b/apps/desktop/src/pages/Datasource/index.tsx index 9b6af04..9d532be 100644 --- a/apps/desktop/src/pages/Datasource/index.tsx +++ b/apps/desktop/src/pages/Datasource/index.tsx @@ -1,30 +1,27 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useCallback, useEffect, useState } from "react"; -import { Series, SeriesTable } from "./SeriesTable"; +import { useEffect, useState } 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 } from "./type"; export const Datasource = () => { - const [seriesData, setSeriesData] = useState([]); + const [patients, setPatients] = useState< + (PatientInfo & { active: boolean })[] + >([]); useEffect(() => { - window.ipcRenderer.send("db:series:select"); + window.ipcRenderer + .invoke("dicom:select") + .then((patients: PatientInfo[]) => { + console.log(patients); + setPatients(patients.map((p) => ({ ...p, active: false }))); + }); }, []); - const fetchSeriesData = useCallback( - (_event: Electron.IpcRendererEvent, data: any) => { - console.log(data); - setSeriesData(data); - }, - [] - ); - - useEffect(() => { - window.ipcRenderer.once("db:series:select:response", fetchSeriesData); - return () => { - window.ipcRenderer.off("db:series:select:response", fetchSeriesData); - }; - }, [fetchSeriesData]); - return ( { exit={{ y: 0, opacity: 0 }} transition={{ duration: 0.25 }} > - +
+ + +
+ +
+ {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} +
+
+ ))} +
+
+
+
+ + + 123 + + + 33 +
+
); }; diff --git a/apps/desktop/src/pages/Datasource/type.ts b/apps/desktop/src/pages/Datasource/type.ts new file mode 100644 index 0000000..8ef1593 --- /dev/null +++ b/apps/desktop/src/pages/Datasource/type.ts @@ -0,0 +1,68 @@ +export interface PatientInfo { + ID: string; + LastUpdate: string; + MainDicomTags: { + OtherPatientIDs: string; + PatientBirthDate: string; + PatientID: string; + PatientName: string; + PatientSex: string; + }; + Studies: string[]; + Type: string; + children: StudyInfo[]; +} + +export interface StudyInfo { + ID: string; + MainDicomTags: { + AccessionNumber: string; + StudyDate: string; + StudyDescription: string; + StudyID: string; + StudyInstanceUID: string; + StudyTime: string; + }; + ParentPatient: string; + Series: string[]; + Type: string; + children: SeriesInfo[]; +} + +export interface SeriesInfo { + ExpectedNumberOfInstances: number; + ID: string; + Instances: string[]; + MainDicomTags: { + Manufacturer: string; + Modality: string; + NumberOfSlices: string; + ProtocolName: string; + SeriesDate: string; + SeriesDescription: string; + SeriesInstanceUID: string; + SeriesNumber: string; + SeriesTime: string; + StationName: string; + }; + ParentStudy: string; + Status: string; + Type: string; + children: []; +} + +export interface InstanceInfo { + FileSize: number; + FileUuid: string; + ID: string; + IndexInSeries: number; + MainDicomTags: { + ImageIndex: string; + InstanceCreationDate: string; + InstanceCreationTime: string; + InstanceNumber: string; + SOPInstanceUID: string; + }; + ParentSeries: string; + Type: string; +}