返回 AiToEarn
NotificationCenter.tsx
根目录 / project / aitoearn-web / src / components / ui / NotificationCenter.tsx
1 /**
2 * NotificationCenter - 全局通知中心组件
3 * 显示不同类型的通知(success/error/warning/info/loading)
4 * 支持鼠标悬停暂停自动关闭、手动关闭等功能
5 */
6 'use client'
7
8 import { AlertCircle, CheckCircle2, Info, Loader2, X, XCircle } from 'lucide-react'
9 import React, { useEffect, useRef, useState } from 'react'
10 import { cn } from '@/utils/className'
11
12 type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
13
14 interface NotificationDetail {
15 key?: string
16 id?: string
17 _uid?: string
18 content?: React.ReactNode
19 duration?: number
20 type?: NotificationType
21 }
22
23 interface NotificationItem {
24 uid: string
25 key?: string
26 content: React.ReactNode
27 duration: number
28 expiresAt: number
29 visible: boolean
30 type: NotificationType
31 isPaused: boolean
32 }
33
34 function genUid() {
35 return `${Date.now()}-${Math.floor(Math.random() * 100000)}`
36 }
37
38 // 通知图标配置
39 const notificationConfig: Record<
40 NotificationType,
41 {
42 icon: React.ReactNode
43 containerClass: string
44 iconClass: string
45 }
46 > = {
47 success: {
48 icon: <CheckCircle2 className="w-5 h-5" />,
49 containerClass: 'border-green-200 dark:border-green-800/50 bg-green-50/95 dark:bg-green-950/95',
50 iconClass: 'text-green-600 dark:text-green-400',
51 },
52 error: {
53 icon: <XCircle className="w-5 h-5" />,
54 containerClass: 'border-red-200 dark:border-red-800/50 bg-red-50/95 dark:bg-red-950/95',
55 iconClass: 'text-red-600 dark:text-red-400',
56 },
57 warning: {
58 icon: <AlertCircle className="w-5 h-5" />,
59 containerClass:
60 'border-yellow-200 dark:border-yellow-800/50 bg-yellow-50/95 dark:bg-yellow-950/95',
61 iconClass: 'text-yellow-600 dark:text-yellow-500',
62 },
63 info: {
64 icon: <Info className="w-5 h-5" />,
65 containerClass: 'border-blue-200 dark:border-blue-800/50 bg-blue-50/95 dark:bg-blue-950/95',
66 iconClass: 'text-blue-600 dark:text-blue-400',
67 },
68 loading: {
69 icon: <Loader2 className="w-5 h-5 animate-spin" />,
70 containerClass: 'border-border bg-card/95',
71 iconClass: 'text-primary',
72 },
73 }
74
75 export const NotificationCenter: React.FC = () => {
76 const [items, setItems] = useState<NotificationItem[]>([])
77 const timeoutsRef = useRef<Record<string, number>>({})
78 const remainingRef = useRef<Record<string, number>>({})
79 const animationStartRef = useRef<Record<string, number>>({})
80
81 useEffect(() => {
82 function onAdd(e: Event) {
83 const detail = (e as CustomEvent)?.detail as NotificationDetail | undefined
84 if (!detail)
85 return
86 const uid = detail._uid || genUid()
87 const key = detail.key || detail.id
88 const type = detail.type || 'info'
89 // loading 类型默认不自动关闭,其他类型默认 3 秒
90 const defaultDuration = type === 'loading' ? 0 : 3000
91 const duration
92 = typeof detail.duration === 'number' ? detail.duration * 1000 : defaultDuration
93 const expiresAt = duration > 0 ? Date.now() + duration : 0
94 const item: NotificationItem = {
95 uid,
96 key,
97 content: detail.content || '',
98 duration,
99 expiresAt,
100 visible: false,
101 type,
102 isPaused: false,
103 }
104
105 // 相同 key 去重:如果已有同 key 的通知,替换内容并重置计时器,不再新增
106 if (key) {
107 setItems((prev) => {
108 const existingIndex = prev.findIndex(it => it.key === key)
109 if (existingIndex !== -1) {
110 const existing = prev[existingIndex]
111 // 清除旧的定时器
112 if (timeoutsRef.current[existing.uid]) {
113 clearTimeout(timeoutsRef.current[existing.uid])
114 delete timeoutsRef.current[existing.uid]
115 }
116 delete remainingRef.current[existing.uid]
117 delete animationStartRef.current[existing.uid]
118
119 // 用新 uid 替换旧通知,保持位置不变
120 const updated = [...prev]
121 updated[existingIndex] = { ...item, visible: true }
122 return updated
123 }
124 return [item, ...prev]
125 })
126 }
127 else {
128 // 无 key 的通知正常添加到顶部
129 setItems(prev => [item, ...prev])
130 }
131
132 // 触发入场动画(仅对新增的通知)
133 setTimeout(() => {
134 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: true } : it)))
135 }, 10)
136
137 // 自动关闭(duration > 0,带 key 的通知也需要自动关闭)
138 if (duration > 0) {
139 // 记录动画开始时间
140 animationStartRef.current[uid] = Date.now()
141 remainingRef.current[uid] = duration
142
143 const timeoutId = window.setTimeout(() => {
144 // 开始退出动画
145 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it)))
146 // 动画结束后移除
147 const removeId = window.setTimeout(() => {
148 setItems(prev => prev.filter(it => it.uid !== uid))
149 delete timeoutsRef.current[uid]
150 delete remainingRef.current[uid]
151 delete animationStartRef.current[uid]
152 }, 300)
153 timeoutsRef.current[uid] = removeId
154 }, duration)
155 timeoutsRef.current[uid] = timeoutId
156 }
157 }
158
159 function onRemove(e: Event) {
160 const detail = (e as CustomEvent)?.detail as NotificationDetail | undefined
161 if (!detail)
162 return
163 const uid = detail._uid
164 const key = detail.key || detail.id
165 if (uid) {
166 if (timeoutsRef.current[uid]) {
167 clearTimeout(timeoutsRef.current[uid])
168 delete timeoutsRef.current[uid]
169 }
170 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it)))
171 setTimeout(() => {
172 setItems(prev => prev.filter(it => it.uid !== uid))
173 delete remainingRef.current[uid]
174 delete animationStartRef.current[uid]
175 }, 300)
176 }
177 if (key) {
178 setItems(prev => prev.map(it => (it.key === key ? { ...it, visible: false } : it)))
179 setTimeout(() => {
180 setItems(prev => prev.filter((it) => {
181 if (it.key === key) {
182 if (timeoutsRef.current[it.uid]) {
183 clearTimeout(timeoutsRef.current[it.uid])
184 delete timeoutsRef.current[it.uid]
185 }
186 delete remainingRef.current[it.uid]
187 delete animationStartRef.current[it.uid]
188 return false
189 }
190 return true
191 }))
192 }, 300)
193 }
194 }
195
196 window.addEventListener('aito:notification', onAdd as EventListener)
197 window.addEventListener('aito:notification-remove', onRemove as EventListener)
198 return () => {
199 window.removeEventListener('aito:notification', onAdd as EventListener)
200 window.removeEventListener('aito:notification-remove', onRemove as EventListener)
201 }
202 }, [])
203
204 // 关闭通知
205 const handleClose = (uid: string) => {
206 if (timeoutsRef.current[uid]) {
207 clearTimeout(timeoutsRef.current[uid])
208 delete timeoutsRef.current[uid]
209 }
210 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it)))
211 setTimeout(() => {
212 setItems(prev => prev.filter(it => it.uid !== uid))
213 delete remainingRef.current[uid]
214 delete animationStartRef.current[uid]
215 }, 300)
216 }
217
218 // 鼠标进入暂停自动关闭
219 const handleMouseEnter = (uid: string, item: NotificationItem) => {
220 // 清除当前定时器
221 if (timeoutsRef.current[uid]) {
222 clearTimeout(timeoutsRef.current[uid])
223 delete timeoutsRef.current[uid]
224 }
225 // 计算剩余时间
226 const startTime = animationStartRef.current[uid]
227 if (startTime && item.duration > 0) {
228 const elapsed = Date.now() - startTime
229 const remaining = Math.max(0, item.duration - elapsed)
230 remainingRef.current[uid] = remaining
231 }
232 // 标记为暂停,触发 CSS 动画暂停
233 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, isPaused: true } : it)))
234 }
235
236 // 鼠标离开恢复自动关闭
237 const handleMouseLeave = (uid: string, item: NotificationItem) => {
238 const remaining = remainingRef.current[uid]
239 // 如果没有剩余时间或 duration 为 0,不需要恢复
240 if (!remaining || remaining <= 0 || item.duration <= 0) {
241 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, isPaused: false } : it)))
242 return
243 }
244
245 // 更新动画开始时间,使进度条从暂停位置继续
246 animationStartRef.current[uid] = Date.now() - (item.duration - remaining)
247
248 // 恢复动画
249 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, isPaused: false } : it)))
250
251 // 重新设置定时器
252 const timeoutId = window.setTimeout(() => {
253 // 开始退出动画
254 setItems(prev => prev.map(it => (it.uid === uid ? { ...it, visible: false } : it)))
255 // 动画结束后移除
256 const removeId = window.setTimeout(() => {
257 setItems(prev => prev.filter(it => it.uid !== uid))
258 delete timeoutsRef.current[uid]
259 delete remainingRef.current[uid]
260 delete animationStartRef.current[uid]
261 }, 300)
262 timeoutsRef.current[uid] = removeId
263 }, remaining)
264 timeoutsRef.current[uid] = timeoutId
265 }
266
267 return (
268 <div className="fixed top-4 right-4 z-[500000] flex flex-col items-end gap-3 pointer-events-none">
269 {items.map((item) => {
270 const config = notificationConfig[item.type]
271
272 return (
273 <div
274 key={item.uid}
275 className={cn(
276 'w-[360px] max-w-[calc(100vw-2rem)] pointer-events-auto',
277 'border rounded-lg shadow-lg backdrop-blur-sm',
278 'transform transition-all duration-300 ease-out',
279 item.visible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4',
280 config.containerClass,
281 )}
282 role="status"
283 aria-live="polite"
284 onClick={(e) => {
285 e.stopPropagation()
286 e.preventDefault()
287 }}
288 onMouseDown={(e) => {
289 e.stopPropagation()
290 e.preventDefault()
291 }}
292 onPointerDownCapture={(e) => {
293 // 在捕获阶段阻止事件传播
294 // Radix UI 的 DismissableLayer 使用 onPointerDownCapture 检测外部点击
295 // 必须在捕获阶段阻止,否则冒泡阶段已经太晚
296 e.stopPropagation()
297 }}
298 onMouseEnter={() => handleMouseEnter(item.uid, item)}
299 onMouseLeave={() => handleMouseLeave(item.uid, item)}
300 >
301 <div className="flex items-start gap-3 p-4">
302 {/* 图标 */}
303 <div className={cn('flex-shrink-0 mt-0.5', config.iconClass)}>{config.icon}</div>
304
305 {/* 内容 */}
306 <div className="flex-1 min-w-0">
307 <div className="text-sm font-medium text-foreground break-words">
308 {item.content}
309 </div>
310 </div>
311
312 {/* 关闭按钮 */}
313 <button
314 type="button"
315 aria-label="Close notification"
316 onClick={() => handleClose(item.uid)}
317 className={cn(
318 'flex-shrink-0 p-1 rounded-md cursor-pointer',
319 'text-muted-foreground/60 hover:text-foreground',
320 'hover:bg-black/5 dark:hover:bg-white/10',
321 'transition-colors duration-150',
322 )}
323 >
324 <X className="w-4 h-4" />
325 </button>
326 </div>
327
328 {/* 进度条(可选,显示剩余时间) */}
329 {item.duration > 0 && item.visible && (
330 <div className="h-1 bg-black/5 dark:bg-white/5 rounded-b-lg overflow-hidden">
331 <div
332 className={cn(
333 'h-full',
334 item.type === 'success' && 'bg-green-500',
335 item.type === 'error' && 'bg-red-500',
336 item.type === 'warning' && 'bg-yellow-500',
337 item.type === 'info' && 'bg-blue-500',
338 item.type === 'loading' && 'bg-primary',
339 )}
340 style={{
341 width: '100%',
342 animation: `shrink ${item.duration}ms linear forwards`,
343 animationPlayState: item.isPaused ? 'paused' : 'running',
344 }}
345 />
346 </div>
347 )}
348 </div>
349 )
350 })}
351
352 {/* 进度条动画 CSS */}
353 <style jsx>
354 {`
355 @keyframes shrink {
356 from {
357 width: 100%;
358 }
359 to {
360 width: 0%;
361 }
362 }
363 `}
364 </style>
365 </div>
366 )
367 }
368
369 export default NotificationCenter
370
370 lines Plain Text