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
180 lines
5.8 KiB
TypeScript
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>
|
|
)
|
|
}
|