Files
NextEdu/src/modules/messaging/components/notification-dropdown.tsx
SpecialX a4d096a6fc fix: patch P0 security vulnerabilities and critical UX issues across 6 modules
Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages

Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata

Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx

Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND

Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade

Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class

Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import

Docs: Update architecture docs 004/005
2026-06-22 13:57:31 +08:00

180 lines
5.8 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { cn, formatDate } from "@/shared/lib/utils"
import {
getNotificationsAction,
getUnreadNotificationCountAction,
markAllNotificationsAsReadAction,
markNotificationAsReadAction,
} from "../actions"
import type { Notification, NotificationType } from "@/modules/notifications/types"
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
message: MessageSquare,
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
}
export function NotificationDropdown() {
const router = useRouter()
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [open, setOpen] = useState(false)
useEffect(() => {
let active = true
const fetchNotifications = async () => {
const res = await getNotificationsAction({ pageSize: 10 })
if (!active) return
if (res.success && res.data) {
setNotifications(res.data.items)
}
}
const fetchUnreadCount = async () => {
const res = await getUnreadNotificationCountAction()
if (!active) return
if (res.success && typeof res.data === "number") {
setUnreadCount(res.data)
}
}
void fetchNotifications()
void fetchUnreadCount()
// 每 30 秒轮询刷新通知和未读计数
const timer = setInterval(() => {
void fetchNotifications()
void fetchUnreadCount()
}, 30_000)
return () => {
active = false
clearInterval(timer)
}
}, [])
const handleMarkAllRead = async () => {
const res = await markAllNotificationsAsReadAction()
if (res.success) {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
setUnreadCount(0)
router.refresh()
}
}
const handleMarkRead = async (id: string) => {
const res = await markNotificationAsReadAction(id)
if (res.success) {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
)
setUnreadCount((c) => Math.max(0, c - 1))
router.refresh()
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative text-muted-foreground">
<Bell className="size-5" />
{unreadCount > 0 ? (
<Badge
variant="destructive"
className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center px-1 text-[10px]"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
) : null}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Notifications</span>
{unreadCount > 0 ? (
<button
type="button"
onClick={handleMarkAllRead}
className="text-primary text-xs hover:underline"
>
<CheckCheck className="mr-1 inline h-3 w-3" />
Mark all read
</button>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator className="mb-0" />
<ScrollArea className="max-h-[320px]">
{notifications.length === 0 ? (
<div className="text-muted-foreground px-4 py-8 text-center text-sm">
No notifications
</div>
) : (
notifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell
return (
<DropdownMenuItem
key={n.id}
className="flex items-start gap-2 py-3"
onSelect={(e) => {
if (!n.isRead) {
e.preventDefault()
handleMarkRead(n.id)
}
}}
>
<div className="bg-muted mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full">
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5">
{!n.isRead ? (
<span className="bg-primary size-1.5 shrink-0 rounded-full" />
) : null}
<span className={cn("text-xs", !n.isRead ? "font-semibold" : "font-medium")}>
{n.title}
</span>
</div>
{n.content ? (
<p className="text-muted-foreground line-clamp-2 text-xs">{n.content}</p>
) : null}
<span className="text-muted-foreground text-[10px]">
{formatDate(n.createdAt)}
</span>
</div>
</DropdownMenuItem>
)
})
)}
</ScrollArea>
<DropdownMenuSeparator className="mt-0" />
<DropdownMenuItem asChild>
<Link href="/messages" className="text-primary justify-center text-xs">
View all notifications
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}