VetNet-LCPS / vetcopilot-app /src /components /NotificationBell.tsx
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
8.6 kB
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from '../i18n'
import {
getInAppNotifications,
getInAppUnreadCount,
markInAppAsRead,
markAllInAppRead,
type InAppNotificationData,
} from '../api'
/** 格式化相对时间 */
function formatRelativeTime(timestamp: string, locale: string): string {
if (!timestamp) return ''
const diff = Date.now() - new Date(timestamp).getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (seconds < 60) return locale === 'en' ? 'Just now' : '刚刚'
if (minutes < 60) return locale === 'en' ? `${minutes}m ago` : `${minutes}分钟前`
if (hours < 24) return locale === 'en' ? `${hours}h ago` : `${hours}小时前`
const days = Math.floor(hours / 24)
return locale === 'en' ? `${days}d ago` : `${days}天前`
}
const TYPE_ICONS: Record<string, string> = {
appointment: '\u{1F4C5}',
patient: '\u{1F43E}',
vaccine: '\u{1F489}',
bill: '\u{1F4B0}',
system: '\u{1F514}',
}
export default function NotificationBell() {
const { t, locale } = useTranslation()
const navigate = useNavigate()
const [unreadCount, setUnreadCount] = useState(0)
const [open, setOpen] = useState(false)
const [notifications, setNotifications] = useState<InAppNotificationData[]>([])
const [loading, setLoading] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// 加载未读数
const loadUnreadCount = useCallback(async () => {
try {
const data = await getInAppUnreadCount()
setUnreadCount(data.count)
} catch {
// 静默失败
}
}, [])
// 加载通知列表
const loadNotifications = useCallback(async () => {
setLoading(true)
try {
const res = await getInAppNotifications({ page: 1, page_size: 10 })
setNotifications(res.items)
} catch {
// 静默失败
} finally {
setLoading(false)
}
}, [])
// 打开面板时加载
useEffect(() => {
if (open) {
loadNotifications()
}
}, [open, loadNotifications])
// 定时轮询未读数
useEffect(() => {
loadUnreadCount()
const interval = setInterval(loadUnreadCount, 30_000)
return () => clearInterval(interval)
}, [loadUnreadCount])
// 点击外部关闭
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handler)
}
return () => document.removeEventListener('mousedown', handler)
}, [open])
// 点击通知
const handleClickNotification = async (notif: InAppNotificationData) => {
if (!notif.is_read) {
try {
await markInAppAsRead(notif.id)
setUnreadCount((c) => Math.max(0, c - 1))
setNotifications((prev) =>
prev.map((n) => (n.id === notif.id ? { ...n, is_read: true } : n))
)
} catch {
// 静默失败
}
}
if (notif.link) {
navigate(notif.link)
}
setOpen(false)
}
// 全部已读
const handleMarkAllRead = async () => {
try {
await markAllInAppRead()
setUnreadCount(0)
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
} catch {
// 静默失败
}
}
return (
<div ref={dropdownRef} className="relative">
{/* 铃铛按钮 */}
<button
onClick={() => setOpen(!open)}
className="relative p-1.5 hover:bg-gray-100 rounded-lg text-gray-500 hover:text-gray-700 transition-colors min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label={t('notifications.bellLabel') || '通知'}
aria-haspopup="true"
aria-expanded={open}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
{unreadCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center"
style={{ animation: 'count-badge 0.4s cubic-bezier(.34,1.56,.64,1)' }}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* 下拉面板 */}
{open && (
<div
className="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-50 overflow-hidden dropdown-enter origin-top-right"
role="menu"
aria-label={t('notifications.panelTitle') || '通知'}
>
{/* 头部 */}
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800">
{t('notifications.panelTitle') || '通知'}
</h3>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="text-xs text-medical-600 hover:text-medical-700 font-medium transition-colors"
>
{t('notifications.markAllRead') || '全部已读'}
</button>
)}
</div>
{/* 列表 */}
<div className="max-h-80 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-medical-200 border-t-medical-600 rounded-full animate-spin" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<svg className="w-8 h-8 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
<p className="text-xs">{t('notifications.noInApp') || '暂无通知'}</p>
</div>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => handleClickNotification(n)}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors border-b border-gray-50 last:border-0 ${
!n.is_read ? 'bg-medical-50/50' : ''
}`}
>
<div className="flex items-start gap-2.5">
<span className="text-lg leading-none mt-0.5 shrink-0">
{TYPE_ICONS[n.type] || TYPE_ICONS.system}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className={`text-sm truncate ${!n.is_read ? 'font-semibold text-gray-800' : 'text-gray-600'}`}>
{n.title}
</p>
{!n.is_read && (
<span className="w-2 h-2 rounded-full bg-medical-500 shrink-0" />
)}
</div>
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{n.message}</p>
<p className="text-[10px] text-gray-400 mt-1">
{formatRelativeTime(n.created_at, locale)}
</p>
</div>
</div>
</button>
))
)}
</div>
{/* 底部 */}
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50/50">
<button
onClick={() => { navigate('/notifications'); setOpen(false) }}
className="w-full text-center text-xs text-medical-600 hover:text-medical-700 font-medium py-1 transition-colors"
>
{t('notifications.viewAll') || '查看全部'}
</button>
</div>
</div>
)}
</div>
)
}