feat(notification): add dashboard notification center

This commit is contained in:
mlogclub
2026-04-28 19:20:38 +08:00
parent f287727c59
commit 909854cd03
22 changed files with 1230 additions and 13 deletions
+11 -8
View File
@@ -7,6 +7,7 @@ import { useEffect } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { useAuth } from "@/components/auth-provider"
import { NotificationProvider } from "@/components/notification-provider"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
@@ -57,15 +58,17 @@ export default function DashboardLayout({
} as CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
{children}
<NotificationProvider>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
{children}
</div>
</div>
</div>
</SidebarInset>
</SidebarInset>
</NotificationProvider>
</SidebarProvider>
)
}
+187
View File
@@ -0,0 +1,187 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
import { useNotifications } from "@/components/notification-provider"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
fetchNotifications,
markAllNotificationsRead,
markNotificationRead,
type NotificationItem,
type NotificationReadStatus,
} from "@/lib/api/notification"
import type { PageResult } from "@/lib/api/admin"
import { cn, formatDateTime } from "@/lib/utils"
const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
{ value: "all", label: "全部" },
{ value: "unread", label: "未读" },
{ value: "read", label: "已读" },
]
export default function DashboardNotificationsPage() {
const router = useRouter()
const { refreshUnreadCount } = useNotifications()
const [readStatus, setReadStatus] = useState<NotificationReadStatus>("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [result, setResult] = useState<PageResult<NotificationItem>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchNotifications({
page,
limit,
readStatus,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载通知失败")
} finally {
setLoading(false)
}
}, [limit, page, readStatus])
useEffect(() => {
void loadData()
}, [loadData])
async function openNotification(item: NotificationItem) {
try {
if (!item.readAt) {
await markNotificationRead(item.id)
await refreshUnreadCount()
}
if (item.actionUrl) {
router.push(item.actionUrl)
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "打开通知失败")
}
}
async function handleMarkAllRead() {
setActionLoading(true)
try {
await markAllNotificationsRead()
await refreshUnreadCount()
await loadData()
toast.success("已全部标记为已读")
} catch (error) {
toast.error(error instanceof Error ? error.message : "全部已读失败")
} finally {
setActionLoading(false)
}
}
function handleStatusChange(nextStatus: NotificationReadStatus) {
setReadStatus(nextStatus)
setPage(1)
}
function handlePageChange(nextPage: number) {
if (nextPage < 1 || nextPage === page) {
return
}
setPage(nextPage)
}
function handleLimitChange(nextLimit: number) {
setLimit(nextLimit)
setPage(1)
}
return (
<div className="flex flex-col gap-4 p-4 md:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCwIcon className={cn(loading && "animate-spin")} />
</Button>
<Button
variant="outline"
onClick={() => void handleMarkAllRead()}
disabled={actionLoading || result.page.total === 0}
>
<CheckCheckIcon />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{readStatusOptions.map((option) => (
<Button
key={option.value}
variant={option.value === readStatus ? "default" : "outline"}
onClick={() => handleStatusChange(option.value)}
>
{option.label}
</Button>
))}
</div>
<div className="overflow-hidden rounded-lg border bg-background">
{result.results.length > 0 ? (
<div className="divide-y">
{result.results.map((item) => {
const unread = !item.readAt
return (
<button
key={item.id}
type="button"
onClick={() => void openNotification(item)}
className="grid w-full gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/60"
>
<div className="flex flex-wrap items-center gap-2">
<BellIcon className="size-4 text-muted-foreground" />
<span className="font-medium">{item.title || "通知"}</span>
{unread ? <Badge></Badge> : <Badge variant="outline"></Badge>}
<span className="ml-auto text-xs text-muted-foreground">
{formatDateTime(item.createdAt)}
</span>
</div>
<div className="whitespace-pre-line text-sm text-muted-foreground">
{item.content || "-"}
</div>
</button>
)
})}
</div>
) : (
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
{loading ? "正在加载通知" : "暂无通知"}
</div>
)}
</div>
<ListPagination
page={result.page.page}
limit={result.page.limit}
total={result.page.total}
loading={loading}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
)
}
+16 -2
View File
@@ -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 />
+159
View File
@@ -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
}
+81
View File
@@ -0,0 +1,81 @@
import { readSession } from "@/lib/auth"
import { request } from "@/lib/api/client"
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import type { PageResult } from "@/lib/api/admin"
export type NotificationReadStatus = "all" | "unread" | "read"
export type NotificationItem = {
id: number
recipientUserId: number
title: string
content: string
notificationType: string
bizType: string
bizId: number
actionUrl: string
readAt?: string
createdAt?: string
}
export type NotificationUnreadCount = {
unreadCount: number
}
export type NotificationListQuery = {
page?: number
limit?: number
readStatus?: NotificationReadStatus
type?: string
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchNotifications(query?: NotificationListQuery) {
return request<PageResult<NotificationItem>>(
`/api/dashboard/notification/list${toQueryString(query)}`
)
}
export function fetchNotificationUnreadCount() {
return request<NotificationUnreadCount>("/api/dashboard/notification/unread_count")
}
export function markNotificationRead(id: number) {
return request<void>("/api/dashboard/notification/mark_read", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function markAllNotificationsRead() {
return request<void>("/api/dashboard/notification/mark_all_read", {
method: "POST",
})
}
export function createNotificationWebSocketUrl() {
const session = readSession()
if (!session?.accessToken) {
throw new Error("未登录或登录已过期")
}
const params = new URLSearchParams({
accessToken: session.accessToken,
})
return `${createWebSocketBaseUrl()}/api/ws/dashboard/notification?${params.toString()}`
}