feat(announcements,messaging,notifications): 实现所有长期问题 — SSE 实时推送 + 通知日志持久化 + 优先级/归档 + 消息星标/草稿 + 公告已读回执/置顶 + 分类筛选/桌面推送 + 测试覆盖

P1-8 通知实时推送(SSE):
- 新增 /api/notifications/stream SSE 端点(15 秒推送,5 分钟超时)
- 新增 useNotificationStream Hook(SSE + 轮询降级)
- NotificationDropdown 改用 SSE 实时推送

P2-12 测试覆盖:
- notifications/dispatcher.test.ts(6 个测试,渠道选择逻辑)
- notifications/channels/in-app-channel.test.ts(9 个测试,类型映射)
- messaging/schema.test.ts(34 个测试,Zod 校验)
- tests/e2e/messages.spec.ts(消息模块 E2E 测试)
- vitest.unit.config.ts 添加 server-only stub

P2-13a 通知发送日志持久化:
- 新增 notification_logs 表(userId/title/channel/status/messageId/error/sentAt)
- logNotificationSend 改为 async 写入 DB(失败降级 console)
- dispatcher 传递 payload 用于持久化

P2-13b 通知优先级和归档:
- messageNotifications 表新增 priority(low/normal/high/urgent)和 isArchived 字段
- getNotifications 支持归档和优先级筛选
- 新增 archiveNotificationAction
- NotificationList 显示优先级 Badge 和归档按钮

P2-13c 消息星标和草稿:
- messages 表新增 isStarred 字段
- 新增 message_drafts 表
- 新增 toggleMessageStar + 草稿 CRUD Server Actions
- 新增 5 个草稿 data-access 函数

P2-13d 公告已读回执和置顶:
- announcements 表新增 isPinned 字段
- 新增 announcement_reads 表(唯一索引保证幂等)
- 新增 toggleAnnouncementPinAction + markAnnouncementAsReadAction
- getAnnouncements 排序置顶优先

P2-13e 通知分类筛选和桌面推送:
- NotificationList 添加按类型筛选按钮组
- 新增 useDesktopNotifications Hook(浏览器 Notification API)
- NotificationDropdown 集成桌面推送(新通知触发)

架构图同步:
- 004 和 005 均已更新(新增表、Action、Hook、组件描述)
This commit is contained in:
SpecialX
2026-06-23 10:13:57 +08:00
parent 696346dc08
commit f75602d14e
39 changed files with 2557 additions and 110 deletions

View File

@@ -733,6 +733,8 @@ export const announcements = mysqlTable("announcements", {
targetClassId: varchar("target_class_id", { length: 128 }),
authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id),
publishedAt: datetime("published_at", { mode: "date" }),
// V2-P2-13d: 公告置顶(置顶公告在列表中优先显示)
isPinned: boolean("is_pinned").default(false).notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
}, (table) => ({
@@ -741,6 +743,22 @@ export const announcements = mysqlTable("announcements", {
typeIdx: index("announcements_type_idx").on(table.type),
targetGradeIdx: index("announcements_target_grade_idx").on(table.targetGradeId),
targetClassIdx: index("announcements_target_class_idx").on(table.targetClassId),
// V2-P2-13d: 置顶索引
statusPinnedIdx: index("announcements_status_pinned_idx").on(table.status, table.isPinned),
}));
// --- 8b. Announcement Reads (公告已读回执) ---
export const announcementReads = mysqlTable("announcement_reads", {
id: id("id").primaryKey(),
announcementId: varchar("announcement_id", { length: 128 }).notNull().references(() => announcements.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
readAt: timestamp("read_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
announcementIdx: index("announcement_reads_announcement_idx").on(table.announcementId),
userIdx: index("announcement_reads_user_idx").on(table.userId),
// 唯一约束:一个用户对一条公告只能有一条已读记录
uniqueAnnouncementUser: uniqueIndex("announcement_reads_unique_idx").on(table.announcementId, table.userId),
}));
// --- 9. Audit & Login Logs ---
@@ -975,6 +993,8 @@ export const messages = mysqlTable("messages", {
// 软删除:发送方/接收方各自独立删除,互不影响
senderDeletedAt: timestamp("sender_deleted_at", { mode: "date" }),
receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }),
// V2-P2-13c: 消息星标(接收方可标记重要消息)
isStarred: boolean("is_starred").default(false).notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
senderIdx: index("messages_sender_idx").on(table.senderId),
@@ -982,6 +1002,24 @@ export const messages = mysqlTable("messages", {
isReadIdx: index("messages_is_read_idx").on(table.isRead),
parentIdx: index("messages_parent_idx").on(table.parentMessageId),
receiverReadIdx: index("messages_receiver_read_idx").on(table.receiverId, table.isRead),
// V2-P2-13c: 星标索引
receiverStarredIdx: index("messages_receiver_starred_idx").on(table.receiverId, table.isStarred),
}));
// --- 14b. Message Drafts (消息草稿) ---
export const messageDrafts = mysqlTable("message_drafts", {
id: id("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
receiverId: varchar("receiver_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }),
subject: varchar("subject", { length: 255 }),
content: text("content"),
parentMessageId: varchar("parent_message_id", { length: 128 }),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdx: index("message_drafts_user_idx").on(table.userId),
userUpdatedIdx: index("message_drafts_user_updated_idx").on(table.userId, table.updatedAt),
}));
// --- 15. Message Notifications (消息通知) ---
@@ -994,12 +1032,44 @@ export const messageNotifications = mysqlTable("message_notifications", {
content: text("content"),
link: varchar("link", { length: 512 }),
isRead: boolean("is_read").default(false).notNull(),
// V2-P2-13b: 通知优先级low/normal/high/urgent默认 normal
priority: varchar("priority", { length: 16 }).default("normal").notNull(),
// V2-P2-13b: 通知归档标记,归档后不在默认列表显示
isArchived: boolean("is_archived").default(false).notNull(),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdx: index("message_notifications_user_idx").on(table.userId),
isReadIdx: index("message_notifications_is_read_idx").on(table.isRead),
userReadIdx: index("message_notifications_user_read_idx").on(table.userId, table.isRead),
createdAtIdx: index("message_notifications_created_at_idx").on(table.createdAt),
// V2-P2-13b: 新增优先级和归档索引
priorityIdx: index("message_notifications_priority_idx").on(table.priority),
userArchivedIdx: index("message_notifications_user_archived_idx").on(table.userId, table.isArchived),
}));
// --- 17. Notification Logs (通知发送日志) ---
export const notificationLogs = mysqlTable("notification_logs", {
id: id("id").primaryKey(),
// 关联用户(通知接收人)
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
// 通知标题(冗余存储,便于查询)
title: varchar("title", { length: 255 }).notNull(),
// 发送渠道: in_app | email | sms | wechat
channel: varchar("channel", { length: 32 }).notNull(),
// 发送状态: success | failure
status: varchar("status", { length: 16 }).notNull(),
// 渠道返回的消息 ID用于追踪
messageId: varchar("message_id", { length: 255 }),
// 失败时的错误信息
error: text("error"),
// 发送时间
sentAt: timestamp("sent_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
userIdx: index("notification_logs_user_idx").on(table.userId),
channelIdx: index("notification_logs_channel_idx").on(table.channel),
statusIdx: index("notification_logs_status_idx").on(table.status),
sentAtIdx: index("notification_logs_sent_at_idx").on(table.sentAt),
}));
// --- 16. Notification Preferences (通知偏好) ---
@@ -1270,6 +1340,8 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports
id: id("id").primaryKey(),
studentId: varchar("student_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }),
generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
// v4-P1-4: 班级报告关联的 classId用于发布时批量通知全班学生
classId: varchar("class_id", { length: 128 }).references(() => classes.id, { onDelete: "set null" }),
reportType: diagnosticReportTypeEnum.default("individual").notNull(),
period: varchar("period", { length: 50 }),
summary: text("summary"),
@@ -1285,6 +1357,7 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports
generatedByIdx: index("diagnostic_generated_by_idx").on(table.generatedBy),
statusIdx: index("diagnostic_status_idx").on(table.status),
reportTypeIdx: index("diagnostic_report_type_idx").on(table.reportType),
classIdx: index("diagnostic_class_idx").on(table.classId),
}));
// --- 24. Lesson Preparation (备课) ---
@@ -1440,3 +1513,30 @@ export const errorBookReviews = mysqlTable("error_book_reviews", {
studentIdx: index("eb_review_student_idx").on(table.studentId),
studentReviewedIdx: index("eb_review_student_reviewed_idx").on(table.studentId, table.reviewedAt),
}));
// --- 27. Grade Drafts (成绩录入草稿 - 服务端自动保存) ---
/**
* 成绩录入草稿 - 用于跨设备恢复未保存的成绩。
* v3-P2-10: 替代纯 localStorage 方案,支持换设备恢复。
* 唯一键userId + classId + subjectId + type确保每位教师每组合只有一个草稿。
*/
export const gradeDrafts = mysqlTable("grade_drafts", {
id: id("id").primaryKey(),
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
subjectId: varchar("subject_id", { length: 128 }).notNull().references(() => subjects.id, { onDelete: "cascade" }),
type: varchar("type", { length: 20 }).notNull(),
/** 草稿内容:{ scores: Record<string, string>, timestamp: number } */
content: json("content").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userDraftIdx: uniqueIndex("gd_user_class_subject_type_idx").on(
table.userId,
table.classId,
table.subjectId,
table.type
),
userUpdatedIdx: index("gd_user_updated_idx").on(table.userId, table.updatedAt),
}));

View File

@@ -21,7 +21,10 @@
"status": {
"draft": "Draft",
"published": "Published",
"archived": "Archived"
"archived": "Archived",
"pinned": "Pinned",
"read": "Read",
"unread": "Unread"
},
"type": {
"school": "School",
@@ -52,7 +55,10 @@
"archive": "Archive",
"edit": "Edit",
"delete": "Delete",
"back": "Back"
"back": "Back",
"pin": "Pin",
"unpin": "Unpin",
"markRead": "Mark as Read"
},
"messages": {
"created": "Announcement created",
@@ -67,7 +73,9 @@
"archiveFailed": "Failed to archive",
"deleteFailed": "Failed to delete",
"invalidForm": "Invalid form data",
"invalidState": "Invalid form state"
"invalidState": "Invalid state",
"pinToggled": "Pin status updated",
"markedRead": "Marked as read"
},
"meta": {
"publishedAt": "Published {date}",

View File

@@ -21,7 +21,11 @@
"send": "Send",
"sending": "Sending...",
"cancel": "Cancel",
"back": "Back"
"back": "Back",
"star": "Star",
"unstar": "Unstar",
"saveDraft": "Save Draft",
"deleteDraft": "Delete Draft"
},
"form": {
"to": "To",
@@ -55,7 +59,9 @@
"sentEmpty": "No sent messages",
"sentEmptyDesc": "You have not sent any messages yet.",
"deleteTitle": "Delete message",
"deleteDesc": "This will permanently delete the message \"{subject}\"."
"deleteDesc": "This will permanently delete the message \"{subject}\".",
"noDrafts": "No drafts",
"noStarred": "No starred messages"
},
"messages": {
"sent": "Message sent",
@@ -66,7 +72,10 @@
"deleteFailed": "Failed to delete",
"notFound": "Message not found",
"invalidId": "Invalid message id",
"selectRecipient": "Please select a recipient"
"selectRecipient": "Please select a recipient",
"draftSaved": "Draft saved",
"draftDeleted": "Draft deleted",
"starToggled": "Star status updated"
},
"notification": {
"messageTitle": "New message: {subject}",

View File

@@ -10,7 +10,9 @@
"markRead": "Mark as read",
"markAllRead": "Mark all as read",
"viewAll": "View all notifications",
"view": "View"
"view": "View",
"archive": "Archive",
"unarchive": "Unarchive"
},
"type": {
"message": "Message",
@@ -18,18 +20,30 @@
"homework": "Homework",
"grade": "Grade"
},
"filter": {
"all": "All"
},
"priority": {
"low": "Low",
"normal": "Normal",
"high": "High",
"urgent": "Urgent"
},
"status": {
"new": "New"
},
"empty": {
"noNotifications": "No notifications",
"noNotificationsDesc": "You have no notifications yet.",
"noNotificationsDropdown": "No notifications"
"noNotificationsDropdown": "No notifications",
"noFilterResults": "No results",
"noFilterResultsDesc": "No notifications match the current filter."
},
"messages": {
"allMarkedRead": "All notifications marked as read",
"notificationMarkedRead": "Notification marked as read",
"markReadFailed": "Failed to mark as read"
"markReadFailed": "Failed to mark as read",
"archiveFailed": "Archive failed"
},
"error": {
"loadFailed": "Failed to load notifications",

View File

@@ -21,7 +21,10 @@
"status": {
"draft": "草稿",
"published": "已发布",
"archived": "已归档"
"archived": "已归档",
"pinned": "已置顶",
"read": "已读",
"unread": "未读"
},
"type": {
"school": "全校",
@@ -52,7 +55,10 @@
"archive": "归档",
"edit": "编辑",
"delete": "删除",
"back": "返回"
"back": "返回",
"pin": "置顶",
"unpin": "取消置顶",
"markRead": "标记已读"
},
"messages": {
"created": "公告已创建",
@@ -67,7 +73,9 @@
"archiveFailed": "归档失败",
"deleteFailed": "删除失败",
"invalidForm": "表单数据无效",
"invalidState": "表单状态无效"
"invalidState": "表单状态无效",
"pinToggled": "置顶状态已更新",
"markedRead": "已标记为已读"
},
"meta": {
"publishedAt": "发布于 {date}",

View File

@@ -21,7 +21,11 @@
"send": "发送",
"sending": "发送中...",
"cancel": "取消",
"back": "返回"
"back": "返回",
"star": "星标",
"unstar": "取消星标",
"saveDraft": "保存草稿",
"deleteDraft": "删除草稿"
},
"form": {
"to": "收件人",
@@ -55,7 +59,9 @@
"sentEmpty": "无已发送消息",
"sentEmptyDesc": "您还没有发送任何消息。",
"deleteTitle": "删除消息",
"deleteDesc": "此操作将永久删除消息{subject}。"
"deleteDesc": "此操作将永久删除消息{subject}。",
"noDrafts": "暂无草稿",
"noStarred": "暂无星标消息"
},
"messages": {
"sent": "消息已发送",
@@ -66,7 +72,10 @@
"deleteFailed": "删除失败",
"notFound": "消息不存在",
"invalidId": "消息 ID 无效",
"selectRecipient": "请选择收件人"
"selectRecipient": "请选择收件人",
"draftSaved": "草稿已保存",
"draftDeleted": "草稿已删除",
"starToggled": "星标状态已更新"
},
"notification": {
"messageTitle": "新消息:{subject}",

View File

@@ -10,7 +10,9 @@
"markRead": "标记已读",
"markAllRead": "全部标记已读",
"viewAll": "查看全部通知",
"view": "查看"
"view": "查看",
"archive": "归档",
"unarchive": "取消归档"
},
"type": {
"message": "消息",
@@ -18,18 +20,30 @@
"homework": "作业",
"grade": "成绩"
},
"filter": {
"all": "全部"
},
"priority": {
"low": "低",
"normal": "普通",
"high": "高",
"urgent": "紧急"
},
"status": {
"new": "新"
},
"empty": {
"noNotifications": "暂无通知",
"noNotificationsDesc": "您还没有任何通知。",
"noNotificationsDropdown": "暂无通知"
"noNotificationsDropdown": "暂无通知",
"noFilterResults": "无筛选结果",
"noFilterResultsDesc": "当前筛选条件下没有通知。"
},
"messages": {
"allMarkedRead": "全部已标记为已读",
"notificationMarkedRead": "通知已标记为已读",
"markReadFailed": "标记已读失败"
"markReadFailed": "标记已读失败",
"archiveFailed": "归档失败"
},
"error": {
"loadFailed": "通知加载失败",

View File

@@ -20,9 +20,12 @@ export type EventName =
| "announcement.published"
| "announcement.archived"
| "announcement.deleted"
| "announcement.pin_toggled"
| "announcement.marked_read"
| "message.sent"
| "message.deleted"
| "message.marked_read"
| "message.star_toggled"
| "notification.marked_read"
| "notification.marked_all_read"
| "notification.sent"