feat: 导入数据前端列表显示

This commit is contained in:
mozzie 2024-09-02 14:18:06 +08:00
parent d4a43e211a
commit 40717541d4
8 changed files with 232 additions and 227 deletions

View File

@ -1,24 +1,38 @@
import path from "node:path"; import path from "node:path";
import { JSONFilePreset } from "lowdb/node"; import { JSONFilePreset } from "lowdb/node";
import { app } from "electron"; import { app } from "electron";
import { Low } from "node_modules/lowdb/lib/core/Low";
import { StructuredMetadata } from "./dicom";
const initDb = async () => { interface ICreateDatabase {
// Read or create db.json name: string;
const defaultData = { posts: [] }; }
const db = await JSONFilePreset(
path.join(app.getPath("userData"), "db.json"),
defaultData
);
// Update db.json type SeriesTableType = StructuredMetadata & {
await db.update(({ posts }) => posts.push("hello world")); createTime?: number;
updateTime?: number;
// Alternatively you can call db.write() explicitely later
// to write to db.json
db.data.posts.push("hello world");
await db.write();
console.log(db);
}; };
initDb(); interface DbTable {
series: SeriesTableType[];
}
const defaultData: DbTable = { series: [] };
export let db: Low<{ series: SeriesTableType[] }>;
export const createDatabase = async (config: ICreateDatabase) => {
const { name } = config;
db = await JSONFilePreset(
path.join(app.getPath("userData"), name),
defaultData
);
};
// Update db.json
// await db.update(({ posts }) => posts.push("hello world"));
// Alternatively you can call db.write() explicitely later to write to db.json
// db.data.posts.push("hello world");
// await db.write();
// console.log(db);

View File

@ -1,7 +1,7 @@
import path from "path"; import path from "path";
import * as dicomParser from "dicom-parser"; import * as dicomParser from "dicom-parser";
import fs from "fs"; import fs from "fs";
import crypto from 'crypto'; import crypto from "crypto";
export interface StructuredData { export interface StructuredData {
[SeriesInstanceUID: string]: ExtractMetadata[]; [SeriesInstanceUID: string]: ExtractMetadata[];
@ -13,6 +13,8 @@ export interface ExtractMetadata {
SeriesInstanceUID?: string; SeriesInstanceUID?: string;
PatientName?: string; PatientName?: string;
pixelData?: Uint16Array; pixelData?: Uint16Array;
PatientSex: string;
PatientAge: string;
} }
/** /**
@ -77,6 +79,8 @@ export const parseDICOMFile = async (
const StudyInstanceUID = dataSet.string("x0020000d"); const StudyInstanceUID = dataSet.string("x0020000d");
const SeriesInstanceUID = dataSet.string("x0020000e"); const SeriesInstanceUID = dataSet.string("x0020000e");
const PatientName = dataSet.string("x00100030"); const PatientName = dataSet.string("x00100030");
const PatientSex = dataSet.string("x00100040") ?? "";
const PatientAge = dataSet.string("x00101010") ?? "";
const pixelDataElement = dataSet.elements.x7fe00010; const pixelDataElement = dataSet.elements.x7fe00010;
const pixelData = new Uint16Array( const pixelData = new Uint16Array(
dataSet.byteArray.buffer, dataSet.byteArray.buffer,
@ -89,6 +93,8 @@ export const parseDICOMFile = async (
StudyInstanceUID, StudyInstanceUID,
SeriesInstanceUID, SeriesInstanceUID,
PatientName, PatientName,
PatientSex,
PatientAge,
// pixelData, // pixelData,
}; };
} catch (error) { } catch (error) {
@ -129,34 +135,37 @@ export interface StructuredMetadata {
StudyInstanceUID?: string; StudyInstanceUID?: string;
SeriesInstanceUID?: string; SeriesInstanceUID?: string;
PatientName?: string; PatientName?: string;
fileHash: string[] fileHash: string[];
PatientSex: string;
PatientAge: string;
} }
export interface ScanProgress { export interface ScanProgress {
percentage: number percentage: number;
} }
// 计算文件的哈希值 // 计算文件的哈希值
async function calculateFileHash(filePath: string): Promise<string> { async function calculateFileHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256'); const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath); const stream = fs.createReadStream(filePath);
stream.on('error', reject); stream.on("error", reject);
stream.pipe(hash).on('finish', () => resolve(hash.digest('hex'))); stream.pipe(hash).on("finish", () => resolve(hash.digest("hex")));
}); });
} }
// 序列级别
export const keyProp = "SeriesInstanceUID";
export const structureMetadata = async ( export const structureMetadata = async (
data: ExtractMetadata[], data: ExtractMetadata[],
progressCallback?: (progress: ScanProgress) => void progressCallback?: (progress: ScanProgress) => void
): Promise<StructuredMetadata[]> => { ): Promise<StructuredMetadata[]> => {
const result: StructuredMetadata[] = []; const result: StructuredMetadata[] = [];
// 序列级别
const keyProp = 'SeriesInstanceUID'
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const item = data[i] const item = data[i];
const existItem = result.find((i) => i[keyProp] === item[keyProp]); const existItem = result.find((i) => i[keyProp] === item[keyProp]);
const hash = await calculateFileHash(item.filePath); const hash = await calculateFileHash(item.filePath);
@ -164,7 +173,7 @@ export const structureMetadata = async (
// 如果找到了相同的条目,合并 filePath // 如果找到了相同的条目,合并 filePath
if (!existItem.fileHash.includes(hash)) { if (!existItem.fileHash.includes(hash)) {
existItem.filePaths.push(item.filePath); existItem.filePaths.push(item.filePath);
existItem.fileHash.push(hash) existItem.fileHash.push(hash);
} }
} else { } else {
// 如果没有找到,创建一个新的条目 // 如果没有找到,创建一个新的条目
@ -174,10 +183,12 @@ export const structureMetadata = async (
StudyInstanceUID: item.StudyInstanceUID, StudyInstanceUID: item.StudyInstanceUID,
SeriesInstanceUID: item.SeriesInstanceUID, SeriesInstanceUID: item.SeriesInstanceUID,
PatientName: item.PatientName, PatientName: item.PatientName,
PatientAge: item.PatientAge,
PatientSex: item.PatientSex,
}); });
} }
const progress: ScanProgress = { const progress: ScanProgress = {
percentage: ((i + 1) / data.length) * 100 percentage: ((i + 1) / data.length) * 100,
}; };
progressCallback?.(progress); progressCallback?.(progress);
} }

View File

@ -2,11 +2,14 @@ import { dialog, ipcMain } from "electron";
import os from "os"; import os from "os";
import { import {
findDcmFiles, findDcmFiles,
keyProp,
processFilesInBatches, processFilesInBatches,
StructuredMetadata,
structureMetadata, structureMetadata,
} from "./core/dicom"; } from "./core/dicom";
import { EVENT_PARSE_DICOM } from "./ipcEvent"; import { EVENT_PARSE_DICOM } from "./ipcEvent";
import PythonManager from "./core/PythonManager"; import PythonManager from "./core/PythonManager";
import { db } from "./core/db";
/** /**
* *
@ -60,8 +63,25 @@ const registerIpcMainHandlers = (
const structDicom = await structureMetadata(items, (progress) => { const structDicom = await structureMetadata(items, (progress) => {
event.reply("scan-progress", progress); event.reply("scan-progress", progress);
}); });
// 存数据库
const changeTime = Date.now();
for (const item of structDicom) {
const existSeries = db.data.series.find(
(i) => i[keyProp] === item[keyProp]
);
existSeries
? Object.assign(existSeries, item, { updateTime: changeTime })
: db.data.series.push({ ...item, createTime: changeTime });
await db.write();
}
event.reply("scan-progress-done", structDicom); event.reply("scan-progress-done", structDicom);
}); });
ipcMain.on("db:series:select", async (event, data) => {
await db.read();
const seriesList = db.data.series;
event.reply("db:series:select:response", seriesList);
});
}; };
export default registerIpcMainHandlers; export default registerIpcMainHandlers;

View File

@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
import path from "node:path"; import path from "node:path";
import registerIpcMainHandlers from "./ipcMainHandlers"; import registerIpcMainHandlers from "./ipcMainHandlers";
import PythonManager from "./core/PythonManager"; import PythonManager from "./core/PythonManager";
import { createDatabase } from "./core/db";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -133,6 +134,8 @@ app.whenReady().then(() => {
createWindow(); createWindow();
createTray(); createTray();
registerGlobalShortcuts(); registerGlobalShortcuts();
createDatabase({ name: "cvpilot.json" });
console.log(path.join(app.getPath("userData")));
// 设置 Dock 图标 // 设置 Dock 图标
if (process.platform === "darwin") { if (process.platform === "darwin") {

View File

@ -1,11 +1,4 @@
"use client"; import { ChevronDownIcon } from "@radix-ui/react-icons";
import * as React from "react";
import {
CaretSortIcon,
ChevronDownIcon,
DotsHorizontalIcon,
} from "@radix-ui/react-icons";
import { import {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
@ -25,9 +18,6 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -39,48 +29,22 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useEffect, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
const data: Payment[] = [ export type Series = {
{
id: "m5gr84i9",
amount: 316,
status: "success",
email: "ken99@yahoo.com",
},
{
id: "3u1reuv4",
amount: 242,
status: "success",
email: "Abe45@gmail.com",
},
{
id: "derv1ws0",
amount: 837,
status: "processing",
email: "Monserrat44@gmail.com",
},
{
id: "5kma53ae",
amount: 874,
status: "success",
email: "Silas22@gmail.com",
},
{
id: "bhqecj4p",
amount: 721,
status: "failed",
email: "carmella@hotmail.com",
},
];
export type Payment = {
id: string; id: string;
amount: number; StudyInstanceUID?: string;
status: "pending" | "processing" | "success" | "failed"; SeriesInstanceUID?: string;
email: string; PatientName?: string;
PatientAge: string;
PatientSex: string;
createTime?: number;
updateTime?: number;
filePaths: string[];
}; };
export const columns: ColumnDef<Payment>[] = [ export const columns: ColumnDef<Series>[] = [
{ {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
@ -104,81 +68,82 @@ export const columns: ColumnDef<Payment>[] = [
enableHiding: false, enableHiding: false,
}, },
{ {
accessorKey: "status", accessorKey: "PatientName",
header: "Status", header: "姓名",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="capitalize">{row.getValue("status")}</div> <div className="capitalize">{row.getValue("PatientName")}</div>
), ),
}, },
{ {
accessorKey: "email", accessorKey: "PatientAge",
header: ({ column }) => { header: "年龄",
return ( cell: ({ row }) => (
<Button <div className="capitalize">{row.getValue("PatientAge")}</div>
variant="ghost" ),
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Email
<CaretSortIcon className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>,
}, },
{ {
accessorKey: "amount", accessorKey: "PatientSex",
header: () => <div className="text-right">Amount</div>, header: "性别",
cell: ({ row }) => { cell: ({ row }) => (
const amount = parseFloat(row.getValue("amount")); <div className="capitalize">{row.getValue("PatientSex")}</div>
),
// Format the amount as a dollar amount
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return <div className="text-right font-medium">{formatted}</div>;
},
}, },
{ {
id: "actions", accessorKey: "SeriesInstanceUID",
enableHiding: false, header: "序列UID",
cell: ({ row }) => { cell: ({ row }) => (
const payment = row.original; <div className="capitalize">{row.getValue("SeriesInstanceUID")}</div>
),
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<DotsHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(payment.id)}
>
Copy payment ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View customer</DropdownMenuItem>
<DropdownMenuItem>View payment details</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}, },
{
accessorKey: "filePaths",
header: "层数",
cell: ({ row }) => (
<div className="capitalize">
{(row.getValue("filePaths") as string[]).length}
</div>
),
},
{
accessorKey: "createTime",
header: "创建时间",
cell: ({ row }) => (
<div className="capitalize">
{new Date(row.getValue("createTime")).toLocaleString()}
</div>
),
},
{
accessorKey: "updateTime",
header: "修改时间",
cell: ({ row }) => (
<div className="capitalize">
{new Date(row.getValue("updateTime")).toLocaleString()}
</div>
),
}, },
]; ];
export function SeriesTable() { export function SeriesTable() {
const [sorting, setSorting] = React.useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [data, setData] = useState<Series[]>([]);
[] const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
const [columnVisibility, setColumnVisibility] = SeriesInstanceUID: false,
React.useState<VisibilityState>({}); updateTime: false,
const [rowSelection, setRowSelection] = React.useState({}); });
const [rowSelection, setRowSelection] = useState({});
useEffect(() => {
window.ipcRenderer.send("db:series:select");
}, []);
useEffect(() => {
window.ipcRenderer.on("db:series:select:response", (event, data) => {
console.log(data);
setData(data);
});
}, []);
const table = useReactTable({ const table = useReactTable({
data, data,
@ -200,20 +165,22 @@ export function SeriesTable() {
}); });
return ( return (
<div className="w-full"> <div className="w-full h-full flex flex-col">
<div className="flex items-center py-4"> <div className="flex-shrink-0 flex items-center p-4">
<Input <Input
placeholder="Filter emails..." placeholder="筛选姓名"
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""} value={
(table.getColumn("PatientName")?.getFilterValue() as string) ?? ""
}
onChange={(event) => onChange={(event) =>
table.getColumn("email")?.setFilterValue(event.target.value) table.getColumn("PatientName")?.setFilterValue(event.target.value)
} }
className="max-w-sm" className="max-w-sm"
/> />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto"> <Button variant="outline" size="sm" className="ml-auto">
Columns <ChevronDownIcon className="ml-2 h-4 w-4" /> <ChevronDownIcon className="ml-2 h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -230,13 +197,14 @@ export function SeriesTable() {
column.toggleVisibility(!!value) column.toggleVisibility(!!value)
} }
> >
{column.id} {column.columnDef.header}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
); );
})} })}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<ScrollArea className="h-full w-full flex-grow pl-4 pr-4">
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
@ -287,30 +255,7 @@ export function SeriesTable() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div className="flex items-center justify-end space-x-2 py-4"> </ScrollArea>
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div> </div>
); );
} }

View File

@ -11,6 +11,8 @@ import { ToastAction } from "@/components/ui/toast";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { RocketIcon } from "@radix-ui/react-icons"; import { RocketIcon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { SeriesTable } from "./SeriesTable";
import { motion } from "framer-motion";
interface ScanProgress { interface ScanProgress {
percentage: number; percentage: number;
@ -43,7 +45,7 @@ export const Datasource = () => {
setImportDialogVisible(true); setImportDialogVisible(true);
setStartTime(Date.now()); setStartTime(Date.now());
}); });
}, [progress?.percentage, toast]); }, []);
useEffect(() => { useEffect(() => {
const handleScanFinished = (event, data) => { const handleScanFinished = (event, data) => {
@ -65,6 +67,7 @@ export const Datasource = () => {
title: "完成", title: "完成",
description: `本次操作共导入${data.length}组序列数据,耗时:${timeDuration} s`, description: `本次操作共导入${data.length}组序列数据,耗时:${timeDuration} s`,
action: <ToastAction altText="启动AI测量">AI测量</ToastAction>, action: <ToastAction altText="启动AI测量">AI测量</ToastAction>,
duration: 30 * 1000,
}); });
} }
}; };
@ -86,7 +89,15 @@ export const Datasource = () => {
}, [importDialogVisible]); }, [importDialogVisible]);
return ( return (
<div className="p-4"> <motion.div
className="h-full"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
>
<SeriesTable />
{/* 导入数据dialog */}
<Dialog open={importDialogVisible} onOpenChange={setImportDialogVisible}> <Dialog open={importDialogVisible} onOpenChange={setImportDialogVisible}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
@ -106,6 +117,6 @@ export const Datasource = () => {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </motion.div>
); );
}; };

View File

@ -251,9 +251,9 @@ export function ModelTable() {
<div className="flex-shrink-0 flex items-center justify-between p-4"> <div className="flex-shrink-0 flex items-center justify-between p-4">
<Input <Input
placeholder="根据名称查询模型" placeholder="根据名称查询模型"
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""} value={(table.getColumn("modelname")?.getFilterValue() as string) ?? ""}
onChange={(event) => onChange={(event) =>
table.getColumn("email")?.setFilterValue(event.target.value) table.getColumn("modelname")?.setFilterValue(event.target.value)
} }
className="max-w-sm" className="max-w-sm"
/> />
@ -266,7 +266,7 @@ export function ModelTable() {
</Button> </Button>
</div> </div>
</div> </div>
<ScrollArea className="flex-grow pl-4 pr-4"> <ScrollArea className="h-full w-full flex-grow pl-4 pr-4">
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>

View File

@ -4,6 +4,7 @@ import { ModelTable } from "./ModelTable";
export const Models = () => { export const Models = () => {
return ( return (
<motion.div <motion.div
className="h-full"
initial={{ y: 10, opacity: 0 }} initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: 0, opacity: 0 }} exit={{ y: 0, opacity: 0 }}