import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { clearNotifications, deleteNotification, listNotifications, markAllNotificationsRead, markNotificationRead, subscribeNotificationEvents, } from '../api/client'; import type { UserNotification } from '../types'; import { isRechargeNotification, requestBalanceRefresh } from '../utils/balanceRefresh'; const POPOVER_WIDTH = 340; const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open'; const HEADER_POPOVER_ID = 'notification-center'; function formatTime(timestamp: number) { const date = new Date(timestamp); if (Number.isNaN(date.getTime())) return ''; return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } type Filter = 'all' | 'unread' | 'read'; function parseNotificationLines(body: string) { return body .split('\n') .map((line) => line.trim()) .filter(Boolean); } export function NotificationCenter({ onOpenRecharge, }: { onOpenRecharge?: () => void; }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState('all'); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [popoverStyle, setPopoverStyle] = useState({}); const wrapRef = useRef(null); const unreadCount = useMemo( () => items.reduce((count, item) => count + (item.status === 'unread' ? 1 : 0), 0), [items], ); const load = async (nextFilter = filter) => { setLoading(true); setError(null); try { const notifications = await listNotifications(nextFilter, 50); setItems(notifications); } catch (err) { setError(err instanceof Error ? err.message : '读取通知失败'); } finally { setLoading(false); } }; useEffect(() => { const syncAll = () => { if (document.visibilityState === 'visible') { void load('all'); } }; void load('all'); const unsubscribe = subscribeNotificationEvents({ onNotification: (notification) => { if (isRechargeNotification(notification)) { requestBalanceRefresh(); } void load('all'); }, onSync: () => void load('all'), }); window.addEventListener('focus', syncAll); document.addEventListener('visibilitychange', syncAll); return () => { unsubscribe(); window.removeEventListener('focus', syncAll); document.removeEventListener('visibilitychange', syncAll); }; }, []); useEffect(() => { if (!open) return; const handleClick = (event: MouseEvent) => { if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) { setOpen(false); } }; document.addEventListener('click', handleClick); return () => document.removeEventListener('click', handleClick); }, [open]); useEffect(() => { const handleOtherPopoverOpen = (event: Event) => { const detail = (event as CustomEvent<{ id?: string }>).detail; if (detail?.id && detail.id !== HEADER_POPOVER_ID) { setOpen(false); } }; window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen); return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen); }, []); useEffect(() => { if (!open) return; const updatePosition = () => { const anchor = wrapRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); const width = Math.min(POPOVER_WIDTH, window.innerWidth - 24); let left = rect.left + rect.width / 2 - width / 2; left = Math.max(12, Math.min(left, window.innerWidth - width - 12)); setPopoverStyle({ position: 'fixed', top: rect.bottom + 10, left, width, }); }; updatePosition(); window.addEventListener('resize', updatePosition); window.addEventListener('scroll', updatePosition, true); return () => { window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); }; }, [open]); const visibleItems = filter === 'all' ? items : items.filter((item) => item.status === filter); return (
{open && (

通知中心

{unreadCount > 0 ? `${unreadCount} 条未读` : '通知已处理完'}

{(['all', 'unread', 'read'] as Filter[]).map((value) => ( ))}
{loading ?

正在读取通知…

: null} {error ?

{error}

: null} {!loading && !error && visibleItems.length === 0 ?

暂时没有通知

: null}
{visibleItems.map((item) => (
{item.title} {formatTime(item.createdAt)}
{item.notificationType === 'todo_digest' ? (
{parseNotificationLines(item.body).map((line, index) => (
{line}
))}
) : (

{item.body}

)}
{item.status === 'unread' ? ( ) : ( 已读 )} {item.notificationType === 'balance_low' && onOpenRecharge ? ( ) : null}
))}
)}
); }