refactor(announcements,messaging,notifications): V1+V2 审计重构 — i18n 命名空间独立 + 通知标题 i18n 化 + 服务端过滤 + 编排下沉 + 表单错误展示 + 架构图同步
V1 改进(已完成): - P0-4/P1-4/P1-5: 通知组件和 CRUD Action 从 messaging 迁移至 notifications 模块 - P1-5: 新增 getMessagesPageData / getAdminAnnouncementsPageData 编排函数 - P1-6: announcements schema 添加 superRefine 条件校验 - P1-7: 新增 useMessageSearch hook(防抖 + 请求竞态取消)+ 客户端分页 UI - P1-9: deleteMessage 事务化 - P2-11: 全模块 trackEvent 埋点 - 全模块 i18n 接入 + Error Boundary + a11y 改进 V2 改进(本次完成): - V2-P0-1: 通知 i18n 命名空间独立(notifications.json),useTranslations 从 "messages" 切换到 "notifications" - V2-P0-2: 公告/消息通知标题 i18n 化,Server Action 中使用 getTranslations 生成通知标题 - V2-P1-1: AnnouncementList 纯服务端过滤,移除客户端 useState/useMemo - V2-P1-2: MessageList 客户端过滤仅在初始数据时执行,搜索结果由服务端按 tab 过滤 - V2-P1-3: 消息详情页编排下沉,新增 getMessageDetailPageData 编排函数 - V2-P1-4: 表单服务端校验错误展示(fieldErrors + aria-invalid) - V2-P2-1: 轮询间隔常量化(POLL_INTERVAL_MS) - V2-P2-2: 架构图同步(004 + 005)
This commit is contained in:
@@ -5,21 +5,31 @@
|
||||
*
|
||||
* - sendNotificationAction: 发送通知给指定用户(需要 MESSAGE_SEND 权限)
|
||||
* - sendClassNotificationAction: 发送班级通知(教师权限,按班级查询学生后批量发送)
|
||||
* - getNotificationsAction / markNotificationAsReadAction / markAllNotificationsAsReadAction / getUnreadNotificationCountAction: 站内通知 CRUD
|
||||
*
|
||||
* 权限说明:
|
||||
* 项目无独立 NOTIFICATION_SEND 权限点,复用 MESSAGE_SEND(教师/管理员/年级主任均拥有)。
|
||||
* 班级通知按教师所教班级过滤,确保教师只能给自己班级发通知。
|
||||
* 站内通知读取/标记已读复用 MESSAGE_READ 权限(任何能读消息的用户都能查看通知)。
|
||||
*/
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { z } from "zod"
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { trackEvent } from "@/shared/lib/track-event"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { getClassExists, getStudentIdsByClassId } from "@/modules/classes/data-access"
|
||||
|
||||
import { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
import type { NotificationPayload, ChannelSendResult } from "./types"
|
||||
import {
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
} from "./data-access"
|
||||
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
|
||||
|
||||
/**
|
||||
* Zod 校验:通知负载(sendNotificationAction 入参)
|
||||
@@ -157,3 +167,101 @@ export async function sendClassNotificationAction(
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 站内通知 CRUD Server Actions
|
||||
//
|
||||
// 这些 Action 从 messaging/actions.ts 迁移而来,使 notifications 模块成为
|
||||
// 通知相关 UI 组件的唯一数据来源,消除 messaging 与 notifications 在 UI 层
|
||||
// 的双向耦合。权限复用 MESSAGE_READ(任何能读消息的用户都能查看通知)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Zod 校验:通知 ID 路径参数 */
|
||||
const NotificationIdSchema = z.string().trim().min(1)
|
||||
|
||||
/**
|
||||
* 获取当前用户的通知列表(分页)。
|
||||
*/
|
||||
export async function getNotificationsAction(
|
||||
params?: { page?: number; pageSize?: number; unreadOnly?: boolean }
|
||||
): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const result = await getNotifications(ctx.userId, params)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的未读通知计数。
|
||||
*/
|
||||
export async function getUnreadNotificationCountAction(): Promise<ActionState<number>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const count = await getUnreadNotificationCount(ctx.userId)
|
||||
return { success: true, data: count }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单条通知标记为已读。
|
||||
*/
|
||||
export async function markNotificationAsReadAction(
|
||||
notificationId: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
const parsed = NotificationIdSchema.safeParse(notificationId)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid notification id" }
|
||||
}
|
||||
|
||||
await markNotificationAsRead(parsed.data, ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
|
||||
void trackEvent({
|
||||
event: "notification.marked_read",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data,
|
||||
targetType: "notification",
|
||||
})
|
||||
|
||||
return { success: true, message: "Notification marked as read" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前用户的所有未读通知标记为已读。
|
||||
*/
|
||||
export async function markAllNotificationsAsReadAction(): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
await markAllNotificationsAsRead(ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
|
||||
void trackEvent({
|
||||
event: "notification.marked_all_read",
|
||||
userId: ctx.userId,
|
||||
targetType: "notification",
|
||||
})
|
||||
|
||||
return { success: true, message: "All notifications marked as read" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
2
src/modules/notifications/components/index.ts
Normal file
2
src/modules/notifications/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { NotificationList } from "./notification-list"
|
||||
export { NotificationDropdown } from "./notification-dropdown"
|
||||
184
src/modules/notifications/components/notification-dropdown.tsx
Normal file
184
src/modules/notifications/components/notification-dropdown.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"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 "../types"
|
||||
|
||||
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
message: MessageSquare,
|
||||
announcement: Megaphone,
|
||||
homework: PenTool,
|
||||
grade: GraduationCap,
|
||||
}
|
||||
|
||||
/** 通知下拉菜单轮询间隔(毫秒) */
|
||||
const POLL_INTERVAL_MS = 30_000
|
||||
|
||||
export function NotificationDropdown() {
|
||||
const t = useTranslations("notifications")
|
||||
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()
|
||||
|
||||
// 每 POLL_INTERVAL_MS 毫秒轮询刷新通知和未读计数
|
||||
const timer = setInterval(() => {
|
||||
void fetchNotifications()
|
||||
void fetchUnreadCount()
|
||||
}, POLL_INTERVAL_MS)
|
||||
|
||||
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.dropdown")}>
|
||||
<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.dropdown")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span>{t("title.dropdown")}</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>
|
||||
)
|
||||
}
|
||||
137
src/modules/notifications/components/notification-list.tsx
Normal file
137
src/modules/notifications/components/notification-list.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
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 { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { markAllNotificationsAsReadAction, markNotificationAsReadAction } from "../actions"
|
||||
import type { Notification, NotificationType } from "../types"
|
||||
|
||||
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
message: MessageSquare,
|
||||
announcement: Megaphone,
|
||||
homework: PenTool,
|
||||
grade: GraduationCap,
|
||||
}
|
||||
|
||||
export function NotificationList({ notifications }: { notifications: Notification[] }) {
|
||||
const t = useTranslations("notifications")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const hasUnread = notifications.some((n) => !n.isRead)
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
const res = await markAllNotificationsAsReadAction()
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("messages.markReadFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.markReadFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
try {
|
||||
const res = await markNotificationAsReadAction(id)
|
||||
if (res.success) {
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.markReadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title.list")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("description.list")}</p>
|
||||
</div>
|
||||
{hasUnread ? (
|
||||
<Button onClick={handleMarkAllRead} disabled={isWorking} variant="outline">
|
||||
<CheckCheck className="mr-2 h-4 w-4" />
|
||||
{t("actions.markAllRead")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("empty.noNotifications")}
|
||||
description={t("empty.noNotificationsDesc")}
|
||||
icon={Bell}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map((n) => {
|
||||
const Icon = TYPE_ICON[n.type] ?? Bell
|
||||
return (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={cn("transition-colors", !n.isRead && "border-primary/40 bg-primary/5")}
|
||||
>
|
||||
<CardContent className="flex items-start gap-3 py-4">
|
||||
<div className="bg-muted flex size-9 shrink-0 items-center justify-center rounded-full">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm", !n.isRead ? "font-semibold" : "font-medium")}>
|
||||
{n.title}
|
||||
</span>
|
||||
{!n.isRead ? <Badge variant="default" className="text-xs">{t("status.new")}</Badge> : null}
|
||||
</div>
|
||||
{n.content ? (
|
||||
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
|
||||
{n.content}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(`type.${n.type}`)}
|
||||
</Badge>
|
||||
<span>{formatDate(n.createdAt)}</span>
|
||||
{!n.isRead ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMarkRead(n.id)}
|
||||
className="text-primary hover:underline"
|
||||
aria-label={t("actions.markRead")}
|
||||
>
|
||||
{t("actions.markRead")}
|
||||
</button>
|
||||
) : null}
|
||||
{n.link ? (
|
||||
<Link href={n.link} className="ml-auto text-primary hover:underline">
|
||||
{t("actions.view")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
* - sendNotification / sendBatchNotifications: 分发器入口
|
||||
* - createNotification / getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
|
||||
* - getNotificationPreferences / upsertNotificationPreferences: 通知偏好 CRUD
|
||||
* - Server Actions: sendNotificationAction / sendClassNotificationAction / getNotificationsAction / markNotificationAsReadAction / markAllNotificationsAsReadAction / getUnreadNotificationCountAction
|
||||
* - UI 组件: NotificationList / NotificationDropdown
|
||||
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel, NotificationType, Notification, NotificationPreferences 等
|
||||
* - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender
|
||||
*
|
||||
@@ -36,6 +38,15 @@ export {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "./preferences"
|
||||
export {
|
||||
sendNotificationAction,
|
||||
sendClassNotificationAction,
|
||||
getNotificationsAction,
|
||||
getUnreadNotificationCountAction,
|
||||
markNotificationAsReadAction,
|
||||
markAllNotificationsAsReadAction,
|
||||
} from "./actions"
|
||||
export { NotificationList, NotificationDropdown } from "./components"
|
||||
export type {
|
||||
NotificationChannel,
|
||||
NotificationPayload,
|
||||
|
||||
Reference in New Issue
Block a user