Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { Bell, CheckCircle, AlertTriangle, Info, X } from 'lucide-react'; | |
| import { useSocket } from '@/hooks/useSocket'; | |
| import { useTranslation } from 'react-i18next'; | |
| interface ToastMessage { | |
| id: string; | |
| title: string; | |
| content: string; | |
| type: 'success' | 'warning' | 'info' | 'error'; | |
| } | |
| export default function NotificationToast() { | |
| const { t } = useTranslation(); | |
| const [notifications, setNotifications] = useState<ToastMessage[]>([]); | |
| const socket = useSocket(); | |
| const addToast = (title: string, content: string, type: ToastMessage['type'] = 'info') => { | |
| const id = Math.random().toString(36).substring(7); | |
| setNotifications(prev => [...prev, { id, title, content, type }]); | |
| setTimeout(() => { | |
| removeToast(id); | |
| }, 5000); | |
| }; | |
| const removeToast = (id: string) => { | |
| setNotifications(prev => prev.filter(n => n.id !== id)); | |
| }; | |
| useEffect(() => { | |
| if (!socket) return; | |
| // 1. 监听支付成功 | |
| socket.on('payment_success', (data) => { | |
| addToast(t('notifications.payment_success'), data.message, 'success'); | |
| }); | |
| // 2. 监听熔断告警 (仅管理员) | |
| socket.on('admin:circuit_breaker_change', (data) => { | |
| const type = data.state === 'OPEN' ? 'error' : 'success'; | |
| const title = data.state === 'OPEN' ? t('notifications.circuit_breaker_title') : t('notifications.service_recovered'); | |
| addToast(title, t('notifications.circuit_breaker_msg', { name: data.name, state: data.state }), type); | |
| }); | |
| return () => { | |
| socket.off('payment_success'); | |
| socket.off('admin:circuit_breaker_change'); | |
| }; | |
| }, [socket]); | |
| return ( | |
| <div className="fixed top-6 right-6 z-[100] flex flex-col gap-3 pointer-events-none"> | |
| <AnimatePresence> | |
| {notifications.map((n) => ( | |
| <motion.div | |
| key={n.id} | |
| initial={{ opacity: 0, x: 50, scale: 0.9 }} | |
| animate={{ opacity: 1, x: 0, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }} | |
| className={` | |
| pointer-events-auto w-80 p-4 rounded-xl shadow-2xl border flex gap-3 items-start | |
| ${n.type === 'success' ? 'bg-white border-green-100' : | |
| n.type === 'error' ? 'bg-red-50 border-red-100' : | |
| n.type === 'warning' ? 'bg-orange-50 border-orange-100' : 'bg-white border-zinc-100'} | |
| `} | |
| > | |
| <div className={` | |
| p-2 rounded-lg shrink-0 | |
| ${n.type === 'success' ? 'bg-green-50 text-green-600' : | |
| n.type === 'error' ? 'bg-red-100 text-red-600' : | |
| n.type === 'warning' ? 'bg-orange-100 text-orange-600' : 'bg-blue-50 text-blue-600'} | |
| `}> | |
| {n.type === 'success' ? <CheckCircle size={18} /> : | |
| n.type === 'error' ? <AlertTriangle size={18} /> : | |
| n.type === 'warning' ? <AlertTriangle size={18} /> : <Info size={18} />} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <h4 className="text-sm font-bold text-zinc-900 mb-0.5">{n.title}</h4> | |
| <p className="text-xs text-zinc-500 leading-relaxed">{n.content}</p> | |
| </div> | |
| <button | |
| onClick={() => removeToast(n.id)} | |
| className="text-zinc-300 hover:text-zinc-500 transition-colors" | |
| > | |
| <X size={14} /> | |
| </button> | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| } | |