From 202f18332f36e93e688d04367997776097f6a50c Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 17 Jul 2026 10:48:58 +0800 Subject: [PATCH] fix(notification): clarify query and progress feedback --- src/app/RefineContext.tsx | 4 +- .../olmap/BurstSimulation/SchemeQuery.tsx | 6 + .../ContaminantSimulation/SchemeQuery.tsx | 6 + .../olmap/DMALeakDetection/SchemeQuery.tsx | 15 ++ .../olmap/FlushingAnalysis/SchemeQuery.tsx | 6 + .../SchemeQuery.tsx | 6 + .../useAppNotificationProvider.test.tsx | 78 ++++++++++ .../useAppNotificationProvider.tsx | 137 ++++++++++++++++++ 8 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/providers/notification-provider/useAppNotificationProvider.test.tsx create mode 100644 src/providers/notification-provider/useAppNotificationProvider.tsx diff --git a/src/app/RefineContext.tsx b/src/app/RefineContext.tsx index 3b76364..4826171 100644 --- a/src/app/RefineContext.tsx +++ b/src/app/RefineContext.tsx @@ -4,7 +4,6 @@ import { Refine, type AuthProvider } from "@refinedev/core"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; import { RefineSnackbarProvider, - useNotificationProvider, } from "@refinedev/mui"; import { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; import { usePathname } from "next/navigation"; @@ -18,6 +17,7 @@ import { ProjectProvider } from "@/contexts/ProjectContext"; import { useAuthStore } from "@/store/authStore"; import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; +import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider"; import { LiaNetworkWiredSolid } from "react-icons/lia"; import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb"; @@ -165,7 +165,7 @@ const App = (props: React.PropsWithChildren) => { = ({ ? "没有找到任何方案" : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx index 92f83bd..9fb159d 100644 --- a/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx +++ b/src/components/olmap/ContaminantSimulation/SchemeQuery.tsx @@ -250,6 +250,12 @@ const SchemeQuery: React.FC = ({ ? "没有找到任何方案" : `${queryDate!.format("YYYY-MM-DD")} 没有找到相关方案`, }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx index bae1d39..3895d01 100644 --- a/src/components/olmap/DMALeakDetection/SchemeQuery.tsx +++ b/src/components/olmap/DMALeakDetection/SchemeQuery.tsx @@ -88,6 +88,21 @@ const SchemeQuery: React.FC = ({ }); const nextSchemes = response.data as LeakageSchemeRecord[]; setSchemes(nextSchemes); + if (nextSchemes.length === 0) { + open?.({ + type: "error", + message: "查询结果", + description: queryAll + ? "没有找到任何方案" + : `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`, + }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${nextSchemes.length} 条方案记录`, + }); + } } catch (error: any) { open?.({ type: "error", diff --git a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx index b6ae215..be64f71 100644 --- a/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx +++ b/src/components/olmap/FlushingAnalysis/SchemeQuery.tsx @@ -296,6 +296,12 @@ const SchemeQuery: React.FC = ({ message: "未找到相关方案", description: "请尝试更改查询条件", }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx index 4cee79d..4b9db6f 100644 --- a/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx +++ b/src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx @@ -214,6 +214,12 @@ const SchemeQuery: React.FC = ({ ? "没有找到任何方案" : `${queryDate?.format("YYYY-MM-DD")} 没有找到相关方案`, }); + } else { + open?.({ + type: "success", + message: "查询成功", + description: `共找到 ${filteredResults.length} 条方案记录`, + }); } } catch (error) { console.error("查询请求失败:", error); diff --git a/src/providers/notification-provider/useAppNotificationProvider.test.tsx b/src/providers/notification-provider/useAppNotificationProvider.test.tsx new file mode 100644 index 0000000..bdba34a --- /dev/null +++ b/src/providers/notification-provider/useAppNotificationProvider.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { useSnackbar } from "@refinedev/mui"; + +import { useAppNotificationProvider } from "./useAppNotificationProvider"; + +jest.mock("@refinedev/mui", () => ({ + useSnackbar: jest.fn(), +})); + +const mockedUseSnackbar = useSnackbar as jest.MockedFunction; + +const TestComponent = ({ + cancelMutation, +}: { + cancelMutation?: () => void; +}) => { + const notificationProvider = useAppNotificationProvider(); + + return ( + + ); +}; + +describe("useAppNotificationProvider", () => { + const enqueueSnackbar = jest.fn(); + const closeSnackbar = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockedUseSnackbar.mockReturnValue({ + enqueueSnackbar, + closeSnackbar, + } as unknown as ReturnType); + }); + + it("does not render an undo action for progress notifications without cancellation", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open progress" })); + + expect(enqueueSnackbar).toHaveBeenCalledTimes(1); + expect(enqueueSnackbar.mock.calls[0][1]).toMatchObject({ + key: "analysis-progress", + preventDuplicate: true, + autoHideDuration: 3000, + }); + expect(enqueueSnackbar.mock.calls[0][1]).not.toHaveProperty("action"); + }); + + it("keeps undo action for progress notifications with cancellation", async () => { + const cancelMutation = jest.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open progress" })); + + const options = enqueueSnackbar.mock.calls[0][1]; + expect(options.action).toEqual(expect.any(Function)); + + const undoAction = render(options.action("progress-key")); + fireEvent.click(within(undoAction.container).getByRole("button")); + + expect(cancelMutation).toHaveBeenCalledTimes(1); + expect(closeSnackbar).toHaveBeenCalledWith("progress-key"); + }); +}); diff --git a/src/providers/notification-provider/useAppNotificationProvider.tsx b/src/providers/notification-provider/useAppNotificationProvider.tsx new file mode 100644 index 0000000..7ac2f06 --- /dev/null +++ b/src/providers/notification-provider/useAppNotificationProvider.tsx @@ -0,0 +1,137 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import type { NotificationProvider } from "@refinedev/core"; +import { useSnackbar } from "@refinedev/mui"; +import UndoOutlined from "@mui/icons-material/UndoOutlined"; +import Box from "@mui/material/Box"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; + +type ProgressNotificationProps = { + undoableTimeout: number; + message: string; +}; + +const ProgressNotification = ({ + undoableTimeout, + message, +}: ProgressNotificationProps) => { + const [progress, setProgress] = useState(100); + const [timeCount, setTimeCount] = useState(undoableTimeout); + + useEffect(() => { + if (undoableTimeout <= 0 || timeCount <= 0) { + return; + } + + const progressStep = 100 / undoableTimeout; + const timer = window.setInterval(() => { + setTimeCount((previous) => Math.max(previous - 1, 0)); + setProgress((previous) => Math.max(previous - progressStep, 0)); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, [timeCount, undoableTimeout]); + + return ( + <> + + + + {timeCount} + + + + {message} + + + ); +}; + +export const useAppNotificationProvider = (): NotificationProvider => { + const { closeSnackbar, enqueueSnackbar } = useSnackbar(); + + return { + open: ({ + message, + type, + undoableTimeout, + key, + cancelMutation, + description, + }) => { + if (type === "progress") { + const options: NonNullable[1]> = { + preventDuplicate: true, + key, + autoHideDuration: (undoableTimeout ?? 0) * 1000, + }; + + if (cancelMutation) { + options.action = (snackbarKey) => ( + { + cancelMutation(); + closeSnackbar(snackbarKey); + }} + color="inherit" + > + + + ); + } + + enqueueSnackbar( + , + options, + ); + return; + } + + enqueueSnackbar( + + + {description} + + + {message} + + , + { + key, + variant: type, + }, + ); + }, + close: (key) => { + closeSnackbar(key); + }, + }; +};