Files
NextEdu/src/modules/messaging/components/notification-dropdown.tsx
SpecialX fde711ce46 feat(announcements,messaging): 公告与消息模块审计重构 — i18n + Error Boundary + a11y
- 新增审计报告 docs/architecture/audit/announcements-messages-audit-report.md
- 新增中英双语 i18n 字典 announcements.json / messages.json(11/13 个命名空间)
- 重构所有 announcements 和 messaging 组件接入 next-intl(useTranslations)
- 所有页面 page.tsx 使用 generateMetadata + getTranslations 替代硬编码 metadata
- 新增 7 个 error.tsx 错误边界(4 公告 + 3 消息),统一 EmptyState + i18n + 重试
- a11y 改进:announcement-card / message-list / notification-dropdown 添加 aria-label
- 同步架构图 004 和 005:i18n.messages 清单 + 已知问题修复记录
2026-06-22 16:02:07 +08:00

182 lines
6.1 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
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 t = useTranslations("messages")
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" aria-label={t("title.notifications")}>
<Bell className="size-5" aria-hidden="true" />
{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">{t("title.notifications")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<DropdownMenuLabel className="flex items-center justify-between">
<span>{t("title.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" aria-hidden="true" />
{t("actions.markAllRead")}
</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">
{t("empty.noNotificationsDropdown")}
</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" aria-hidden="true" />
</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" aria-hidden="true" />
) : 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">
{t("actions.viewAll")}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}