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:
SpecialX
2026-06-22 18:43:12 +08:00
parent 6d7838a210
commit 1fe30984b6
29 changed files with 1252 additions and 351 deletions

View File

@@ -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" }
}
}