notification-panel.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. 'use client'
  2. import { useState } from 'react'
  3. import { useTranslation } from 'react-i18next'
  4. import { RiCheckDoubleLine, RiCheckLine, RiTimeLine } from '@remixicon/react'
  5. import { BellIcon } from '@heroicons/react/24/outline'
  6. import useSWR from 'swr'
  7. import { useRouter } from 'next/navigation'
  8. import classNames from '@/utils/classnames'
  9. import { formatDistanceToNow } from '@/utils/time'
  10. import { fetchNotifications, markNotificationsAsRead } from '@/service/notifications'
  11. import type { Notification } from '@/service/notifications'
  12. type NotificationPanelProps = {
  13. onClose: () => void
  14. onNotificationRead: () => void
  15. }
  16. const NotificationPanel = ({ onClose, onNotificationRead }: NotificationPanelProps) => {
  17. const { t } = useTranslation()
  18. const router = useRouter()
  19. const [filter, setFilter] = useState<'all' | 'unread'>('all')
  20. // 构建 SWR key,确保 filter 变化时触发重新请求
  21. const swrKey = filter === 'unread'
  22. ? '/datasets/notifications?is_read=false'
  23. : '/datasets/notifications'
  24. // 获取通知列表
  25. const { data, mutate } = useSWR(
  26. swrKey,
  27. fetchNotifications,
  28. {
  29. dedupingInterval: 0,
  30. revalidateOnFocus: false,
  31. onSuccess: (data) => {
  32. console.log('[NOTIFY-PANEL] Notifications data:', data)
  33. console.log('[NOTIFY-PANEL] Number of notifications:', data?.notifications?.length || 0)
  34. console.log('[NOTIFY-PANEL] Current filter:', filter)
  35. },
  36. onError: (error) => {
  37. console.error('[NOTIFY-PANEL] Failed to fetch notifications:', error)
  38. },
  39. },
  40. )
  41. const notifications = data?.notifications || []
  42. const unreadCount = data?.unread_count || 0
  43. // 标记单个通知为已读
  44. const markAsRead = async (notificationId: string) => {
  45. try {
  46. await markNotificationsAsRead({
  47. notification_ids: [notificationId],
  48. })
  49. mutate()
  50. onNotificationRead()
  51. }
  52. catch (error) {
  53. console.error('Failed to mark notification as read:', error)
  54. }
  55. }
  56. // 标记全部为已读
  57. const markAllAsRead = async () => {
  58. try {
  59. await markNotificationsAsRead({
  60. mark_all_as_read: true,
  61. })
  62. mutate()
  63. onNotificationRead()
  64. }
  65. catch (error) {
  66. console.error('Failed to mark all notifications as read:', error)
  67. }
  68. }
  69. // 点击通知
  70. const handleNotificationClick = async (notification: Notification) => {
  71. // 如果未读,先标记为已读
  72. if (!notification.is_read)
  73. await markAsRead(notification.id)
  74. // 跳转到对应页面
  75. if (notification.link_url) {
  76. router.push(notification.link_url)
  77. onClose()
  78. }
  79. }
  80. return (
  81. <div className="w-[420px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xl">
  82. {/* 头部 */}
  83. <div className="flex items-center justify-between border-b border-divider-subtle px-4 py-3">
  84. <div className="flex items-center gap-2">
  85. <h3 className="system-md-semibold text-text-primary">
  86. {t('common.notifications.title')}
  87. </h3>
  88. {unreadCount > 0 && (
  89. <span className="rounded-full bg-util-colors-blue-blue-500 px-2 py-0.5 text-xs font-semibold text-white">
  90. {unreadCount}
  91. </span>
  92. )}
  93. </div>
  94. {/* 全部已读按钮 */}
  95. {unreadCount > 0 && (
  96. <button
  97. onClick={markAllAsRead}
  98. className="system-xs-medium flex items-center gap-1 text-text-tertiary hover:text-text-secondary"
  99. >
  100. <RiCheckDoubleLine className="h-3.5 w-3.5" />
  101. {t('common.notifications.markAllAsRead')}
  102. </button>
  103. )}
  104. </div>
  105. {/* 过滤标签 */}
  106. <div className="flex gap-2 border-b border-divider-subtle px-4 py-2">
  107. <button
  108. onClick={() => setFilter('all')}
  109. className={classNames(
  110. 'system-xs-medium rounded-md px-3 py-1',
  111. filter === 'all'
  112. ? 'bg-state-base-active text-text-primary'
  113. : 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
  114. )}
  115. >
  116. {t('common.notifications.all')}
  117. </button>
  118. <button
  119. onClick={() => setFilter('unread')}
  120. className={classNames(
  121. 'system-xs-medium rounded-md px-3 py-1',
  122. filter === 'unread'
  123. ? 'bg-state-base-active text-text-primary'
  124. : 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
  125. )}
  126. >
  127. {t('common.notifications.unread')} {unreadCount > 0 && `(${unreadCount})`}
  128. </button>
  129. </div>
  130. {/* 通知列表 */}
  131. <div className="max-h-[500px] overflow-y-auto">
  132. {notifications.length === 0
  133. ? (
  134. <div className="flex flex-col items-center justify-center py-12">
  135. <BellIcon className="mb-2 h-12 w-12 text-text-quaternary" />
  136. <p className="system-sm-regular text-text-tertiary">
  137. {filter === 'unread'
  138. ? t('common.notifications.noUnread')
  139. : t('common.notifications.noNotifications')}
  140. </p>
  141. </div>
  142. )
  143. : (
  144. notifications.map((notification: Notification) => (
  145. <div
  146. key={notification.id}
  147. onClick={() => handleNotificationClick(notification)}
  148. className={classNames(
  149. 'cursor-pointer border-b border-divider-subtle px-4 py-3 transition-colors hover:bg-state-base-hover',
  150. !notification.is_read && 'bg-state-base-active',
  151. )}
  152. >
  153. <div className="flex items-start gap-3">
  154. {/* 已读/未读指示器 */}
  155. <div className="mt-1.5 shrink-0">
  156. {notification.is_read
  157. ? <RiCheckLine className="h-4 w-4 text-text-quaternary" />
  158. : <div className="h-2 w-2 rounded-full bg-util-colors-blue-blue-600" />}
  159. </div>
  160. <div className="flex-1 overflow-hidden">
  161. {/* 标题 */}
  162. <div className="system-sm-semibold mb-1 text-text-primary">
  163. {notification.title}
  164. </div>
  165. {/* 内容预览 */}
  166. {notification.content && (
  167. <div className="system-xs-regular mb-1 line-clamp-2 text-text-secondary">
  168. {notification.content}
  169. </div>
  170. )}
  171. {/* 片段预览 */}
  172. {notification.segment_preview && (
  173. <div className="system-xs-regular mb-2 line-clamp-1 rounded bg-background-section-burn px-2 py-1 text-text-tertiary">
  174. "{notification.segment_preview}"
  175. </div>
  176. )}
  177. {/* 底部信息 */}
  178. <div className="flex items-center gap-2 text-xs text-text-quaternary">
  179. <span className="font-medium text-text-tertiary">
  180. {notification.dataset_name}
  181. </span>
  182. <span>·</span>
  183. <div className="flex items-center gap-1">
  184. <RiTimeLine className="h-3 w-3" />
  185. <span>{formatDistanceToNow(new Date(notification.created_at))}</span>
  186. </div>
  187. </div>
  188. </div>
  189. </div>
  190. </div>
  191. ))
  192. )}
  193. </div>
  194. </div>
  195. )
  196. }
  197. export default NotificationPanel