feat: 更新了查询orthanc数据结构
This commit is contained in:
parent
9b905b7498
commit
5aa710431d
|
@ -1,26 +1,104 @@
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import log from 'electron-log'
|
import log from "electron-log";
|
||||||
import path from "node:path";
|
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 = {
|
const orthancExecFile = {
|
||||||
macos: "orthanc-mac-24.8.1/Orthanc",
|
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
|
const basePath = isDevelopment
|
||||||
? path.join(process.env.VITE_PUBLIC, "../extraResources")
|
? path.join(process.env.VITE_PUBLIC, "../extraResources")
|
||||||
: path.join(process.resourcesPath, 'lib');
|
: path.join(process.resourcesPath, "lib");
|
||||||
return path.join(basePath, orthancExecFile[platform])
|
return path.join(basePath, orthancExecFile[platform]);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const runOrthancServer = (pacsPath: string) => {
|
export const runOrthancServer = (pacsPath: string) => {
|
||||||
if (existsSync(pacsPath)) {
|
if (existsSync(pacsPath)) {
|
||||||
const child_process = spawn(pacsPath)
|
const child_process = spawn(pacsPath);
|
||||||
child_process.stdout.on('data', data => log.info(data))
|
child_process.stdout.on("data", (data) => log.info(data));
|
||||||
// child_process.stderr.on('data', data => log.error(data))
|
// child_process.stderr.on('data', data => log.error(data))
|
||||||
} else {
|
} else {
|
||||||
console.error('pacsPath is a not exist')
|
console.error("pacsPath is a not exist");
|
||||||
log.error('pacsPath is a not exist')
|
log.error("pacsPath is a not exist");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件到到pacs
|
||||||
|
* @param {string} filePath 文件地址
|
||||||
|
* @param {string} orthancUrl orthanc的服务地址
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const uploadDicomFile = async (
|
||||||
|
filePath: string,
|
||||||
|
orthancUrl: string = OrthancServerRoot
|
||||||
|
): Promise<boolean> => {
|
||||||
|
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<PatientInfo>(
|
||||||
|
`${orthancUrl}/patients/${patientId}`
|
||||||
|
);
|
||||||
|
const patientDetails = patientDetailsResponse.data;
|
||||||
|
|
||||||
|
const studyIds = patientDetails.Studies;
|
||||||
|
const studies: StudyInfo[] = [];
|
||||||
|
|
||||||
|
for (const studyId of studyIds) {
|
||||||
|
const studyDetailsResponse = await axios.get<StudyInfo>(
|
||||||
|
`${orthancUrl}/studies/${studyId}`
|
||||||
|
);
|
||||||
|
const studyDetails = studyDetailsResponse.data;
|
||||||
|
|
||||||
|
const seriesIds = studyDetails.Series;
|
||||||
|
const series: SeriesInfo[] = [];
|
||||||
|
|
||||||
|
for (const seriesId of seriesIds) {
|
||||||
|
const seriesDetailsResponse = await axios.get<SeriesInfo>(
|
||||||
|
`${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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
68
apps/desktop/electron/core/pacs.type.ts
Normal file
68
apps/desktop/electron/core/pacs.type.ts
Normal file
|
@ -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;
|
||||||
|
}
|
33
apps/desktop/electron/ipcEvent/common/index.ts
Normal file
33
apps/desktop/electron/ipcEvent/common/index.ts
Normal file
|
@ -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: `操作失败` };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -1,12 +1,32 @@
|
||||||
import { dialog, ipcMain } from "electron";
|
import { dialog, ipcMain } from "electron";
|
||||||
import { filterDicoms, uploadFilesInBatches } from "./util";
|
import { filterDicoms, uploadFilesInBatches } from "./util";
|
||||||
|
import { selectStructuredDicom } from "../../core/pacs";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export const registerDicomHandler = () => {
|
export const registerDicomHandler = () => {
|
||||||
ipcMain.handle("dicom:upload", async () => {
|
ipcMain.on("dicom:upload", async (event) => {
|
||||||
const dia = await dialog.showOpenDialog({ properties: ["openDirectory"] });
|
const dia = await dialog.showOpenDialog({ properties: ["openDirectory"] });
|
||||||
if (dia.canceled) return null;
|
if (dia.canceled) return null;
|
||||||
const dcmPaths = await filterDicoms(dia.filePaths[0]);
|
const dcmPaths = await filterDicoms(dia.filePaths[0]);
|
||||||
uploadFilesInBatches(dcmPaths, 5);
|
uploadFilesInBatches({
|
||||||
// return dia.filePaths[0];
|
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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import axios from "axios";
|
import { uploadDicomFile } from "../../core/pacs";
|
||||||
import FormData from "form-data";
|
|
||||||
import log from "electron-log";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查文件是否为 DICOM 文件(通过 Magic Number 判断)
|
* 检查文件是否为 DICOM 文件(通过 Magic Number 判断)
|
||||||
|
@ -52,49 +50,69 @@ export const filterDicoms = async (
|
||||||
return fileList;
|
return fileList;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadDicomFile = async (
|
interface UploadFilesInBatchesParams {
|
||||||
filePath: string,
|
filePaths: string[];
|
||||||
orthancUrl: string = "http://localhost:8042"
|
batchSize: number;
|
||||||
) => {
|
orthancUrl?: string;
|
||||||
try {
|
feedback?: (detail: {
|
||||||
const buffer = await fs.promises.readFile(filePath);
|
progress: number;
|
||||||
const fd = new FormData();
|
totalSuccess: number;
|
||||||
fd.append("files", buffer);
|
totalFailed: number;
|
||||||
const url = `${orthancUrl}/instances`;
|
}) => void;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadFilesInBatches = async (
|
/**
|
||||||
filePaths: string[],
|
* 批量上传文件,并提供进度更新。
|
||||||
batchSize: number,
|
* @param filePaths 文件路径数组。
|
||||||
orthancUrl: string = "http://localhost:8042"
|
* @param batchSize 每批上传的文件数量。
|
||||||
) => {
|
* @param orthancUrl Orthanc 服务器的 URL。
|
||||||
const results = [];
|
* @param feedback 每批处理完成后调用的回调函数。
|
||||||
const totalStartTime = Date.now(); // 记录总体开始时间
|
*/
|
||||||
for (let i = 0; i < filePaths.length; i += batchSize) {
|
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 batch = filePaths.slice(i, i + batchSize);
|
||||||
const batchResults = await Promise.allSettled(
|
const batchResults = await Promise.allSettled(
|
||||||
batch.map((filePath) => uploadDicomFile(filePath, orthancUrl))
|
batch.map((filePath) => uploadDicomFile(filePath))
|
||||||
);
|
);
|
||||||
// 提取状态为 'fulfilled' 的结果的 value
|
|
||||||
const fulfilledResults = batchResults
|
batchResults.forEach((result) => {
|
||||||
.filter((result) => result.status === "fulfilled")
|
if (result.status === "fulfilled" && result.value) {
|
||||||
.map((result) => (result as PromiseFulfilledResult<boolean>).value);
|
totalSuccess++;
|
||||||
results.push(...fulfilledResults);
|
} 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 totalEndTime = Date.now();
|
||||||
const totalFailed = results.length - totalSuccess;
|
console.log(
|
||||||
log.info(
|
|
||||||
`[上传序列] Success: ${totalSuccess}, Failed: ${totalFailed}, Total upload time: ${
|
`[上传序列] Success: ${totalSuccess}, Failed: ${totalFailed}, Total upload time: ${
|
||||||
totalEndTime - totalStartTime
|
totalEndTime - totalStartTime
|
||||||
} ms`
|
} ms`
|
||||||
);
|
);
|
||||||
return results;
|
|
||||||
|
return {
|
||||||
|
totalSuccess,
|
||||||
|
totalFailed,
|
||||||
|
totalTime: totalEndTime - totalStartTime,
|
||||||
|
progress: 100,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { registerDicomHandler } from "./dicom/handler";
|
import { registerDicomHandler } from "./dicom/handler";
|
||||||
|
import { registerCommonHandler } from "./common";
|
||||||
|
|
||||||
export const registerIpcMainHandlers = (mainWindow: Electron.BrowserWindow) => {
|
export const registerIpcMainHandlers = (mainWindow: Electron.BrowserWindow) => {
|
||||||
ipcMain.removeAllListeners();
|
ipcMain.removeAllListeners();
|
||||||
|
@ -9,5 +10,6 @@ export const registerIpcMainHandlers = (mainWindow: Electron.BrowserWindow) => {
|
||||||
*/
|
*/
|
||||||
ipcMain.on("ipc-loaded", () => mainWindow.show());
|
ipcMain.on("ipc-loaded", () => mainWindow.show());
|
||||||
|
|
||||||
|
registerCommonHandler();
|
||||||
registerDicomHandler();
|
registerDicomHandler();
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,12 +13,11 @@ import {
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
MenubarTrigger,
|
MenubarTrigger,
|
||||||
} from "@/components/ui/menubar";
|
} from "@/components/ui/menubar";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { inferDeviceType } from "./type";
|
import { inferDeviceType } from "./type";
|
||||||
import { inferDevices } from "./constant";
|
import { inferDevices } from "./constant";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { ToastAction } from "@/components/ui/toast";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
@ -29,7 +28,6 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { RocketIcon } from "@radix-ui/react-icons";
|
import { RocketIcon } from "@radix-ui/react-icons";
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
interface ScanProgress {
|
interface ScanProgress {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
@ -45,73 +43,29 @@ export const MenuBar = () => {
|
||||||
const [progress, setProgress] = useState<ScanProgress>(defaultProgress);
|
const [progress, setProgress] = useState<ScanProgress>(defaultProgress);
|
||||||
const [inferOption, setInferOption] =
|
const [inferOption, setInferOption] =
|
||||||
useState<inferDeviceType[]>(inferDevices);
|
useState<inferDeviceType[]>(inferDevices);
|
||||||
|
|
||||||
const [importDialogVisible, setImportDialogVisible] = useState(false);
|
const [importDialogVisible, setImportDialogVisible] = useState(false);
|
||||||
|
|
||||||
const [, setResult] = useState<[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScanProgress = (
|
const handleUploadFeedback = (
|
||||||
_event: Electron.IpcRendererEvent,
|
_event: Electron.IpcRendererEvent,
|
||||||
data: any
|
data: { progress: number; totalSuccess: number; totalFailed: number }
|
||||||
) => {
|
) => {
|
||||||
setProgress(data);
|
setProgress({ percentage: data.progress });
|
||||||
if (data.error) return;
|
|
||||||
};
|
};
|
||||||
window.ipcRenderer.on("scan-progress", handleScanProgress);
|
window.ipcRenderer.on("dicom:upload:detail", handleUploadFeedback);
|
||||||
return () => {
|
return () => {
|
||||||
window.ipcRenderer.off("scan-progress", handleScanProgress);
|
window.ipcRenderer.off("dicom:upload:detail", handleUploadFeedback);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScanFinished = (
|
const visible = ![0, 100].includes(progress.percentage);
|
||||||
_event: Electron.IpcRendererEvent,
|
setImportDialogVisible(visible);
|
||||||
data: any
|
}, [progress.percentage]);
|
||||||
) => {
|
|
||||||
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: <ToastAction altText="重试">重试</ToastAction>,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
variant: "default",
|
|
||||||
title: "完成",
|
|
||||||
description: `导入${structDicom.length}组序列数据,耗时:${(
|
|
||||||
scanDuration / 1000
|
|
||||||
).toFixed(2)} s`,
|
|
||||||
action: (
|
|
||||||
<ToastAction
|
|
||||||
altText="启动AI测量"
|
|
||||||
onClick={() => handleTasks(structDicom)}
|
|
||||||
>
|
|
||||||
分析
|
|
||||||
</ToastAction>
|
|
||||||
),
|
|
||||||
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 handleImportDicom = () => {
|
const handleImportDicom = () => {
|
||||||
navigate("datasource");
|
navigate("datasource");
|
||||||
// window.ipcRenderer.send("import-dicom-dialog-visible");
|
window.ipcRenderer.send("dicom:upload");
|
||||||
window.ipcRenderer.invoke("dicom:upload").then((value) => {
|
|
||||||
console.log(value);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -121,33 +75,13 @@ export const MenuBar = () => {
|
||||||
setInferOption((p) =>
|
setInferOption((p) =>
|
||||||
p.map((i) => ({ ...i, checked: i.key === item.key }))
|
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 handleOpenOutputPath = () => window.ipcRenderer.invoke("output:open");
|
||||||
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 } });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -158,7 +92,7 @@ export const MenuBar = () => {
|
||||||
<MenubarTrigger>文件</MenubarTrigger>
|
<MenubarTrigger>文件</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
<MenubarItem onSelect={handleImportDicom}>
|
<MenubarItem onSelect={handleImportDicom}>
|
||||||
批量导入Dicom<MenubarShortcut>⌘T</MenubarShortcut>
|
导入Dicom<MenubarShortcut>⌘T</MenubarShortcut>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem onClick={handleOpenOutputPath}>
|
<MenubarItem onClick={handleOpenOutputPath}>
|
||||||
打开输出文件夹<MenubarShortcut>⌘N</MenubarShortcut>
|
打开输出文件夹<MenubarShortcut>⌘N</MenubarShortcut>
|
||||||
|
|
|
@ -77,7 +77,7 @@ const Boot = () => {
|
||||||
<div className="p-4 h-full flex flex-col">
|
<div className="p-4 h-full flex flex-col">
|
||||||
<ResizablePanelGroup direction="horizontal" className="w-full h-full">
|
<ResizablePanelGroup direction="horizontal" className="w-full h-full">
|
||||||
<ResizablePanel defaultSize={38.2}>
|
<ResizablePanel defaultSize={38.2}>
|
||||||
<div className="flex flex-col h-full pt-4">
|
<div className="flex flex-col h-full">
|
||||||
<ScrollArea className="flex-grow w-full h-full px-4 pb-2">
|
<ScrollArea className="flex-grow w-full h-full px-4 pb-2">
|
||||||
<div className="w-full flex flex-col gap-y-2">
|
<div className="w-full flex flex-col gap-y-2">
|
||||||
{tasks.map((dicom: Series, index: number) => (
|
{tasks.map((dicom: Series, index: number) => (
|
||||||
|
|
|
@ -1,30 +1,27 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import { useEffect, useState } from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { Series, SeriesTable } from "./SeriesTable";
|
|
||||||
import { motion } from "framer-motion";
|
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 = () => {
|
export const Datasource = () => {
|
||||||
const [seriesData, setSeriesData] = useState<Series[]>([]);
|
const [patients, setPatients] = useState<
|
||||||
|
(PatientInfo & { active: boolean })[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
@ -33,7 +30,57 @@ export const Datasource = () => {
|
||||||
exit={{ y: 0, opacity: 0 }}
|
exit={{ y: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
>
|
>
|
||||||
<SeriesTable data={seriesData} />
|
<div className="p-4 h-full flex flex-col">
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="w-full h-full">
|
||||||
|
<ResizablePanel defaultSize={24}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<ScrollArea className="flex-grow w-full h-full px-4 pb-2">
|
||||||
|
<div className="w-full flex flex-col gap-y-2">
|
||||||
|
{patients.map((patient) => (
|
||||||
|
<Card
|
||||||
|
key={patient.ID}
|
||||||
|
onClick={() =>
|
||||||
|
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" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-semibold">
|
||||||
|
{patient.MainDicomTags.PatientName}
|
||||||
|
</div>
|
||||||
|
<span className="flex h-2 w-2 rounded-full bg-blue-600"></span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-xs text-foreground">
|
||||||
|
{patient.MainDicomTags.PatientSex}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium">
|
||||||
|
{patient.MainDicomTags.PatientBirthDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
上次更新: {patient.LastUpdate}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel defaultSize={24}>
|
||||||
|
123
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel defaultSize={52}>33</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
68
apps/desktop/src/pages/Datasource/type.ts
Normal file
68
apps/desktop/src/pages/Datasource/type.ts
Normal file
|
@ -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;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user