feat: boot分析进度界面

This commit is contained in:
mozzie 2024-09-04 17:00:10 +08:00
parent fc26f12051
commit 3540413340
14 changed files with 787 additions and 220 deletions

View File

@ -15,6 +15,7 @@ export interface ExtractMetadata {
pixelData?: Uint16Array; pixelData?: Uint16Array;
PatientSex: string; PatientSex: string;
PatientAge: string; PatientAge: string;
AcquisitionDate: string;
} }
/** /**
@ -81,6 +82,7 @@ export const parseDICOMFile = async (
const PatientName = dataSet.string("x00100030"); const PatientName = dataSet.string("x00100030");
const PatientSex = dataSet.string("x00100040") ?? ""; const PatientSex = dataSet.string("x00100040") ?? "";
const PatientAge = dataSet.string("x00101010") ?? ""; const PatientAge = dataSet.string("x00101010") ?? "";
const AcquisitionDate = dataSet.string("x00080022") ?? "";
// const pixelDataElement = dataSet.elements.x7fe00010; // const pixelDataElement = dataSet.elements.x7fe00010;
// const pixelData = new Uint16Array( // const pixelData = new Uint16Array(
// dataSet.byteArray.buffer, // dataSet.byteArray.buffer,
@ -95,6 +97,7 @@ export const parseDICOMFile = async (
PatientName, PatientName,
PatientSex, PatientSex,
PatientAge, PatientAge,
AcquisitionDate,
// pixelData, // pixelData,
}; };
} catch (error) { } catch (error) {
@ -130,15 +133,10 @@ export const processFilesInBatches = async (
return results; return results;
}; };
export interface StructuredMetadata { export type StructuredMetadata = Partial<ExtractMetadata> & {
filePaths: string[]; filePaths: string[];
StudyInstanceUID?: string;
SeriesInstanceUID?: string;
PatientName?: string;
fileHash: string[]; fileHash: string[];
PatientSex: string; };
PatientAge: string;
}
export interface ScanProgress { export interface ScanProgress {
percentage: number; percentage: number;
@ -184,6 +182,7 @@ export const structureMetadata = async (
SeriesInstanceUID: item.SeriesInstanceUID, SeriesInstanceUID: item.SeriesInstanceUID,
PatientName: item.PatientName, PatientName: item.PatientName,
PatientAge: item.PatientAge, PatientAge: item.PatientAge,
AcquisitionDate: item.AcquisitionDate,
PatientSex: item.PatientSex, PatientSex: item.PatientSex,
}); });
} }

View File

@ -63,6 +63,7 @@ function createWindow() {
if (VITE_DEV_SERVER_URL) { if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL); win.loadURL(VITE_DEV_SERVER_URL);
registerIpcMainHandlers(win);
} else { } else {
win.loadFile(path.join(RENDERER_DIST, "index.html")).then(() => { win.loadFile(path.join(RENDERER_DIST, "index.html")).then(() => {
if (process.argv.length >= 2) { if (process.argv.length >= 2) {
@ -71,11 +72,9 @@ function createWindow() {
registerIpcMainHandlers(win); registerIpcMainHandlers(win);
} }
}); });
} }
// pythonManager = new PythonManager(win, "http://127.0.0.1:15001", 3000); // pythonManager = new PythonManager(win, "http://127.0.0.1:15001", 3000);
registerIpcMainHandlers(win);
} }
function createTray() { function createTray() {

View File

@ -36,14 +36,17 @@
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"custom-electron-titlebar": "^4.2.8", "custom-electron-titlebar": "^4.2.8",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dayjs": "1.11.13",
"dexie": "^4.0.8", "dexie": "^4.0.8",
"dicom-parser": "1.8.21", "dicom-parser": "1.8.21",
"dockview": "^1.15.2", "dockview": "^1.15.2",
"electron-store": "^10.0.0", "electron-store": "^10.0.0",
"embla-carousel-react": "^8.2.0",
"flexlayout-react": "^0.7.15", "flexlayout-react": "^0.7.15",
"framer-motion": "^11.3.24", "framer-motion": "^11.3.24",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"lucide-react": "^0.408.0", "lucide-react": "^0.408.0",
"node-machine-id": "1.1.12",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"onnxruntime-node": "^1.18.0", "onnxruntime-node": "^1.18.0",
"openvino-node": "2024.3.0", "openvino-node": "2024.3.0",
@ -58,12 +61,11 @@
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"tailwind-merge": "^2.4.0", "tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "3.23.8", "zod": "3.23.8"
"node-machine-id": "1.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.5.2",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@types/node": "22.5.2",
"@types/react": "^18.2.64", "@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
"@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/eslint-plugin": "^7.1.1",

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -5,25 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-9 rounded-md px-3", sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-8", lg: "h-10 rounded-md px-8",
icon: "h-10 w-10", icon: "h-9 w-9",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeftIcon className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRightIcon className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -1,6 +1,32 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import dayjs from "dayjs";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export const timeAgo = (inputDate: string | Date): string => {
const now = dayjs();
const date = dayjs(inputDate);
const diffMinutes = now.diff(date, "minute");
const diffHours = now.diff(date, "hour");
const diffDays = now.diff(date, "day");
if (diffMinutes < 60) {
return `${diffMinutes} 分钟前`;
} else if (diffHours < 24) {
return `${diffHours} 小时前`;
} else if (diffDays < 7) {
return `${diffDays} 天前`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} 周前`;
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `${months} 个月前`;
} else {
const years = Math.floor(diffDays / 365);
return `${years} 年前`;
}
};

View File

@ -2,12 +2,141 @@ import { Button } from "@/components/ui/button";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IoCheckmarkCircleSharp } from "react-icons/io5"; import { IoCheckmarkCircleSharp } from "react-icons/io5";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { ToastAction } from "@/components/ui/toast";
import { useToast } from "@/components/ui/use-toast";
import { RocketIcon } from "@radix-ui/react-icons";
import { useLocation } from "react-router-dom";
import { Terminal } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Carousel,
CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { Series } from "../Datasource/SeriesTable";
interface ScanProgress {
percentage: number;
}
const defaultProgress = {
percentage: 0,
};
const Boot = () => { const Boot = () => {
const [flaskWaiting, setFlaskWaiting] = useState(false); const [flaskWaiting, setFlaskWaiting] = useState(false);
const [flaskRunning, setflaskRunning] = useState(false); const [flaskRunning, setflaskRunning] = useState(false);
const [flaskInfo, setFlaskInfo] = useState(""); const [flaskInfo, setFlaskInfo] = useState("");
const [openFolder, setOpenFolder] = useState(""); const location = useLocation();
const selectDicoms =
location.state?.selectDicoms ??
JSON.parse(localStorage.getItem("selectDicoms") ?? "{}");
/**
* windows系统右键启动应用菜单的入口路径
*/
const [bootDirectoryPath, setBootDirectoryPath] = useState("");
const [progress, setProgress] = useState<ScanProgress>(defaultProgress);
const { toast } = useToast();
const [, setResult] = useState<[]>([]);
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const [importDialogVisible, setImportDialogVisible] = useState(false);
useEffect(() => {
if (!api) return;
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
useEffect(() => {
const handleScanProgress = (
_event: Electron.IpcRendererEvent,
data: any
) => {
setProgress(data);
if (data.error) return;
};
window.ipcRenderer.on("scan-progress", handleScanProgress);
return () => {
window.ipcRenderer.off("scan-progress", handleScanProgress);
};
}, []);
useEffect(() => {
window.ipcRenderer.once("scan-start", () => {
setImportDialogVisible(true);
});
}, []);
useEffect(() => {
const handleScanFinished = (
_event: Electron.IpcRendererEvent,
data: any
) => {
const { scanDuration, structDicom } = data;
console.log(structDicom);
setResult(structDicom);
setImportDialogVisible(false);
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测量">AI测量</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]);
useEffect(() => {
if (!importDialogVisible) {
setProgress(defaultProgress);
}
}, [importDialogVisible]);
const handleBootPythonServer = () => { const handleBootPythonServer = () => {
if (!flaskRunning) setFlaskWaiting(true); if (!flaskRunning) setFlaskWaiting(true);
@ -35,8 +164,8 @@ const Boot = () => {
useEffect(() => { useEffect(() => {
window.ipcRenderer.on("open-folder", (_, data) => { window.ipcRenderer.on("open-folder", (_, data) => {
console.log(data); console.log("右键菜单拉起启动文件夹路径", data);
setOpenFolder(data); setBootDirectoryPath(data);
}); });
}, []); }, []);
@ -49,8 +178,59 @@ const Boot = () => {
className="h-full" className="h-full"
> >
<div className="p-4 h-full flex flex-col"> <div className="p-4 h-full flex flex-col">
{/* */}
<div className="flex flex-col items-center">
<div className="w-2/3 mt-8">
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>dicom</AlertDescription>
</Alert>
</div>
<div className="w-2/3 mt-4">
<Button size="sm" className="rounded-full mr-2">
DICOM
</Button>
<Button variant="outline" size="sm" className="rounded-full">
MRI
</Button>
</div>
<div className="w-2/3 mt-8 flex flex-col items-center justify-center">
<Carousel setApi={setApi} className="w-full max-w-xs">
<CarouselContent>
{selectDicoms.map((item: Series, index: number) => (
<CarouselItem key={index}>
<Card>
<CardHeader>
<CardTitle>{item.PatientName}</CardTitle>
<CardDescription>
{item.PatientAge}, {item.PatientSex},{" "}
{item.filePaths.length}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center ">
<span className="text-4xl font-semibold">
{index + 1}
</span>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Cancel</Button>
<Button></Button>
</CardFooter>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
<div className="py-2 text-center text-sm text-muted-foreground">
{current} / {count}
</div>
</div>
<div className="w-2/3 mt-4"></div>
</div>
{/* card */} {/* card */}
<div>{openFolder}</div>
<main className="flex-1 flex flex-col"> <main className="flex-1 flex flex-col">
<div className="mt-4 flex-1"> <div className="mt-4 flex-1">
<pre className="text-xs">{flaskInfo}</pre> <pre className="text-xs">{flaskInfo}</pre>
@ -71,6 +251,26 @@ const Boot = () => {
</footer> </footer>
</main> </main>
</div> </div>
{/* 导入数据dialog */}
<Dialog open={importDialogVisible} onOpenChange={setImportDialogVisible}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<Alert>
<RocketIcon className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
<Progress value={progress?.percentage} />
</AlertDescription>
</Alert>
</div>
</DialogContent>
</Dialog>
</motion.div> </motion.div>
); );
}; };

View File

@ -1,4 +1,5 @@
import { ChevronDownIcon } from "@radix-ui/react-icons"; import { ChevronDownIcon } from "@radix-ui/react-icons";
import dayjs from "dayjs";
import { import {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
@ -7,11 +8,17 @@ import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import {
ArrowUpDown,
Brain,
DeleteIcon,
FileDownIcon,
FilterIcon,
MoreHorizontal,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@ -19,9 +26,11 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -35,6 +44,9 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useState } from "react"; import { useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { timeAgo } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { useNavigate } from "react-router-dom";
export type Series = { export type Series = {
id: string; id: string;
@ -56,6 +68,7 @@ const columnsAlias: { [K in keyof Partial<Series> as string]: string } = {
filePaths: "层数", filePaths: "层数",
createTime: "创建时间", createTime: "创建时间",
updateTime: "修改时间", updateTime: "修改时间",
AcquisitionDate: "采集日期",
}; };
export const columns: ColumnDef<Series>[] = [ export const columns: ColumnDef<Series>[] = [
@ -123,23 +136,57 @@ export const columns: ColumnDef<Series>[] = [
</div> </div>
), ),
}, },
{
id: "AcquisitionDate",
accessorKey: "AcquisitionDate",
header: ({ column }) => {
return (
<div className="flex items-center">
{columnsAlias["AcquisitionDate"]}
<Button
variant="ghost"
className="h-7 w-7 ml-2"
size="icon"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<ArrowUpDown className="h-3.5 w-3.5" />
</Button>
</div>
);
},
cell: ({ row }) =>
row.getValue("AcquisitionDate") && (
<div className="capitalize">
<span>{row.getValue("AcquisitionDate")}</span>
</div>
),
},
{ {
id: "createTime", id: "createTime",
accessorKey: "createTime", accessorKey: "createTime",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center">
{columnsAlias["createTime"]}
<Button <Button
variant="ghost" variant="ghost"
className="h-7 w-7 ml-2"
size="icon"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
{columnsAlias["createTime"]} <ArrowUpDown className="h-3.5 w-3.5" />
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
</div>
); );
}, },
cell: ({ row }) => ( cell: ({ row }) =>
row.getValue("createTime") && (
<div className="capitalize"> <div className="capitalize">
{new Date(row.getValue("createTime")).toLocaleString()} <span>{dayjs(row.getValue("createTime")).format("YYYYMMDD")}</span>
<Badge className="ml-2 text-xs font-normal">
{timeAgo(row.getValue("createTime"))}
</Badge>
</div> </div>
), ),
}, },
@ -148,18 +195,26 @@ export const columns: ColumnDef<Series>[] = [
accessorKey: "updateTime", accessorKey: "updateTime",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<div className="flex items-center">
{columnsAlias["updateTime"]}
<Button <Button
variant="ghost" variant="ghost"
className="h-7 w-7 ml-2"
size="icon"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
{columnsAlias["updateTime"]} <ArrowUpDown className="h-3.5 w-3.5" />
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
</div>
); );
}, },
cell: ({ row }) => ( cell: ({ row }) =>
row.getValue("updateTime") && (
<div className="capitalize"> <div className="capitalize">
{new Date(row.getValue("updateTime")).toLocaleString()} <span>{dayjs(row.getValue("updateTime")).format("YYYYMMDD")}</span>
<Badge className="ml-2 text-xs font-normal">
{timeAgo(row.getValue("updateTime"))}
</Badge>
</div> </div>
), ),
}, },
@ -204,8 +259,11 @@ export function SeriesTable(props: SeriesTableProps) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
SeriesInstanceUID: false, SeriesInstanceUID: false,
updateTime: false,
createTime: false,
}); });
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
const navigate = useNavigate();
const table = useReactTable({ const table = useReactTable({
data: props.data, data: props.data,
@ -213,7 +271,6 @@ export function SeriesTable(props: SeriesTableProps) {
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
@ -226,9 +283,22 @@ export function SeriesTable(props: SeriesTableProps) {
}, },
}); });
const handleSelectExport2Excel = () => {
console.log("导出到excel");
};
const handleSelectAI = () => {
const selectDicoms = table
.getSelectedRowModel()
.rows.map((r) => r.original);
localStorage.setItem("selectDicoms", JSON.stringify(selectDicoms));
navigate("/", { state: { selectDicoms } });
};
return ( return (
<div className="w-full h-full flex flex-col"> <div className="w-full h-full flex flex-col">
<div className="flex-shrink-0 flex items-center p-4"> <div className="flex-shrink-0 flex items-center p-4">
<div className="flex items-center">
<Input <Input
placeholder="筛选姓名" placeholder="筛选姓名"
value={ value={
@ -241,8 +311,8 @@ export function SeriesTable(props: SeriesTableProps) {
/> />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto"> <Button variant="ghost" size="icon" className="ml-2">
<ChevronDownIcon className="ml-2 h-4 w-4" /> <FilterIcon className="h-3.5 w-3.5 " />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -266,6 +336,45 @@ export function SeriesTable(props: SeriesTableProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="flex-grow justify-end inline-flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="mr-4">
<ChevronDownIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={handleSelectExport2Excel}>
<FileDownIcon className="mr-2 h-4 w-4" />
<span>excel</span>
<DropdownMenuShortcut>+T</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleSelectAI}>
<Brain className="mr-2 h-4 w-4" />
<span></span>
<DropdownMenuShortcut>+A</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<DeleteIcon className="mr-2 h-4 w-4" />
<span></span>
<DropdownMenuShortcut>+D</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="space-x-2">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length}
</div>
</div>
</div>
)}
</div>
<ScrollArea className="h-full w-full 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>

View File

@ -1,107 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { useCallback, useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { ToastAction } from "@/components/ui/toast";
import { useToast } from "@/components/ui/use-toast";
import { RocketIcon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
import { Series, SeriesTable } from "./SeriesTable"; import { Series, SeriesTable } from "./SeriesTable";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
interface ScanProgress {
percentage: number;
}
const defaultProgress = {
percentage: 0,
};
export const Datasource = () => { export const Datasource = () => {
const [progress, setProgress] = useState<ScanProgress>(defaultProgress);
const { toast } = useToast();
const [, setResult] = useState<[]>([]);
const [seriesData, setSeriesData] = useState<Series[]>([]); const [seriesData, setSeriesData] = useState<Series[]>([]);
const [importDialogVisible, setImportDialogVisible] = useState(false);
useEffect(() => {
const handleScanProgress = (
_event: Electron.IpcRendererEvent,
data: any
) => {
setProgress(data);
if (data.error) return;
};
window.ipcRenderer.on("scan-progress", handleScanProgress);
return () => {
window.ipcRenderer.off("scan-progress", handleScanProgress);
};
}, []);
useEffect(() => {
window.ipcRenderer.once("scan-start", () => {
setImportDialogVisible(true);
});
}, []);
useEffect(() => {
const handleScanFinished = (
_event: Electron.IpcRendererEvent,
data: any
) => {
const { scanDuration, structDicom } = data;
setResult(structDicom);
setImportDialogVisible(false);
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测量">AI测量</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]);
useEffect(() => {
if (!importDialogVisible) {
setProgress(defaultProgress);
}
}, [importDialogVisible]);
useEffect(() => { useEffect(() => {
window.ipcRenderer.send("db:series:select"); window.ipcRenderer.send("db:series:select");
}, []); }, []);
useEffect(() => { const fetchSeriesData = useCallback(
window.ipcRenderer.on("db:series:select:response", (_event, data) => { (_event: Electron.IpcRendererEvent, data: any) => {
console.log(data); console.log(data);
setSeriesData(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
@ -112,26 +34,6 @@ export const Datasource = () => {
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
> >
<SeriesTable data={seriesData} /> <SeriesTable data={seriesData} />
{/* 导入数据dialog */}
<Dialog open={importDialogVisible} onOpenChange={setImportDialogVisible}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2">
<Alert>
<RocketIcon className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
<Progress value={progress?.percentage} />
</AlertDescription>
</Alert>
</div>
</DialogContent>
</Dialog>
</motion.div> </motion.div>
); );
}; };

View File

@ -17,7 +17,7 @@ type MenuItem = {
}; };
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ to: "/", name: "一键启动", icon: <IoPlayOutline size={24} /> }, { to: "/", name: "自动分析", icon: <IoPlayOutline size={24} /> },
{ to: "/datasource", name: "数据列表", icon: <IoListOutline size={24} /> }, { to: "/datasource", name: "数据列表", icon: <IoListOutline size={24} /> },
{ to: "/models", name: "模型管理", icon: <IoCubeOutline size={24} /> }, { to: "/models", name: "模型管理", icon: <IoCubeOutline size={24} /> },
{ to: "/tools", name: "小工具", icon: <IoHammerOutline size={24} /> }, { to: "/tools", name: "小工具", icon: <IoHammerOutline size={24} /> },

View File

@ -19,7 +19,7 @@ export const MenuBar = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleImportDicom = () => { const handleImportDicom = () => {
navigate("datasource"); navigate("/");
window.ipcRenderer.send("import-dicom-dialog-visible"); window.ipcRenderer.send("import-dicom-dialog-visible");
}; };

View File

@ -7,7 +7,6 @@ import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
@ -232,7 +231,6 @@ export function ModelTable() {
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
@ -258,7 +256,13 @@ export function ModelTable() {
} }
className="max-w-sm" className="max-w-sm"
/> />
<div className="flex items-center space-x-2"> <div className="flex-grow justify-end inline-flex items-center">
<div className="text-right space-x-2">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length}
</div>
</div>
<div className="flex items-center space-x-2 ml-4">
<Button className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2"> <Button className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
<IoRefreshCircleOutline className="mr-2 h-4 w-4" /> <IoRefreshCircleOutline className="mr-2 h-4 w-4" />
</Button> </Button>
@ -267,6 +271,7 @@ export function ModelTable() {
</Button> </Button>
</div> </div>
</div> </div>
</div>
<ScrollArea className="h-full w-full 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>
@ -318,11 +323,6 @@ export function ModelTable() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div className="text-right space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length}
</div>
</div>
</ScrollArea> </ScrollArea>
</div> </div>
); );

View File

@ -85,6 +85,9 @@ importers:
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
dayjs:
specifier: 1.11.13
version: 1.11.13
dexie: dexie:
specifier: ^4.0.8 specifier: ^4.0.8
version: 4.0.8 version: 4.0.8
@ -97,6 +100,9 @@ importers:
electron-store: electron-store:
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
embla-carousel-react:
specifier: ^8.2.0
version: 8.2.0(react@18.3.1)
flexlayout-react: flexlayout-react:
specifier: ^0.7.15 specifier: ^0.7.15
version: 0.7.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 0.7.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -2191,6 +2197,19 @@ packages:
engines: {node: '>= 12.20.55'} engines: {node: '>= 12.20.55'}
hasBin: true hasBin: true
embla-carousel-react@8.2.0:
resolution: {integrity: sha512-dWqbmaEBQjeAcy/EKrcAX37beVr0ubXuHPuLZkx27z58V1FIvRbbMb4/c3cLZx0PAv/ofngX2QFrwUB+62SPnw==}
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0
embla-carousel-reactive-utils@8.2.0:
resolution: {integrity: sha512-ZdaPNgMydkPBiDRUv+wRIz3hpZJ3LKrTyz+XWi286qlwPyZFJDjbzPBiXnC3czF9N/nsabSc7LTRvGauUzwKEg==}
peerDependencies:
embla-carousel: 8.2.0
embla-carousel@8.2.0:
resolution: {integrity: sha512-rf2GIX8rab9E6ZZN0Uhz05746qu2KrDje9IfFyHzjwxLwhvGjUt6y9+uaY1Sf+B0OPSa3sgas7BE2hWZCtopTA==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@ -5730,7 +5749,7 @@ snapshots:
app-builder-bin@4.0.0: {} app-builder-bin@4.0.0: {}
app-builder-lib@24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)): app-builder-lib@24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies: dependencies:
'@develar/schema-utils': 2.6.5 '@develar/schema-utils': 2.6.5
'@electron/notarize': 2.2.1 '@electron/notarize': 2.2.1
@ -5744,7 +5763,7 @@ snapshots:
builder-util-runtime: 9.2.4 builder-util-runtime: 9.2.4
chromium-pickle-js: 0.2.0 chromium-pickle-js: 0.2.0
debug: 4.3.6 debug: 4.3.6
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
ejs: 3.1.10 ejs: 3.1.10
electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3) electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3)
electron-publish: 24.13.1 electron-publish: 24.13.1
@ -6222,9 +6241,9 @@ snapshots:
dlv@1.1.3: {} dlv@1.1.3: {}
dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3): dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies: dependencies:
app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
builder-util: 24.13.1 builder-util: 24.13.1
builder-util-runtime: 9.2.4 builder-util-runtime: 9.2.4
fs-extra: 10.1.0 fs-extra: 10.1.0
@ -6292,7 +6311,7 @@ snapshots:
electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3): electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3):
dependencies: dependencies:
app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
archiver: 5.3.2 archiver: 5.3.2
builder-util: 24.13.1 builder-util: 24.13.1
fs-extra: 10.1.0 fs-extra: 10.1.0
@ -6302,11 +6321,11 @@ snapshots:
electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)): electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
dependencies: dependencies:
app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) app-builder-lib: 24.13.3(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
builder-util: 24.13.1 builder-util: 24.13.1
builder-util-runtime: 9.2.4 builder-util-runtime: 9.2.4
chalk: 4.1.2 chalk: 4.1.2
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
fs-extra: 10.1.0 fs-extra: 10.1.0
is-ci: 3.0.1 is-ci: 3.0.1
lazy-val: 1.0.5 lazy-val: 1.0.5
@ -6344,6 +6363,18 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
embla-carousel-react@8.2.0(react@18.3.1):
dependencies:
embla-carousel: 8.2.0
embla-carousel-reactive-utils: 8.2.0(embla-carousel@8.2.0)
react: 18.3.1
embla-carousel-reactive-utils@8.2.0(embla-carousel@8.2.0):
dependencies:
embla-carousel: 8.2.0
embla-carousel@8.2.0: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}