เพิ่ม Toast Component

This commit is contained in:
Flook 2025-11-11 05:45:15 +07:00
parent 693ca097f4
commit 5670ef88e1
8 changed files with 217 additions and 11 deletions

14
web/package-lock.json generated
View File

@ -23,6 +23,7 @@
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.5",
"tailwindcss": "^4.1.16",
"uuid": "^13.0.0",
"yup": "^1.7.1"
},
"devDependencies": {
@ -3834,6 +3835,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": {
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",

View File

@ -25,6 +25,7 @@
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.5",
"tailwindcss": "^4.1.16",
"uuid": "^13.0.0",
"yup": "^1.7.1"
},
"devDependencies": {

View File

@ -2,12 +2,13 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import AuthRoutes from "./routes/AuthRoutes.jsx";
import PrivateRoutes from "./routes/PrivateRoutes.jsx";
import ToastNotification from './components/ToastNotification.jsx';
function App() {
return (
// Provider Redux QueryClient main.jsx
<BrowserRouter>
<ToastNotification />
<Routes>
{/* Private Routes: ตรวจสอบล็อกอินก่อนเข้า /dashboard/* */}
<Route path="/dashboard/*" element={<PrivateRoutes/>}/>

View File

@ -1,10 +1,12 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '../features/auth/authSlice';
import toastReducer from '../features/toast/toastSlice';
export const store = configureStore({
reducer: {
// กำหนด Reducer หลัก
auth: authReducer,
toast: toastReducer,
// [เพิ่ม Reducer อื่น ๆ ที่นี่]
},
// ปิด serializableCheck ใน Production เพื่อประสิทธิภาพ

View File

@ -1,7 +1,8 @@
import React from 'react';
import { useTestConnection } from '../../services/modelRegistryApi'; // Hook Test Connection
import { FaCheckCircle, FaExclamationTriangle, FaTrash, FaEdit } from 'react-icons/fa';
import { useDispatch } from 'react-redux';
import { addToast } from '../../features/toast/toastSlice';
// ----------------------------------------------------
// Helper Function: Badge
@ -17,7 +18,10 @@ const getStatusBadge = (status) => {
};
function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) {
// 1. Hook (Test Connection)
// useDispatch Hook
const dispatch = useDispatch();
// Hook (Test Connection)
// Hook Logic Action
const testConnectionMutation = useTestConnection();
@ -26,10 +30,18 @@ function ModelTable({ models, handleOpenEdit, handleDelete, deleteLoading }) {
testConnectionMutation.mutate(modelId, {
onSuccess: (result) => {
const status = result.status === 'success' ? 'SUCCESS' : 'FAILED';
alert(`Test Result: ${status}\nMessage: ${result.message || JSON.stringify(result.response_data)}`);
const message = `Test Result: ${status}. Service responded successfully.`;
dispatch(addToast({
message: message,
type: status === 'SUCCESS' ? 'success' : 'error'
}));
},
onError: (error) => {
alert(`Test Failed! Error: ${error.message || 'Check network or base URL'}`);
const errorMessage = error.message || 'Check network or base URL';
dispatch(addToast({
message: `Test Failed: ${errorMessage}`,
type: 'error'
}));
},
});
};

View File

@ -0,0 +1,137 @@
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { removeToast } from '../features/toast/toastSlice';
const TOAST_DURATION = 3000;
// ----------------------------------------------------
// Helper Functions
// ----------------------------------------------------
const getColors = (type) => {
switch (type) {
case 'success':
return 'alert-success';
case 'error':
return 'alert-error';
case 'warning':
return 'alert-warning';
case 'info':
return 'alert-info';
default:
return 'alert-info';
}
};
const getIcon = (type) => {
switch (type) {
case 'success':
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
case 'error':
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
case 'warning':
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667
1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464
0L3.332 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
);
case 'info':
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="stroke-current shrink-0 w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 16h-1v-4h-1m1-4h.01M21
12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
default:
return null;
}
};
// ----------------------------------------------------
// ToastNotification Component
// ----------------------------------------------------
export default function ToastNotification() {
const dispatch = useDispatch();
const queue = useSelector((state) => state.toast.queue);
useEffect(() => {
if (queue.length > 0) {
queue.forEach((toast) => {
if (!toast.timerId) {
setTimeout(() => {
dispatch(removeToast({ id: toast.id }));
}, TOAST_DURATION);
}
});
}
}, [queue, dispatch]);
return (
<div className="fixed top-0 right-0 z-[100] p-4 space-y-3 pointer-events-none">
{queue.map((toast) => (
<div
key={toast.id}
className={`alert ${getColors(toast.type)} shadow-xl transition-all duration-500 ease-out transform translate-x-0 opacity-100 max-w-sm`}
style={{ pointerEvents: 'auto' }}
onClick={() => dispatch(removeToast({ id: toast.id }))}
>
<div className="flex items-center space-x-2 text-white">
{getIcon(toast.type)}
<span>{toast.message}</span>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,31 @@
import { createSlice } from '@reduxjs/toolkit';
import { v4 as uuidv4 } from 'uuid';
const TOAST_DURATION = 3000; // 3 วินาที
const toastSlice = createSlice({
name: 'toast',
initialState: {
queue: [], // สถานะเก็บรายการ Toast ทั้งหมดที่ต้องแสดง
},
reducers: {
// Action สำหรับเพิ่ม Toast เข้าคิว
addToast: (state, action) => {
const newToast = {
id: uuidv4(),
message: action.payload.message,
type: action.payload.type || 'info',
};
state.queue.push(newToast);
},
// Action สำหรับลบ Toast ออกจากคิว
removeToast: (state, action) => {
state.queue = state.queue.filter(toast => toast.id !== action.payload.id);
}
},
});
export const { addToast, removeToast } = toastSlice.actions;
export default toastSlice.reducer;

View File

@ -1,5 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axiosClient from './axiosClient'; // Axios Client ที่มี JWT Interceptor
import { useDispatch } from 'react-redux';
import { addToast } from '../features/toast/toastSlice';
const STALE_TIME = 60 * 1000; // 1 นาที
@ -32,6 +34,7 @@ export const useModelList = () => {
*/
export const useCreateModel = () => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useMutation({
mutationFn: async (modelData) => {
// modelData: { name, model_version, base_url, inference_path, ... }
@ -39,11 +42,12 @@ export const useCreateModel = () => {
return response.data;
},
onSuccess: () => {
alert('Model ถูกลงทะเบียนสำเร็จแล้ว!');
dispatch(addToast({ message: 'Model ถูกลงทะเบียนสำเร็จแล้ว!', type: 'success' }));
queryClient.invalidateQueries({ queryKey: ['modelList'] });
},
onError: (error) => {
alert(`การลงทะเบียนล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`);
const errorMessage = error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล';
dispatch(addToast({ message: `การลงทะเบียนล้มเหลว: ${errorMessage}`, type: 'error' }));
}
});
};
@ -54,17 +58,19 @@ export const useCreateModel = () => {
*/
export const useDeleteModel = () => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useMutation({
mutationFn: async (modelId) => {
const response = await axiosClient.delete(`/api/v1/models/${modelId}/`);
return response.data;
},
onSuccess: () => {
alert('Model ถูกลบสำเร็จแล้ว!');
dispatch(addToast({ message: 'Model ถูกลบสำเร็จแล้ว!', type: 'success' }));
queryClient.invalidateQueries({ queryKey: ['modelList'] });
},
onError: (error) => {
alert(`การลบล้มเหลว: ${error.response?.data?.detail || 'คุณอาจไม่มีสิทธิ์'}`);
const errorMessage = error.response?.data?.detail || 'คุณอาจไม่มีสิทธิ์';
dispatch(addToast({ message: `การลบล้มเหลว: ${errorMessage}`, type: 'error' }));
}
});
};
@ -89,6 +95,7 @@ export const useTestConnection = () => {
*/
export const useUpdateModel = () => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useMutation({
// mutationFn รับ object ที่มี { id: number, data: object }
mutationFn: async ({ id, data }) => {
@ -97,12 +104,13 @@ export const useUpdateModel = () => {
return response.data;
},
onSuccess: () => {
alert('Model ถูกแก้ไขสำเร็จแล้ว!');
dispatch(addToast({ message: 'Model ถูกแก้ไขสำเร็จแล้ว!', type: 'success' }));
// Invalidate query list เพื่อบังคับให้ตารางอัปเดตข้อมูล
queryClient.invalidateQueries({ queryKey: ['modelList'] });
},
onError: (error) => {
alert(`การแก้ไขล้มเหลว: ${error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล'}`);
const errorMessage = error.response?.data?.detail || 'โปรดตรวจสอบข้อมูล';
dispatch(addToast({ message: `การแก้ไขล้มเหลว: ${errorMessage}`, type: 'error' }));
}
});
};