feat(notification): add dashboard notification center
This commit is contained in:
@@ -5,6 +5,8 @@ import { useState } from "react"
|
||||
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { ChangePasswordDialog } from "@/components/change-password-dialog"
|
||||
import { useNotifications } from "@/components/notification-provider"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
@@ -42,7 +44,9 @@ export function NavUser({
|
||||
}
|
||||
}) {
|
||||
const { signOut } = useAuth()
|
||||
const { unreadCount } = useNotifications()
|
||||
const { isMobile } = useSidebar()
|
||||
const router = useRouter()
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
|
||||
const fallback = user.name.slice(0, 1).toUpperCase() || "U"
|
||||
return (
|
||||
@@ -99,9 +103,19 @@ export function NavUser({
|
||||
<KeyRoundIcon />
|
||||
修改密码
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
router.push("/dashboard/notifications")
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<BellIcon />
|
||||
通知中心
|
||||
<span className="flex-1">通知中心</span>
|
||||
{unreadCount > 0 ? (
|
||||
<Badge className="h-5 min-w-5 px-1.5">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
createNotificationWebSocketUrl,
|
||||
fetchNotificationUnreadCount,
|
||||
markNotificationRead,
|
||||
type NotificationItem,
|
||||
} from "@/lib/api/notification"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import {
|
||||
createRealtimeConnectionManager,
|
||||
type RealtimeConnectionStatus,
|
||||
} from "@/lib/realtime-connection"
|
||||
|
||||
type NotificationRealtimeEnvelope = {
|
||||
eventId?: string
|
||||
type?: string
|
||||
data?: {
|
||||
notification?: NotificationItem
|
||||
}
|
||||
}
|
||||
|
||||
type NotificationContextValue = {
|
||||
unreadCount: number
|
||||
realtimeStatus: RealtimeConnectionStatus
|
||||
refreshUnreadCount: () => Promise<void>
|
||||
markReadAndNavigate: (notification: NotificationItem) => Promise<void>
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextValue | null>(null)
|
||||
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [realtimeStatus, setRealtimeStatus] =
|
||||
useState<RealtimeConnectionStatus>("disconnected")
|
||||
const currentUserIdRef = useRef(readSession()?.user.id ?? 0)
|
||||
|
||||
const refreshUnreadCount = useCallback(async () => {
|
||||
const result = await fetchNotificationUnreadCount()
|
||||
setUnreadCount(result.unreadCount)
|
||||
}, [])
|
||||
|
||||
const markReadAndNavigate = useCallback(
|
||||
async (notification: NotificationItem) => {
|
||||
if (!notification.readAt) {
|
||||
await markNotificationRead(notification.id)
|
||||
setUnreadCount((current) => Math.max(0, current - 1))
|
||||
}
|
||||
if (notification.actionUrl) {
|
||||
router.push(notification.actionUrl)
|
||||
}
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
currentUserIdRef.current = readSession()?.user.id ?? 0
|
||||
void refreshUnreadCount().catch(() => {
|
||||
setUnreadCount(0)
|
||||
})
|
||||
}, [refreshUnreadCount])
|
||||
|
||||
useEffect(() => {
|
||||
const realtime = createRealtimeConnectionManager({
|
||||
createSocket: () => new WebSocket(createNotificationWebSocketUrl()),
|
||||
canReconnect: () => Boolean(readSession()?.accessToken),
|
||||
onStatusChange: setRealtimeStatus,
|
||||
onOpen: () => {
|
||||
void refreshUnreadCount().catch(() => undefined)
|
||||
},
|
||||
onMessage: (event, socket) => {
|
||||
try {
|
||||
const envelope = JSON.parse(event.data) as NotificationRealtimeEnvelope
|
||||
const eventType = envelope.type ?? ""
|
||||
const eventId = envelope.eventId?.trim() ?? ""
|
||||
if (
|
||||
eventType === "" ||
|
||||
eventType === "connected" ||
|
||||
eventType === "pong" ||
|
||||
eventType === "subscribed" ||
|
||||
eventType === "unsubscribed"
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (eventId && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ack", eventId }))
|
||||
}
|
||||
if (eventType !== "notification.created") {
|
||||
return
|
||||
}
|
||||
const notification = envelope.data?.notification
|
||||
if (!notification || notification.recipientUserId !== currentUserIdRef.current) {
|
||||
return
|
||||
}
|
||||
setUnreadCount((current) => current + 1)
|
||||
toast(notification.title || "新通知", {
|
||||
description: notification.content,
|
||||
action: {
|
||||
label: "查看",
|
||||
onClick: () => {
|
||||
void markReadAndNavigate(notification).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "打开通知失败")
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// ignore invalid realtime payload
|
||||
}
|
||||
},
|
||||
onConnectError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "连接通知服务失败")
|
||||
},
|
||||
})
|
||||
|
||||
realtime.connect()
|
||||
return () => {
|
||||
realtime.disconnect()
|
||||
}
|
||||
}, [markReadAndNavigate, refreshUnreadCount])
|
||||
|
||||
const value = useMemo<NotificationContextValue>(
|
||||
() => ({
|
||||
unreadCount,
|
||||
realtimeStatus,
|
||||
refreshUnreadCount,
|
||||
markReadAndNavigate,
|
||||
}),
|
||||
[markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount]
|
||||
)
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const context = useContext(NotificationContext)
|
||||
if (!context) {
|
||||
throw new Error("useNotifications must be used within NotificationProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user