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

@@ -1096,11 +1096,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
## 2.13 messaging私信模块
**职责**站内私信messages 表 CRUD
**职责**站内私信messages 表 CRUD+ 消息草稿message_drafts 表 CRUD
**导出函数**
- Actions`sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` / `getMessagesAction` / `getMessageDetailAction` / `getRecipientsAction` / `getUnreadMessageCountAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(✅ P1-4 已修复:通知 CRUD Action 已迁移至 notifications 模块messaging 仅保留私信和通知偏好 Action
- Data-access`getMessages` / `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID/ `getMessagesPageData`(✅ P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表)/ `getMessageDetailPageData`(✅ V2-P1-3 新增:消息详情页编排函数,获取详情并自动标记已读)
- Actions`sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` / `getMessagesAction` / `getMessageDetailAction` / `getRecipientsAction` / `getUnreadMessageCountAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(✅ P1-4 已修复:通知 CRUD Action 已迁移至 notifications 模块messaging 仅保留私信和通知偏好 Action;✅ V2-P2-13c 新增:`toggleMessageStarAction` 星标切换 / `getMessageDraftsAction` 草稿列表 / `saveMessageDraftAction` 草稿保存(创建或更新)/ `deleteMessageDraftAction` 草稿删除
- Data-access`getMessages`(✅ V2-P2-13c 新增 starredOnly 星标筛选)/ `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `toggleMessageStar`(✅ V2-P2-13c 新增:接收方星标切换)/ `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID/ `getMessagesPageData`(✅ P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表)/ `getMessageDetailPageData`(✅ V2-P1-3 新增:消息详情页编排函数,获取详情并自动标记已读)/ `getMessageDrafts` / `createMessageDraft` / `updateMessageDraft` / `deleteMessageDraft` / `getMessageDraftById`(✅ V2-P2-13c 新增:消息草稿 CRUD操作 message_drafts 表)
- Hooks`useMessageSearch`(✅ P1-7 新增:消息搜索 hook含防抖和请求竞态取消
- Notification-preferences~~re-export shim实际逻辑在 `notifications/preferences.ts`~~ ✅ P0-b 已修复:`notification-preferences.ts` 文件已删除(通知模块去重),消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences`
@@ -1130,6 +1130,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ V2-P1-3 已修复:~~消息详情页分散编排~~ 新增 `getMessageDetailPageData` 编排函数,替代 page.tsx 中 `after()` + `getMessageById` + `markMessageAsRead` 的分散编排
- ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `message-compose.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示receiverId/subject/content
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `unread-message-badge.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(~~60_000ms~~ → 30_000ms✅ V2-P3 与通知组件保持一致)
- ✅ V2-P2-13c 已实现:消息星标和草稿功能。`messages` 表新增 `isStarred` 字段(接收方可标记重要消息)+ `messages_receiver_starred_idx` 复合索引;新增 `message_drafts`userId/receiverId/subject/content/parentMessageId + updatedAt/createdAt`message_drafts_user_idx``message_drafts_user_updated_idx` 索引data-access 新增 `toggleMessageStar` / `getMessageDrafts` / `createMessageDraft` / `updateMessageDraft` / `deleteMessageDraft` / `getMessageDraftById`actions 新增 `toggleMessageStarAction` / `getMessageDraftsAction` / `saveMessageDraftAction` / `deleteMessageDraftAction``getMessages` 支持 `starredOnly` 筛选i18n 新增 actions.star/unstar/saveDraft/deleteDraft + messages.draftSaved/draftDeleted/starToggled + empty.noDrafts/noStarred 翻译键;迁移文件 `drizzle/0008_message_star_draft.sql`
**文件清单**
| 文件 | 行数 | 职责 |
@@ -1164,8 +1165,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- Data-access`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增archiveNotification / unarchiveNotification 归档函数)
- Preferences`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
- Channels`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
- Components`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13bNotificationList 支持优先级 Badge 显示和归档操作)
- Hooks`useNotificationStream`(✅ V2-P3 新增SSE 实时推送 + 轮询降级 Hook
- Components`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13bNotificationList 支持优先级 Badge 显示和归档操作;✅ V2-P2-13cNotificationList 支持按类型筛选
- Hooks`useNotificationStream`(✅ V2-P3 新增SSE 实时推送 + 轮询降级 Hook`useDesktopNotifications`(✅ V2-P2-13c 新增:浏览器桌面推送 Hook支持权限管理和新通知桌面提醒
- Types`NotificationPriority`(✅ V2-P2-13b 新增:通知优先级类型 low/normal/high/urgent
**依赖关系**
@@ -1182,6 +1183,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `notification-dropdown.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量30_000ms
- ✅ V2-P3 已优化:~~30 秒轮询~~ `notification-dropdown.tsx` 改为 SSE 实时推送(`/api/notifications/stream`SSE 不可用时自动降级为轮询(调用 Server Actions间隔 30 秒)
- ✅ V2-P2-13b 新增通知优先级和归档功能。schema.ts `messageNotifications` 表新增 `priority`low/normal/high/urgent默认 normal`isArchived`(默认 false字段 + 2 个索引types.ts 新增 `NotificationPriority` 类型data-access.ts 新增 `archiveNotification` / `unarchiveNotification` 函数,`getNotifications` 支持归档和优先级筛选(默认仅返回未归档),`createNotification` 支持 priorityactions.ts 新增 `archiveNotificationAction`(含 trackEvent 埋点 notification.archivedNotificationList 组件支持优先级 Badge 显示和归档按钮i18n 新增 priority/actions.archive/messages.archiveFailed 翻译键
- ✅ V2-P2-13c 新增:通知分类筛选 + 浏览器桌面推送。NotificationList 组件新增按类型筛选按钮组all/message/announcement/homework/grade空状态区分"无通知"和"无筛选结果";新增 `useDesktopNotifications` Hook浏览器 Notification API支持权限管理、自动请求权限、新通知桌面推送、点击跳转NotificationDropdown 集成桌面推送监听新通知并触发桌面提醒首次加载不批量推送i18n 新增 filter.all / empty.noFilterResults / empty.noFilterResultsDesc 翻译键
- ⚠️ P1发送日志仅 console`notification_logs`
- ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现)
@@ -1193,21 +1195,23 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `preferences.ts` | 166 | 通知偏好 CRUDP0-4 / P1-5 修复后从 messaging 迁移) |
| `actions.ts` | ~300 | 7 个 Server Action✅ P1-4新增 4 个通知 CRUD Action✅ V2-P2-13b新增 archiveNotificationAction |
| `types.ts` | ~130 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型P0-4 / P1-5 修复后扩充;✅ V2-P2-13b新增 NotificationPriority 类型 + priority/isArchived 字段) |
| `index.ts` | ~80 | 对外导出入口(✅ P1-4新增组件和 CRUD Action 导出;✅ V2-P2-13b新增归档函数/Action/类型导出) |
| `index.ts` | ~80 | 对外导出入口(✅ P1-4新增组件和 CRUD Action 导出;✅ V2-P2-13b新增归档函数/Action/类型导出;✅ V2-P2-13c新增 useNotificationStream / useDesktopNotifications Hook 导出 |
| `channels/*` | 5 文件 | 4 个渠道实现 |
| `components/notification-list.tsx` | ~170 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件;✅ V2-P2-13b支持优先级 Badge 显示和归档操作 |
| `components/notification-dropdown.tsx` | ~150 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3改用 SSE 实时推送 + 轮询降级 |
| `components/notification-list.tsx` | ~190 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件;✅ V2-P2-13b支持优先级 Badge 显示和归档操作;✅ V2-P2-13c支持按类型筛选 + 空状态区分 |
| `components/notification-dropdown.tsx` | ~180 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3改用 SSE 实时推送 + 轮询降级;✅ V2-P2-13c集成 useDesktopNotifications 桌面推送 |
| `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增SSE 实时推送 HookEventSource + 轮询降级) |
| `hooks/use-desktop-notifications.ts` | ~100 | ✅ V2-P2-13c 新增:浏览器桌面推送 HookNotification API + 权限管理 + 点击跳转) |
**组件清单**
| 组件 | 职责 |
|------|------|
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b支持优先级 Badge 显示和归档操作) |
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1useTranslations 命名空间切换;✅ V2-P2-1POLL_INTERVAL_MS 常量) |
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b支持优先级 Badge 显示和归档操作;✅ V2-P2-13c支持按类型筛选 + 空状态区分"无通知"/"无筛选结果" |
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1useTranslations 命名空间切换;✅ V2-P2-1POLL_INTERVAL_MS 常量;✅ V2-P2-13c集成 useDesktopNotifications 桌面推送,监听新通知并触发桌面提醒 |
**客户端行为**
- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒)
- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒);✅ V2-P2-13c 集成 `useDesktopNotifications` Hook通过 `prevNotificationIds` 对比检测新通知并触发桌面推送(首次加载不批量推送)
- `hooks/use-notification-stream.ts`:✅ V2-P3 新增,管理 SSE 连接生命周期EventSource onopen/onmessage/onerror降级时通过 `pollFnRef` 调用 Server Actions 轮询
- `hooks/use-desktop-notifications.ts`:✅ V2-P2-13c 新增,封装浏览器 Notification API权限管理、自动请求权限、桌面推送、点击跳转回调SSR 安全(`typeof window !== "undefined"` 检查)
---
@@ -1243,8 +1247,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:公告 CRUD + 发布/归档 + 发布通知。
**导出函数**
- Actions`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复actions 层不再直接访问 DB全部下沉到 data-access✅ 发布公告时触发通知模块 `sendBatchNotifications`
- Data-access`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数,页面层仅调用单一函数)
- Actions`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复actions 层不再直接访问 DB全部下沉到 data-access✅ 发布公告时触发通知模块 `sendBatchNotifications`/ `toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`(✅ V2-P2-13d 新增:置顶切换、已读标记、批量已读状态查询)
- Data-access`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数,页面层仅调用单一函数)/ `toggleAnnouncementPin` / `markAnnouncementAsRead` / `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`(✅ V2-P2-13d 新增:置顶切换 + 已读回执 CRUD操作 `announcement_reads` 表)
**依赖关系**
- 依赖:`shared/*``@/auth``school`(获取年级列表)、`classes`(获取班级列表 + 解析受众)、`users`(获取目标用户 ID 列表)、`notifications`(发布公告时发送通知)
@@ -1267,14 +1271,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ V2-P0-2 已修复:~~通知标题硬编码~~ `createAnnouncementAction` / `updateAnnouncementAction` / `publishAnnouncementAction` 通过 `getTranslations('announcements')` 生成 i18n 通知标题(`notification.publishedTitle` / `publishedContent`
- ✅ V2-P1-1 已修复:~~AnnouncementList 客户端 useState/useMemo 过滤~~ 改为纯服务端过滤模式Select 切换仅更新 URL `?status=` 触发 RSC 重新渲染
- ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `announcement-form.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示title/content/targetGradeId/targetClassId
- ✅ V2-P2-13d 已实现:公告置顶 + 已读回执。`announcements` 表新增 `isPinned` 字段(置顶优先排序,`getAnnouncements` orderBy 改为 `desc(isPinned), desc(createdAt)`);新增 `announcement_reads` 表(唯一索引 announcementId+userId 保证幂等);新增 3 个 Server Action`toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`+ 5 个 data-access 函数(`toggleAnnouncementPin` / `markAnnouncementAsRead` / `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser``Announcement` 类型新增 `isPinned` / `isReadByCurrentUser?` / `readCount?` 字段;新增 `AnnouncementRead` 类型;埋点新增 `announcement.pin_toggled` / `announcement.marked_read`
**文件清单**
| 文件 | 行数 | 职责 |
|------|------|------|
| `actions.ts` | ~330 | 6 个 Server Action + 通知触发逻辑 + trackEvent 埋点P1-2 已修复,无直接 DB 操作) |
| `data-access.ts` | ~230 | 公告 CRUD + 发布/归档 + 受众过滤 + `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData` 编排函数(✅ P1-5 新增) |
| `actions.ts` | ~400 | 9 个 Server Action + 通知触发逻辑 + trackEvent 埋点P1-2 已修复,无直接 DB 操作V2-P2-13d 新增置顶/已读/批量已读状态 3 个 Action |
| `data-access.ts` | ~300 | 公告 CRUD + 发布/归档 + 受众过滤 + `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData` 编排函数(✅ P1-5 新增V2-P2-13d 新增置顶切换 + 已读回执 5 个函数,操作 `announcement_reads` |
| `schema.ts` | ~70 | Zod 校验 + `refineAudience` 条件校验(✅ P1-6 新增 superRefine |
| `types.ts` | ~65 | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段) |
| `types.ts` | ~75 | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段V2-P2-13d `Announcement` 新增 `isPinned` / `isReadByCurrentUser?` / `readCount?`,新增 `AnnouncementRead` 接口 |
**组件清单**
| 组件 | 职责 |

View File

@@ -8408,6 +8408,45 @@
"usedBy": [
"待扩展"
]
},
{
"name": "toggleAnnouncementPinAction",
"permission": "ANNOUNCEMENT_MANAGE",
"signature": "(id: string) => Promise<ActionState<string>>",
"purpose": "V2-P2-13d 新增:切换公告置顶状态(置顶公告在列表中优先显示)",
"deps": [
"requirePermission",
"data-access.toggleAnnouncementPin"
],
"usedBy": [
"待扩展"
]
},
{
"name": "markAnnouncementAsReadAction",
"permission": "ANNOUNCEMENT_READ",
"signature": "(announcementId: string) => Promise<ActionState<string>>",
"purpose": "V2-P2-13d 新增:标记公告为已读(当前用户维度,幂等)",
"deps": [
"requirePermission",
"data-access.markAnnouncementAsRead"
],
"usedBy": [
"待扩展"
]
},
{
"name": "getAnnouncementReadStatusAction",
"permission": "ANNOUNCEMENT_READ",
"signature": "(announcementIds: string[]) => Promise<ActionState<Record<string, boolean>>>",
"purpose": "V2-P2-13d 新增:批量获取当前用户对多个公告的已读状态(用于列表页标记已读/未读)",
"deps": [
"requirePermission",
"data-access.getAnnouncementReadStatusForUser"
],
"usedBy": [
"待扩展"
]
}
],
"dataAccess": [
@@ -8505,6 +8544,71 @@
"usedBy": [
"archiveAnnouncementAction"
]
},
{
"name": "toggleAnnouncementPin",
"signature": "(id: string) => Promise<void>",
"file": "data-access.ts",
"purpose": "V2-P2-13d 新增:切换公告置顶状态(查询当前 isPinned 后取反更新)",
"deps": [
"shared.db",
"shared.db.schema.announcements"
],
"usedBy": [
"toggleAnnouncementPinAction"
]
},
{
"name": "markAnnouncementAsRead",
"signature": "(announcementId: string, userId: string) => Promise<void>",
"file": "data-access.ts",
"purpose": "V2-P2-13d 新增:标记公告为已读(先查后插,依赖唯一索引保证幂等)",
"deps": [
"shared.db",
"shared.db.schema.announcementReads"
],
"usedBy": [
"markAnnouncementAsReadAction"
]
},
{
"name": "isAnnouncementReadByUser",
"signature": "(announcementId: string, userId: string) => Promise<boolean>",
"file": "data-access.ts",
"purpose": "V2-P2-13d 新增:检查用户是否已读指定公告",
"deps": [
"shared.db",
"shared.db.schema.announcementReads"
],
"usedBy": [
"待扩展"
]
},
{
"name": "getAnnouncementReadCount",
"signature": "(announcementId: string) => Promise<number>",
"file": "data-access.ts",
"purpose": "V2-P2-13d 新增:获取公告的已读用户数(管理端统计)",
"deps": [
"shared.db",
"shared.db.schema.announcementReads"
],
"usedBy": [
"待扩展"
]
},
{
"name": "getAnnouncementReadStatusForUser",
"signature": "(announcementIds: string[], userId: string) => Promise<Map<string, boolean>>",
"file": "data-access.ts",
"purpose": "V2-P2-13d 新增:批量获取用户对多个公告的已读状态(列表页标记已读/未读)",
"deps": [
"shared.db",
"shared.db.schema.announcementReads"
],
"usedBy": [
"getAnnouncementReadStatusAction"
]
}
],
"schemas": [
@@ -8532,7 +8636,7 @@
"name": "Announcement",
"type": "interface",
"file": "types.ts",
"definition": "{ id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, createdAt, updatedAt }",
"definition": "{ id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, isPinned, isReadByCurrentUser?, readCount?, createdAt, updatedAt }",
"usedBy": [
"announcements/components",
"页面"
@@ -8576,6 +8680,15 @@
"getAnnouncements",
"getAnnouncementsAction"
]
},
{
"name": "AnnouncementRead",
"type": "interface",
"file": "types.ts",
"definition": "{ id, announcementId, userId, readAt }",
"usedBy": [
"data-access.announcement_reads 相关函数"
]
}
],
"components": [
@@ -10820,6 +10933,64 @@
"unread-message-badge.tsx"
]
},
{
"name": "toggleMessageStarAction",
"permission": "MESSAGE_READ",
"signature": "(messageId: string) => Promise<ActionState<string>>",
"purpose": "V2-P2-13c 新增切换消息星标状态仅接收方可标记trackEvent 埋点 message.star_toggled",
"deps": [
"requirePermission",
"schema.MessageIdSchema",
"data-access.toggleMessageStar",
"trackEvent",
"revalidatePath"
],
"usedBy": [
"message-detail.tsx",
"message-list.tsx"
]
},
{
"name": "getMessageDraftsAction",
"permission": "MESSAGE_SEND",
"signature": "() => Promise<ActionState<MessageDraft[]>>",
"purpose": "V2-P2-13c 新增:获取当前用户的消息草稿列表(按 updatedAt 倒序)",
"deps": [
"requirePermission",
"data-access.getMessageDrafts"
],
"usedBy": [
"messages/compose/page.tsx"
]
},
{
"name": "saveMessageDraftAction",
"permission": "MESSAGE_SEND",
"signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>",
"purpose": "V2-P2-13c 新增保存消息草稿FormData 含 draftId 时更新,否则创建新草稿;返回草稿 ID",
"deps": [
"requirePermission",
"data-access.createMessageDraft",
"data-access.updateMessageDraft"
],
"usedBy": [
"messages/compose/page.tsx"
]
},
{
"name": "deleteMessageDraftAction",
"permission": "MESSAGE_SEND",
"signature": "(draftId: string) => Promise<ActionState<string>>",
"purpose": "V2-P2-13c 新增删除消息草稿仅草稿所有者可删revalidatePath /messages/compose",
"deps": [
"requirePermission",
"data-access.deleteMessageDraft",
"revalidatePath"
],
"usedBy": [
"messages/compose/page.tsx"
]
},
{
"name": "getNotificationPreferencesAction",
"permission": "MESSAGE_READ",
@@ -10929,6 +11100,19 @@
"deleteMessageAction"
]
},
{
"name": "toggleMessageStar",
"signature": "(id: string, userId: string) => Promise<void>",
"file": "data-access.ts",
"purpose": "V2-P2-13c 新增:切换消息星标状态(仅接收方可标记;查询当前 isStarred 后取反更新)",
"deps": [
"shared.db",
"shared.db.schema.messages"
],
"usedBy": [
"toggleMessageStarAction"
]
},
{
"name": "getUnreadMessageCount",
"signature": "(userId: string) => Promise<number>",
@@ -10987,6 +11171,74 @@
"getRecipientsAction",
"messages/compose/page.tsx"
]
},
{
"name": "getMessageDrafts",
"signature": "(userId: string) => Promise<MessageDraft[]>",
"file": "data-access.ts",
"purpose": "V2-P2-13c 新增:获取用户消息草稿列表(按 updatedAt 倒序React cache 包装;批量解析 receiverName",
"deps": [
"shared.db",
"shared.db.schema.messageDrafts",
"shared.db.schema.users"
],
"usedBy": [
"getMessageDraftsAction"
]
},
{
"name": "createMessageDraft",
"signature": "(data: CreateMessageDraftInput) => Promise<string>",
"file": "data-access.ts",
"purpose": "V2-P2-13c 新增创建消息草稿cuid2 生成 ID返回新草稿 ID",
"deps": [
"shared.db",
"shared.db.schema.messageDrafts",
"@paralleldrive/cuid2"
],
"usedBy": [
"saveMessageDraftAction"
]
},
{
"name": "updateMessageDraft",
"signature": "(id: string, userId: string, data: UpdateMessageDraftInput) => Promise<void>",
"file": "data-access.ts",
"purpose": "V2-P2-13c 新增:更新消息草稿(仅 owner 可改未提供字段保留原值updatedAt 由 onUpdateNow 自动更新)",
"deps": [
"shared.db",
"shared.db.schema.messageDrafts"
],
"usedBy": [
"saveMessageDraftAction"
]
},
{
"name": "deleteMessageDraft",
"signature": "(id: string, userId: string) => Promise<void>",
"file": "data-access.ts",
"purpose": "V2-P2-13c 新增:删除消息草稿(仅 owner 可删)",
"deps": [
"shared.db",
"shared.db.schema.messageDrafts"
],
"usedBy": [
"deleteMessageDraftAction"
]
},
{
"name": "getMessageDraftById",
"signature": "(id: string, userId: string) => Promise<MessageDraft | null>",
"file": "data-access.ts",
"purpose": "V2-P2-13c 新增:按 ID 获取单条消息草稿(仅 owner 可读;解析 receiverName",
"deps": [
"shared.db",
"shared.db.schema.messageDrafts",
"shared.db.schema.users"
],
"usedBy": [
"messages/compose/page.tsx"
]
}
],
"notificationPreferences": [
@@ -11032,7 +11284,7 @@
"name": "Message",
"type": "type",
"file": "types.ts",
"definition": "{ id, senderId, receiverId, subject: string | null, content, isRead, readAt: string | null, parentMessageId: string | null, createdAt, senderName, receiverName }",
"definition": "{ id, senderId, receiverId, subject: string | null, content, isRead, isStarred, readAt: string | null, parentMessageId: string | null, createdAt, senderName, receiverName }",
"usedBy": [
"messaging/components",
"页面"
@@ -11121,6 +11373,36 @@
"compose 页面下拉选项"
]
},
{
"name": "MessageDraft",
"type": "type",
"file": "types.ts",
"definition": "V2-P2-13c 新增:{ id, userId, receiverId: string | null, receiverName: string | null, subject: string | null, content: string | null, parentMessageId: string | null, createdAt, updatedAt }",
"usedBy": [
"getMessageDrafts",
"getMessageDraftById",
"getMessageDraftsAction",
"messages/compose/page.tsx"
]
},
{
"name": "CreateMessageDraftInput",
"type": "type",
"file": "types.ts",
"definition": "V2-P2-13c 新增:{ userId, receiverId?, subject?, content?, parentMessageId? }",
"usedBy": [
"createMessageDraft"
]
},
{
"name": "UpdateMessageDraftInput",
"type": "type",
"file": "types.ts",
"definition": "V2-P2-13c 新增:{ receiverId?, subject?, content?, parentMessageId? }(未提供字段保留原值)",
"usedBy": [
"updateMessageDraft"
]
},
{
"name": "PaginatedResult",
"type": "type",
@@ -11705,12 +11987,30 @@
{
"name": "NotificationList",
"file": "components/notification-list.tsx",
"purpose": "P1-4 新增(从 messaging 迁移通知完整列表全部标记已读、单条标记已读、查看链接V2-P0-1 优化useTranslations 命名空间从 'messages' 切换到独立的 'notifications'"
"purpose": "P1-4 新增(从 messaging 迁移通知完整列表全部标记已读、单条标记已读、查看链接V2-P0-1 优化useTranslations 命名空间从 'messages' 切换到独立的 'notifications'V2-P2-13b支持优先级 Badge 显示和归档操作V2-P2-13c支持按类型筛选all/message/announcement/homework/grade+ 空状态区分'无通知'/'无筛选结果'"
},
{
"name": "NotificationDropdown",
"file": "components/notification-dropdown.tsx",
"purpose": "P1-4 新增(从 messaging 迁移SiteHeader 通知下拉菜单Bell 图标 + 未读数 Badge每 30 秒轮询滚动列表标记已读查看全部链接V2-P0-1 优化useTranslations 命名空间从 'messages' 切换到独立的 'notifications'V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量"
"purpose": "P1-4 新增(从 messaging 迁移SiteHeader 通知下拉菜单Bell 图标 + 未读数 Badge每 30 秒轮询滚动列表标记已读查看全部链接V2-P0-1 优化useTranslations 命名空间从 'messages' 切换到独立的 'notifications'V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量V2-P3改用 SSE 实时推送 + 轮询降级V2-P2-13c集成 useDesktopNotifications 桌面推送,监听新通知并触发桌面提醒(首次加载不批量推送)"
}
],
"hooks": [
{
"name": "useNotificationStream",
"file": "hooks/use-notification-stream.ts",
"purpose": "V2-P3 新增SSE 实时推送 HookEventSource + 轮询降级),管理 SSE 连接生命周期,降级时通过 pollFnRef 调用 Server Actions 轮询",
"usedBy": [
"components/notification-dropdown.tsx"
]
},
{
"name": "useDesktopNotifications",
"file": "hooks/use-desktop-notifications.ts",
"purpose": "V2-P2-13c 新增:浏览器桌面推送 HookNotification API支持权限管理、自动请求权限、新通知桌面推送、点击跳转回调SSR 安全typeof window !== 'undefined' 检查)",
"usedBy": [
"components/notification-dropdown.tsx"
]
}
]
}
@@ -14956,7 +15256,7 @@
},
"dbTables": {
"_meta": {
"total": 59,
"total": 60,
"orm": "Drizzle ORM 0.45",
"database": "MySQL",
"idStrategy": "CUID2 (varchar length 128)",
@@ -15152,7 +15452,11 @@
"tables": {
"announcements": {
"owner": "announcements",
"description": "公告(school/grade/class,draft/published/archived)"
"description": "公告(school/grade/class,draft/published/archived;V2-P2-13d 新增 isPinned 置顶字段)"
},
"announcementReads": {
"owner": "announcements",
"description": "公告已读回执(V2-P2-13d 新增,唯一索引 announcementId+userId 保证幂等,cascade 删除)"
}
}
},
@@ -15209,7 +15513,11 @@
"tables": {
"messages": {
"owner": "messaging",
"description": "站内消息(含回复链 parentMessageId软删除 senderDeletedAt/receiverDeletedAt)"
"description": "站内消息(含回复链 parentMessageId软删除 senderDeletedAt/receiverDeletedAtV2-P2-13c 新增 isStarred 星标字段 + messages_receiver_starred_idx 复合索引)"
},
"messageDrafts": {
"owner": "messaging",
"description": "V2-P2-13c 新增:消息草稿(userId/receiverId/subject/content/parentMessageId + updatedAt/createdAt含 message_drafts_user_idx 和 message_drafts_user_updated_idx 索引)"
},
"messageNotifications": {
"owner": "notifications",
@@ -15841,6 +16149,7 @@
"auth-guard.requirePermission",
"auth-guard.requireAuth",
"db.schema.announcements",
"db.schema.announcementReads",
"types.permissions"
],
"auth": [

View File

@@ -0,0 +1,15 @@
CREATE TABLE `notification_logs` (
`id` varchar(128) PRIMARY KEY NOT NULL,
`user_id` varchar(128) NOT NULL,
`title` varchar(255) NOT NULL,
`channel` varchar(32) NOT NULL,
`status` varchar(16) NOT NULL,
`message_id` varchar(255),
`error` text,
`sent_at` timestamp DEFAULT (now()) NOT NULL,
CONSTRAINT `notification_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
);
CREATE INDEX `notification_logs_user_idx` ON `notification_logs`(`user_id`);
CREATE INDEX `notification_logs_channel_idx` ON `notification_logs`(`channel`);
CREATE INDEX `notification_logs_status_idx` ON `notification_logs`(`status`);
CREATE INDEX `notification_logs_sent_at_idx` ON `notification_logs`(`sent_at`);

View File

@@ -0,0 +1,4 @@
ALTER TABLE `message_notifications` ADD COLUMN `priority` varchar(16) DEFAULT 'normal' NOT NULL;
ALTER TABLE `message_notifications` ADD COLUMN `is_archived` boolean DEFAULT false NOT NULL;
CREATE INDEX `message_notifications_priority_idx` ON `message_notifications`(`priority`);
CREATE INDEX `message_notifications_user_archived_idx` ON `message_notifications`(`user_id`, `is_archived`);

View File

@@ -0,0 +1,17 @@
ALTER TABLE `messages` ADD COLUMN `is_starred` boolean DEFAULT false NOT NULL;
CREATE INDEX `messages_receiver_starred_idx` ON `messages`(`receiver_id`, `is_starred`);
CREATE TABLE `message_drafts` (
`id` varchar(128) PRIMARY KEY NOT NULL,
`user_id` varchar(128) NOT NULL,
`receiver_id` varchar(128),
`subject` varchar(255),
`content` text,
`parent_message_id` varchar(128),
`updated_at` timestamp DEFAULT (now()) ON UPDATE now() NOT NULL,
`created_at` timestamp DEFAULT (now()) NOT NULL,
CONSTRAINT `message_drafts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action,
CONSTRAINT `message_drafts_receiver_id_users_id_fk` FOREIGN KEY (`receiver_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
);
CREATE INDEX `message_drafts_user_idx` ON `message_drafts`(`user_id`);
CREATE INDEX `message_drafts_user_updated_idx` ON `message_drafts`(`user_id`, `updated_at`);

View File

@@ -0,0 +1,14 @@
ALTER TABLE `announcements` ADD COLUMN `is_pinned` boolean DEFAULT false NOT NULL;
CREATE INDEX `announcements_status_pinned_idx` ON `announcements`(`status`, `is_pinned`);
CREATE TABLE `announcement_reads` (
`id` varchar(128) PRIMARY KEY NOT NULL,
`announcement_id` varchar(128) NOT NULL,
`user_id` varchar(128) NOT NULL,
`read_at` timestamp DEFAULT (now()) NOT NULL,
CONSTRAINT `announcement_reads_announcement_id_announcements_id_fk` FOREIGN KEY (`announcement_id`) REFERENCES `announcements`(`id`) ON DELETE cascade ON UPDATE no action,
CONSTRAINT `announcement_reads_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
);
CREATE INDEX `announcement_reads_announcement_idx` ON `announcement_reads`(`announcement_id`);
CREATE INDEX `announcement_reads_user_idx` ON `announcement_reads`(`user_id`);
CREATE UNIQUE INDEX `announcement_reads_unique_idx` ON `announcement_reads`(`announcement_id`, `user_id`);

View File

@@ -0,0 +1,118 @@
import { NextRequest } from "next/server"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getUnreadNotificationCount, getNotifications } from "@/modules/notifications/data-access"
/**
* 通知实时推送 SSE 端点
*
* 使用 Server-Sent Events 向客户端推送通知更新,
* 替代原有的 30/60 秒轮询模式,降低延迟和服务器压力。
*
* 推送策略:
* - 连接建立时立即推送一次当前状态(未读数 + 最新通知列表)
* - 之后每 15 秒推送一次更新(作为 SSE 心跳 + 数据刷新)
* - 客户端断开连接时清理定时器
*
* 安全:
* - requirePermission(MESSAGE_READ) 权限校验
* - 连接超时 5 分钟自动关闭(防止僵尸连接)
*/
const FORMAT_EVENT = (data: unknown): string => `data: ${JSON.stringify(data)}\n\n`
const FORMAT_DONE = "data: [DONE]\n\n"
/** SSE 推送间隔(毫秒) */
const SSE_PUSH_INTERVAL_MS = 15_000
/** SSE 连接最大存活时间(毫秒) */
const SSE_MAX_LIFETIME_MS = 5 * 60_000
export async function GET(_request: NextRequest): Promise<Response> {
const encoder = new TextEncoder()
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
let intervalTimer: ReturnType<typeof setInterval> | null = null
let lifetimeTimer: ReturnType<typeof setTimeout> | null = null
let closed = false
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const safeEnqueue = (data: string): void => {
if (closed) return
try {
controller.enqueue(encoder.encode(data))
} catch {
closed = true
}
}
const pushUpdate = async (): Promise<void> => {
if (closed) return
try {
const [unreadCount, notificationsResult] = await Promise.all([
getUnreadNotificationCount(ctx.userId),
getNotifications(ctx.userId, { page: 1, pageSize: 10 }),
])
safeEnqueue(FORMAT_EVENT({
type: "update",
unreadCount,
notifications: notificationsResult.items,
}))
} catch {
// 查询失败不关闭连接,等待下次重试
}
}
// 连接建立时立即推送一次
await pushUpdate()
// 定时推送
intervalTimer = setInterval(() => {
void pushUpdate()
}, SSE_PUSH_INTERVAL_MS)
// 连接超时清理5 分钟后自动关闭)
lifetimeTimer = setTimeout(() => {
safeEnqueue(FORMAT_DONE)
if (intervalTimer) clearInterval(intervalTimer)
if (!closed) {
closed = true
try {
controller.close()
} catch {
// 已关闭
}
}
}, SSE_MAX_LIFETIME_MS)
},
cancel() {
if (intervalTimer) clearInterval(intervalTimer)
if (lifetimeTimer) clearTimeout(lifetimeTimer)
closed = true
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
})
} catch (error) {
if (error instanceof PermissionDeniedError) {
return new Response(FORMAT_EVENT({ type: "error", message: "Permission denied" }), {
status: 403,
headers: { "Content-Type": "text/event-stream" },
})
}
return new Response(FORMAT_EVENT({ type: "error", message: "Internal error" }), {
status: 500,
headers: { "Content-Type": "text/event-stream" },
})
}
}

View File

@@ -25,6 +25,9 @@ import {
deleteAnnouncementById,
publishAnnouncementById,
archiveAnnouncementById,
toggleAnnouncementPin,
markAnnouncementAsRead,
getAnnouncementReadStatusForUser,
} from "./data-access"
import type { GetAnnouncementsParams, Announcement } from "./types"
@@ -325,6 +328,68 @@ export async function archiveAnnouncementAction(id: string): Promise<ActionState
}
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告置顶 Server Action
// ---------------------------------------------------------------------------
export async function toggleAnnouncementPinAction(id: string): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
await toggleAnnouncementPin(id)
revalidatePath("/admin/announcements")
revalidatePath("/announcements")
void trackEvent({
event: "announcement.pin_toggled",
targetId: id,
targetType: "announcement",
})
return { success: true, message: "Pin status toggled" }
} catch (e) {
return handleActionError(e)
}
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告已读标记 Server Action
// ---------------------------------------------------------------------------
export async function markAnnouncementAsReadAction(announcementId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
await markAnnouncementAsRead(announcementId, ctx.userId)
void trackEvent({
event: "announcement.marked_read",
userId: ctx.userId,
targetId: announcementId,
targetType: "announcement",
})
return { success: true, message: "Announcement marked as read" }
} catch (e) {
return handleActionError(e)
}
}
/**
* 批量获取公告已读状态(用于列表页标记已读/未读)。
*/
export async function getAnnouncementReadStatusAction(
announcementIds: string[]
): Promise<ActionState<Record<string, boolean>>> {
try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
const statusMap = await getAnnouncementReadStatusForUser(announcementIds, ctx.userId)
return { success: true, data: Object.fromEntries(statusMap) }
} catch (e) {
return handleActionError(e)
}
}
export async function getAnnouncementsAction(
params?: GetAnnouncementsParams
): Promise<ActionState<Announcement[]>> {

View File

@@ -51,7 +51,7 @@ export function AdminAnnouncementsView({
announcements={announcements}
canManage
initialStatus={initialStatus}
detailHrefBuilder={(id) => `/admin/announcements/${id}`}
detailHrefPrefix="/admin/announcements"
/>
<Dialog open={createOpen} onOpenChange={handleOpenChange}>

View File

@@ -27,18 +27,24 @@ type Filter = "all" | AnnouncementStatus
* - Select 切换时更新 URL `?status=`,触发 RSC 重新渲染
* - 父页面根据 `?status=` 查询并传入 `announcements` prop
* - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余
*
* 详情链接构建:
* - `detailHrefPrefix`:推荐方式,适用于 Server Component前缀 + id 拼接)
* - `detailHrefBuilder`:仅适用于 Client Component 之间的调用
*/
export function AnnouncementList({
announcements,
canManage,
createHref,
detailHrefBuilder,
detailHrefPrefix,
initialStatus,
}: {
announcements: Announcement[]
canManage?: boolean
createHref?: string
detailHrefBuilder?: (id: string) => string
detailHrefPrefix?: string
initialStatus?: Filter
}) {
const t = useTranslations("announcements")
@@ -59,6 +65,14 @@ export function AnnouncementList({
router.replace(qs ? `?${qs}` : "?")
}
// 构建详情链接:优先使用 detailHrefPrefixServer Component 安全),
// 其次使用 detailHrefBuilder仅 Client Component 间调用)
const buildDetailHref = (id: string): string | undefined => {
if (detailHrefPrefix) return `${detailHrefPrefix}/${id}`
if (detailHrefBuilder) return detailHrefBuilder(id)
return undefined
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -101,7 +115,7 @@ export function AnnouncementList({
<AnnouncementCard
key={a.id}
announcement={a}
href={detailHrefBuilder ? detailHrefBuilder(a.id) : undefined}
href={buildDetailHref(a.id)}
/>
))}
</div>

View File

@@ -1,10 +1,11 @@
import "server-only"
import { cache } from "react"
import { and, desc, eq, or } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import { announcements, users } from "@/shared/db/schema"
import { announcements, announcementReads, users } from "@/shared/db/schema"
import type {
Announcement,
AnnouncementInsertData,
@@ -30,6 +31,7 @@ const mapRow = (
authorId: string
authorName: string | null
publishedAt: Date | null
isPinned: boolean
createdAt: Date
updatedAt: Date
}
@@ -44,6 +46,7 @@ const mapRow = (
authorId: row.authorId,
authorName: row.authorName,
publishedAt: toIso(row.publishedAt),
isPinned: row.isPinned,
createdAt: toIsoRequired(row.createdAt),
updatedAt: toIsoRequired(row.updatedAt),
})
@@ -93,13 +96,14 @@ export const getAnnouncements = cache(
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
isPinned: announcements.isPinned,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
.from(announcements)
.leftJoin(users, eq(users.id, announcements.authorId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(announcements.createdAt))
.orderBy(desc(announcements.isPinned), desc(announcements.createdAt))
.limit(pageSize)
.offset(offset)
@@ -121,6 +125,7 @@ export const getAnnouncementById = cache(
authorId: announcements.authorId,
authorName: users.name,
publishedAt: announcements.publishedAt,
isPinned: announcements.isPinned,
createdAt: announcements.createdAt,
updatedAt: announcements.updatedAt,
})
@@ -197,6 +202,94 @@ export async function archiveAnnouncementById(id: string): Promise<void> {
.where(eq(announcements.id, id))
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告置顶
// ---------------------------------------------------------------------------
export async function toggleAnnouncementPin(id: string): Promise<void> {
// 查询当前置顶状态
const [row] = await db
.select({ isPinned: announcements.isPinned })
.from(announcements)
.where(eq(announcements.id, id))
.limit(1)
if (!row) return
await db
.update(announcements)
.set({ isPinned: !row.isPinned, updatedAt: new Date() })
.where(eq(announcements.id, id))
}
// ---------------------------------------------------------------------------
// V2-P2-13d: 公告已读回执announcement_reads 表)
// ---------------------------------------------------------------------------
/**
* 标记公告为已读(如果尚未标记)。
* 使用 INSERT IGNORE 语义避免重复插入(依赖唯一索引)。
*/
export async function markAnnouncementAsRead(announcementId: string, userId: string): Promise<void> {
// 先检查是否已存在已读记录
const [existing] = await db
.select({ id: announcementReads.id })
.from(announcementReads)
.where(and(eq(announcementReads.announcementId, announcementId), eq(announcementReads.userId, userId)))
.limit(1)
if (existing) return
const id = createId()
await db.insert(announcementReads).values({
id,
announcementId,
userId,
})
}
/**
* 检查用户是否已读指定公告。
*/
export async function isAnnouncementReadByUser(announcementId: string, userId: string): Promise<boolean> {
const [row] = await db
.select({ id: announcementReads.id })
.from(announcementReads)
.where(and(eq(announcementReads.announcementId, announcementId), eq(announcementReads.userId, userId)))
.limit(1)
return !!row
}
/**
* 获取公告的已读用户数。
*/
export async function getAnnouncementReadCount(announcementId: string): Promise<number> {
const [row] = await db
.select({ value: count() })
.from(announcementReads)
.where(eq(announcementReads.announcementId, announcementId))
return Number(row?.value ?? 0)
}
/**
* 批量获取用户对多个公告的已读状态。
* 返回 Map<announcementId, boolean>
*/
export async function getAnnouncementReadStatusForUser(
announcementIds: string[],
userId: string
): Promise<Map<string, boolean>> {
if (announcementIds.length === 0) return new Map()
const rows = await db
.select({ announcementId: announcementReads.announcementId })
.from(announcementReads)
.where(and(eq(announcementReads.userId, userId), inArray(announcementReads.announcementId, announcementIds)))
const readSet = new Set(rows.map((r) => r.announcementId))
return new Map(announcementIds.map((id) => [id, readSet.has(id)]))
}
/**
* 管理端公告列表页编排函数:一次性获取公告列表、年级列表、班级列表。
* 将原本散落在 page.tsx 中的多模块编排逻辑下沉到 data-access 层,

View File

@@ -13,6 +13,11 @@ export interface Announcement {
authorId: string
authorName: string | null
publishedAt: string | null
isPinned: boolean
/** V2-P2-13d: 当前用户是否已读(仅用户端查询时填充) */
isReadByCurrentUser?: boolean
/** V2-P2-13d: 已读人数(仅管理端查询时填充) */
readCount?: number
createdAt: string
updatedAt: string
}
@@ -59,3 +64,11 @@ export interface AnnouncementUpdateData {
publishedAt: Date | null
updatedAt: Date
}
/** V2-P2-13d: 公告已读回执 */
export interface AnnouncementRead {
id: string
announcementId: string
userId: string
readAt: string
}

View File

@@ -21,6 +21,11 @@ import {
deleteMessage,
getRecipients,
getUnreadMessageCount,
toggleMessageStar,
getMessageDrafts,
createMessageDraft,
updateMessageDraft,
deleteMessageDraft,
} from "./data-access"
import {
getNotificationPreferences,
@@ -216,6 +221,110 @@ export async function getUnreadMessageCountAction(): Promise<ActionState<number>
}
}
// ---------------------------------------------------------------------------
// V2-P2-13c: 消息星标 Server Actions
// ---------------------------------------------------------------------------
export async function toggleMessageStarAction(messageId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
await toggleMessageStar(parsed.data.messageId, ctx.userId)
revalidatePath("/messages")
revalidatePath(`/messages/${parsed.data.messageId}`)
void trackEvent({
event: "message.star_toggled",
userId: ctx.userId,
targetId: parsed.data.messageId,
targetType: "message",
})
return { success: true, message: "Star toggled" }
} 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" }
}
}
// ---------------------------------------------------------------------------
// V2-P2-13c: 消息草稿 Server Actions
// ---------------------------------------------------------------------------
export async function getMessageDraftsAction(): Promise<ActionState<import("./types").MessageDraft[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const drafts = await getMessageDrafts(ctx.userId)
return { success: true, data: drafts }
} 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 saveMessageDraftAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const draftId = formData.get("draftId") as string | null
const receiverId = formData.get("receiverId") as string | null
const subject = formData.get("subject") as string | null
const content = formData.get("content") as string | null
const parentMessageId = formData.get("parentMessageId") as string | null
if (draftId) {
// 更新现有草稿
await updateMessageDraft(draftId, ctx.userId, {
receiverId: receiverId || null,
subject: subject || null,
content: content || null,
parentMessageId: parentMessageId || null,
})
return { success: true, message: "Draft saved", data: draftId }
}
// 创建新草稿
const id = await createMessageDraft({
userId: ctx.userId,
receiverId: receiverId || null,
subject: subject || null,
content: content || null,
parentMessageId: parentMessageId || null,
})
return { success: true, message: "Draft created", data: id }
} 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 deleteMessageDraftAction(draftId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
await deleteMessageDraft(draftId, ctx.userId)
revalidatePath("/messages/compose")
return { success: true, message: "Draft deleted" }
} 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" }
}
}
// ---------------------------------------------------------------------------
// 通知相关 Server ActionsgetNotificationsAction / markNotificationAsReadAction /
// markAllNotificationsAsReadAction / getUnreadNotificationCountAction已迁移至

View File

@@ -6,15 +6,18 @@ import { Badge } from "@/shared/components/ui/badge"
import { getUnreadMessageCountAction } from "../actions"
/** 未读消息计数轮询间隔(毫秒) */
const POLL_INTERVAL_MS = 60_000
/**
* 未读消息计数徽章
*
* 在侧边栏 Messages 导航项旁显示未读私信数。
* 每 POLL_INTERVAL_MS 毫秒轮询一次以保持计数更新。
*
* 注意:当前 SSE 端点(/api/notifications/stream仅推送通知数据
* 不包含私信未读数,因此本组件仍使用轮询模式。轮询间隔与通知组件
* 保持一致30 秒),未来可扩展 SSE 端点同时推送消息未读数以实现实时更新。
*/
const POLL_INTERVAL_MS = 30_000
export function UnreadMessageBadge() {
const [count, setCount] = useState(0)

View File

@@ -21,6 +21,7 @@ import { and, count, desc, eq, inArray, isNull, like, or, type SQL } from "drizz
import { db } from "@/shared/db"
import {
messages,
messageDrafts,
users,
} from "@/shared/db/schema"
import {
@@ -36,6 +37,9 @@ import type {
GetMessagesParams,
CreateMessageInput,
RecipientOption,
MessageDraft,
CreateMessageDraftInput,
UpdateMessageDraftInput,
} from "./types"
import type { PaginatedResult } from "@/modules/notifications/types"
@@ -50,6 +54,7 @@ interface MessageRow {
subject: string | null
content: string
isRead: boolean
isStarred: boolean
readAt: Date | null
parentMessageId: string | null
createdAt: Date
@@ -74,6 +79,7 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
subject: r.subject,
content: r.content,
isRead: r.isRead,
isStarred: r.isStarred,
readAt: toIso(r.readAt),
parentMessageId: r.parentMessageId,
createdAt: toIsoRequired(r.createdAt),
@@ -108,6 +114,11 @@ export const getMessages = cache(
if (kwCond) conds.push(kwCond)
}
// V2-P2-13c: 仅返回星标消息
if (params.starredOnly) {
conds.push(eq(messages.isStarred, true))
}
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset),
@@ -193,6 +204,22 @@ export async function deleteMessage(id: string, userId: string): Promise<void> {
})
}
export async function toggleMessageStar(id: string, userId: string): Promise<void> {
// 查询当前星标状态
const [row] = await db
.select({ isStarred: messages.isStarred })
.from(messages)
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
.limit(1)
if (!row) return
await db
.update(messages)
.set({ isStarred: !row.isStarred })
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
}
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
@@ -288,3 +315,90 @@ export async function getMessageDetailPageData(
return message
}
// ---------------------------------------------------------------------------
// V2-P2-13c: 消息草稿 CRUDmessage_drafts 表)
// ---------------------------------------------------------------------------
const mapDraft = (
r: {
id: string
userId: string
receiverId: string | null
subject: string | null
content: string | null
parentMessageId: string | null
createdAt: Date
updatedAt: Date
},
nameMap: Map<string, string>
): MessageDraft => ({
id: r.id,
userId: r.userId,
receiverId: r.receiverId,
receiverName: r.receiverId ? (nameMap.get(r.receiverId) ?? null) : null,
subject: r.subject,
content: r.content,
parentMessageId: r.parentMessageId,
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
})
export const getMessageDrafts = cache(
async (userId: string): Promise<MessageDraft[]> => {
const rows = await db
.select()
.from(messageDrafts)
.where(eq(messageDrafts.userId, userId))
.orderBy(desc(messageDrafts.updatedAt))
const receiverIds = rows.map((r) => r.receiverId).filter((id): id is string => id !== null)
const nameMap = await resolveUserNames(receiverIds)
return rows.map((r) => mapDraft(r, nameMap))
}
)
export async function createMessageDraft(data: CreateMessageDraftInput): Promise<string> {
const id = createId()
await db.insert(messageDrafts).values({
id,
userId: data.userId,
receiverId: data.receiverId ?? null,
subject: data.subject ?? null,
content: data.content ?? null,
parentMessageId: data.parentMessageId ?? null,
})
return id
}
export async function updateMessageDraft(id: string, userId: string, data: UpdateMessageDraftInput): Promise<void> {
await db
.update(messageDrafts)
.set({
...(data.receiverId !== undefined && { receiverId: data.receiverId }),
...(data.subject !== undefined && { subject: data.subject }),
...(data.content !== undefined && { content: data.content }),
...(data.parentMessageId !== undefined && { parentMessageId: data.parentMessageId }),
})
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
}
export async function deleteMessageDraft(id: string, userId: string): Promise<void> {
await db
.delete(messageDrafts)
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
}
export async function getMessageDraftById(id: string, userId: string): Promise<MessageDraft | null> {
const [row] = await db
.select()
.from(messageDrafts)
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
.limit(1)
if (!row) return null
const nameMap = row.receiverId ? await resolveUserNames([row.receiverId]) : new Map<string, string>()
return mapDraft(row, nameMap)
}

View File

@@ -0,0 +1,373 @@
import { describe, expect, it } from "vitest"
import {
SendMessageSchema,
MessageIdSchema,
UpdateNotificationPreferencesSchema,
} from "./schema"
describe("SendMessageSchema", () => {
it("should parse valid input with all fields", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
subject: "Hello",
content: "World",
parentMessageId: "msg-1",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.receiverId).toBe("user-1")
expect(result.data.subject).toBe("Hello")
expect(result.data.content).toBe("World")
expect(result.data.parentMessageId).toBe("msg-1")
}
})
it("should parse valid input with only required fields", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
content: "Hello",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.receiverId).toBe("user-1")
expect(result.data.content).toBe("Hello")
expect(result.data.subject).toBeNull()
expect(result.data.parentMessageId).toBeNull()
}
})
it("should transform empty subject string to null", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
subject: "",
content: "Hello",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.subject).toBeNull()
}
})
it("should transform empty parentMessageId string to null", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
content: "Hello",
parentMessageId: "",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.parentMessageId).toBeNull()
}
})
it("should accept nullable subject", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
subject: null,
content: "Hello",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.subject).toBeNull()
}
})
it("should accept nullable parentMessageId", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
content: "Hello",
parentMessageId: null,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.parentMessageId).toBeNull()
}
})
it("should reject empty receiverId", () => {
const result = SendMessageSchema.safeParse({
receiverId: "",
content: "Hello",
})
expect(result.success).toBe(false)
})
it("should reject whitespace-only receiverId after trim", () => {
const result = SendMessageSchema.safeParse({
receiverId: " ",
content: "Hello",
})
expect(result.success).toBe(false)
})
it("should reject empty content", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
content: "",
})
expect(result.success).toBe(false)
})
it("should reject whitespace-only content after trim", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
content: " ",
})
expect(result.success).toBe(false)
})
it("should reject missing receiverId", () => {
const result = SendMessageSchema.safeParse({
content: "Hello",
})
expect(result.success).toBe(false)
})
it("should reject missing content", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
})
expect(result.success).toBe(false)
})
it("should reject subject exceeding 255 chars", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
subject: "a".repeat(256),
content: "Hello",
})
expect(result.success).toBe(false)
})
it("should accept subject of exactly 255 chars", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
subject: "a".repeat(255),
content: "Hello",
})
expect(result.success).toBe(true)
})
it("should trim whitespace from receiverId", () => {
const result = SendMessageSchema.safeParse({
receiverId: " user-1 ",
content: "Hello",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.receiverId).toBe("user-1")
}
})
it("should trim whitespace from content", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
content: " Hello ",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.content).toBe("Hello")
}
})
it("should trim whitespace from subject", () => {
const result = SendMessageSchema.safeParse({
receiverId: "user-1",
subject: " Hello ",
content: "Hello",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.subject).toBe("Hello")
}
})
})
describe("MessageIdSchema", () => {
it("should parse valid messageId", () => {
const result = MessageIdSchema.safeParse({ messageId: "msg-1" })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.messageId).toBe("msg-1")
}
})
it("should reject empty messageId", () => {
const result = MessageIdSchema.safeParse({ messageId: "" })
expect(result.success).toBe(false)
})
it("should reject whitespace-only messageId after trim", () => {
const result = MessageIdSchema.safeParse({ messageId: " " })
expect(result.success).toBe(false)
})
it("should reject missing messageId", () => {
const result = MessageIdSchema.safeParse({})
expect(result.success).toBe(false)
})
it("should trim whitespace from messageId", () => {
const result = MessageIdSchema.safeParse({ messageId: " msg-1 " })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.messageId).toBe("msg-1")
}
})
})
describe("UpdateNotificationPreferencesSchema", () => {
const validInput = {
emailEnabled: true,
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: false,
quietHoursEnabled: false,
}
it("should parse valid input without quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
expect(result.success).toBe(true)
})
it("should parse valid input with quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "22:00",
quietHoursEnd: "07:00",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.quietHoursStart).toBe("22:00")
expect(result.data.quietHoursEnd).toBe("07:00")
}
})
it("should accept null quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: null,
quietHoursEnd: null,
})
expect(result.success).toBe(true)
})
it("should accept undefined quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.quietHoursStart).toBeUndefined()
expect(result.data.quietHoursEnd).toBeUndefined()
}
})
it("should reject invalid time format for quietHoursStart", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "25:00",
})
expect(result.success).toBe(false)
})
it("should reject invalid time format for quietHoursEnd", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursEnd: "12:60",
})
expect(result.success).toBe(false)
})
it("should reject non-time string for quietHoursStart", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "not-a-time",
})
expect(result.success).toBe(false)
})
it("should accept boundary time 00:00", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "00:00",
})
expect(result.success).toBe(true)
})
it("should accept boundary time 23:59", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursEnd: "23:59",
})
expect(result.success).toBe(true)
})
it("should reject non-boolean emailEnabled", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
emailEnabled: "yes",
})
expect(result.success).toBe(false)
})
it("should reject non-boolean smsEnabled", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
smsEnabled: 1,
})
expect(result.success).toBe(false)
})
it("should reject missing required boolean field", () => {
const inputWithoutEmail = {
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: false,
quietHoursEnabled: false,
}
const result = UpdateNotificationPreferencesSchema.safeParse(inputWithoutEmail)
expect(result.success).toBe(false)
})
})

View File

@@ -17,6 +17,7 @@ export interface Message {
subject: string | null
content: string
isRead: boolean
isStarred: boolean
readAt: string | null
parentMessageId: string | null
createdAt: string
@@ -34,6 +35,8 @@ export interface GetMessagesParams {
page?: number
pageSize?: number
keyword?: string
/** V2-P2-13c: 仅返回星标消息 */
starredOnly?: boolean
}
export interface CreateMessageInput {
@@ -50,3 +53,31 @@ export interface RecipientOption {
email: string
role?: string
}
/** V2-P2-13c: 消息草稿 */
export interface MessageDraft {
id: string
userId: string
receiverId: string | null
receiverName: string | null
subject: string | null
content: string | null
parentMessageId: string | null
createdAt: string
updatedAt: string
}
export interface CreateMessageDraftInput {
userId: string
receiverId?: string | null
subject?: string | null
content?: string | null
parentMessageId?: string | null
}
export interface UpdateMessageDraftInput {
receiverId?: string | null
subject?: string | null
content?: string | null
parentMessageId?: string | null
}

View File

@@ -28,6 +28,7 @@ import {
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
archiveNotification,
} from "./data-access"
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
@@ -265,3 +266,35 @@ export async function markAllNotificationsAsReadAction(): Promise<ActionState<st
return { success: false, message: "Unexpected error" }
}
}
/**
* 将单条通知归档(归档后不在默认列表显示)。
*/
export async function archiveNotificationAction(
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 archiveNotification(parsed.data, ctx.userId)
revalidatePath("/messages")
void trackEvent({
event: "notification.archived",
userId: ctx.userId,
targetId: parsed.data,
targetType: "notification",
})
return { success: true, message: "Notification archived" }
} 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" }
}
}

View File

@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
createNotification: vi.fn(),
}))
vi.mock("../data-access", () => ({
createNotification: mocks.createNotification,
}))
import { createInAppSender } from "./in-app-channel"
import type { NotificationPayload } from "../types"
import type { ChannelRecipient } from "./types"
describe("InAppChannelSender", () => {
beforeEach(() => {
vi.resetAllMocks()
})
const sender = createInAppSender()
const recipient: ChannelRecipient = { userId: "user-1" }
it("should map info type to message notification type", async () => {
mocks.createNotification.mockResolvedValue("notif-1")
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
await sender.send(payload, recipient)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({ type: "message" })
)
})
it("should map success type to message notification type", async () => {
mocks.createNotification.mockResolvedValue("notif-1")
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "success",
}
await sender.send(payload, recipient)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({ type: "message" })
)
})
it("should map warning type to announcement notification type", async () => {
mocks.createNotification.mockResolvedValue("notif-1")
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "warning",
}
await sender.send(payload, recipient)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({ type: "announcement" })
)
})
it("should map error type to grade notification type", async () => {
mocks.createNotification.mockResolvedValue("notif-1")
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "error",
}
await sender.send(payload, recipient)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({ type: "grade" })
)
})
it("should return success result with messageId", async () => {
mocks.createNotification.mockResolvedValue("notif-123")
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const result = await sender.send(payload, recipient)
expect(result.channel).toBe("in_app")
expect(result.success).toBe(true)
expect(result.messageId).toBe("notif-123")
})
it("should return failure when recipient userId does not match payload userId", async () => {
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const wrongRecipient: ChannelRecipient = { userId: "user-2" }
const result = await sender.send(payload, wrongRecipient)
expect(result.success).toBe(false)
expect(result.error).toContain("does not match")
})
it("should return failure when createNotification throws", async () => {
mocks.createNotification.mockRejectedValue(new Error("DB error"))
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const result = await sender.send(payload, recipient)
expect(result.success).toBe(false)
expect(result.error).toBe("DB error")
})
it("should use actionUrl as link when provided", async () => {
mocks.createNotification.mockResolvedValue("notif-1")
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
actionUrl: "/messages/123",
}
await sender.send(payload, recipient)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({ link: "/messages/123" })
)
})
it("should use null as link when actionUrl not provided", async () => {
mocks.createNotification.mockResolvedValue("notif-1")
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
await sender.send(payload, recipient)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({ link: null })
)
})
})

View File

@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
@@ -20,12 +20,12 @@ 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"
import { useDesktopNotifications } from "../hooks/use-desktop-notifications"
import { useNotificationStream } from "../hooks/use-notification-stream"
import type { NotificationType } from "../types"
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
message: MessageSquare,
@@ -34,55 +34,52 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
grade: GraduationCap,
}
/** 通知下拉菜单轮询间隔(毫秒) */
const POLL_INTERVAL_MS = 30_000
/** 轮询降级间隔(毫秒) */
const FALLBACK_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)
// 使用 SSE 实时推送(自动降级为轮询)
const { unreadCount, notifications } = useNotificationStream({
fallbackPollInterval: FALLBACK_POLL_INTERVAL_MS,
})
// 桌面推送通知(用户切换标签页时仍可收到提醒)
const { showNotification } = useDesktopNotifications({ enabled: true })
// 监听新通知并触发桌面推送(使用 ref 避免 setState in effect
const prevNotificationIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
let active = true
if (notifications.length === 0) return
const fetchNotifications = async () => {
const res = await getNotificationsAction({ pageSize: 10 })
if (!active) return
if (res.success && res.data) {
setNotifications(res.data.items)
}
const currentIds = new Set(notifications.map((n) => n.id))
const prevIds = prevNotificationIdsRef.current
// 首次加载不触发桌面推送(避免页面加载时批量推送)
if (prevIds.size === 0) {
prevNotificationIdsRef.current = currentIds
return
}
const fetchUnreadCount = async () => {
const res = await getUnreadNotificationCountAction()
if (!active) return
if (res.success && typeof res.data === "number") {
setUnreadCount(res.data)
}
// 找出新增的通知
const newNotifications = notifications.filter(
(n) => !prevIds.has(n.id) && !n.isRead
)
// 为每个新通知触发桌面推送
for (const n of newNotifications) {
showNotification(n)
}
void fetchNotifications()
void fetchUnreadCount()
// 每 POLL_INTERVAL_MS 毫秒轮询刷新通知和未读计数
const timer = setInterval(() => {
void fetchNotifications()
void fetchUnreadCount()
}, POLL_INTERVAL_MS)
return () => {
active = false
clearInterval(timer)
}
}, [])
prevNotificationIdsRef.current = currentIds
}, [notifications, showNotification])
const handleMarkAllRead = async () => {
const res = await markAllNotificationsAsReadAction()
if (res.success) {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
setUnreadCount(0)
router.refresh()
}
}
@@ -90,10 +87,6 @@ export function NotificationDropdown() {
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()
}
}

View File

@@ -13,8 +13,8 @@ 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"
import { markAllNotificationsAsReadAction, markNotificationAsReadAction, archiveNotificationAction } from "../actions"
import type { Notification, NotificationType, NotificationPriority } from "../types"
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
message: MessageSquare,
@@ -23,12 +23,24 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
grade: GraduationCap,
}
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
low: "bg-muted text-muted-foreground",
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
high: "bg-orange-500/10 text-orange-700 dark:text-orange-400",
urgent: "bg-red-500/10 text-red-700 dark:text-red-400",
}
export function NotificationList({ notifications }: { notifications: Notification[] }) {
const t = useTranslations("notifications")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [filterType, setFilterType] = useState<NotificationType | "all">("all")
const hasUnread = notifications.some((n) => !n.isRead)
const filteredNotifications = filterType === "all"
? notifications
: notifications.filter((n) => n.type === filterType)
const handleMarkAllRead = async () => {
setIsWorking(true)
try {
@@ -57,6 +69,17 @@ export function NotificationList({ notifications }: { notifications: Notificatio
}
}
const handleArchive = async (id: string) => {
try {
const res = await archiveNotificationAction(id)
if (res.success) {
router.refresh()
}
} catch {
toast.error(t("messages.archiveFailed"))
}
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -72,16 +95,36 @@ export function NotificationList({ notifications }: { notifications: Notificatio
) : null}
</div>
{notifications.length === 0 ? (
<div className="flex flex-wrap gap-2">
<Button
variant={filterType === "all" ? "default" : "outline"}
size="sm"
onClick={() => setFilterType("all")}
>
{t("filter.all")}
</Button>
{(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => (
<Button
key={type}
variant={filterType === type ? "default" : "outline"}
size="sm"
onClick={() => setFilterType(type)}
>
{t(`type.${type}`)}
</Button>
))}
</div>
{filteredNotifications.length === 0 ? (
<EmptyState
title={t("empty.noNotifications")}
description={t("empty.noNotificationsDesc")}
title={filterType === "all" ? t("empty.noNotifications") : t("empty.noFilterResults")}
description={filterType === "all" ? t("empty.noNotificationsDesc") : t("empty.noFilterResultsDesc")}
icon={Bell}
className="h-auto border-none shadow-none"
/>
) : (
<div className="space-y-3">
{notifications.map((n) => {
{filteredNotifications.map((n) => {
const Icon = TYPE_ICON[n.type] ?? Bell
return (
<Card
@@ -98,6 +141,11 @@ export function NotificationList({ notifications }: { notifications: Notificatio
{n.title}
</span>
{!n.isRead ? <Badge variant="default" className="text-xs">{t("status.new")}</Badge> : null}
{n.priority !== "normal" ? (
<Badge variant="outline" className={cn("text-xs", PRIORITY_COLOR[n.priority])}>
{t(`priority.${n.priority}`)}
</Badge>
) : null}
</div>
{n.content ? (
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
@@ -119,6 +167,14 @@ export function NotificationList({ notifications }: { notifications: Notificatio
{t("actions.markRead")}
</button>
) : null}
<button
type="button"
onClick={() => handleArchive(n.id)}
className="text-muted-foreground hover:text-foreground hover:underline"
aria-label={t("actions.archive")}
>
{t("actions.archive")}
</button>
{n.link ? (
<Link href={n.link} className="ml-auto text-primary hover:underline">
{t("actions.view")}

View File

@@ -7,7 +7,7 @@ import "server-only"
* - createNotification: 创建站内通知记录message_notifications 表)
* - getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出
* - logNotificationSend: 记录发送日志 notification_logs 表DB 写入失败时降级为 console
*
* 表所有权:
* - message_notifications由 notifications 模块统一管理P0-4 / P1-5 修复后从 messaging 迁移)
@@ -22,13 +22,14 @@ import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { messageNotifications, users } from "@/shared/db/schema"
import { messageNotifications, notificationLogs, users } from "@/shared/db/schema"
import type { ChannelRecipient } from "./channels/types"
import type {
ChannelSendResult,
CreateNotificationInput,
GetNotificationsParams,
Notification,
NotificationPriority,
NotificationType,
PaginatedResult,
} from "./types"
@@ -41,6 +42,12 @@ const isNotificationType = (v: unknown): v is NotificationType =>
const toNotificationType = (v: string): NotificationType =>
isNotificationType(v) ? v : "message"
const isNotificationPriority = (v: unknown): v is NotificationPriority =>
v === "low" || v === "normal" || v === "high" || v === "urgent"
const toNotificationPriority = (v: string): NotificationPriority =>
isNotificationPriority(v) ? v : "normal"
interface NotificationRow {
id: string
userId: string
@@ -49,6 +56,8 @@ interface NotificationRow {
content: string | null
link: string | null
isRead: boolean
priority: string
isArchived: boolean
createdAt: Date
}
@@ -60,6 +69,8 @@ const mapNotification = (r: NotificationRow): Notification => ({
content: r.content,
link: r.link,
isRead: r.isRead,
priority: toNotificationPriority(r.priority),
isArchived: r.isArchived,
createdAt: toIsoRequired(r.createdAt),
})
@@ -74,6 +85,11 @@ export const getNotifications = cache(
const offset = (page - 1) * pageSize
const conds = [eq(messageNotifications.userId, userId)]
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
// V2-P2-13b: 默认仅返回未归档通知
const unarchivedOnly = params?.unarchivedOnly ?? true
if (unarchivedOnly) conds.push(eq(messageNotifications.isArchived, false))
// V2-P2-13b: 按优先级筛选
if (params?.priority) conds.push(eq(messageNotifications.priority, params.priority))
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
@@ -94,6 +110,7 @@ export async function createNotification(data: CreateNotificationInput): Promise
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
priority: data.priority ?? "normal",
})
return id
}
@@ -112,6 +129,20 @@ export async function markAllNotificationsAsRead(userId: string): Promise<void>
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
}
export async function archiveNotification(id: string, userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isArchived: true })
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
}
export async function unarchiveNotification(id: string, userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isArchived: false })
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
}
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
@@ -159,24 +190,54 @@ export const getUserContactInfo = cache(
// ---------------------------------------------------------------------------
/**
* 记录通知发送日志。
* 记录通知发送日志到数据库notification_logs 表)
*
* 当前项目无 notification_logs 表,使用 console.info 输出。
* 未来新增 notification_logs 表后,可在此处写入 DB。
* 持久化日志用于:
* - 通知发送失败告警与排查
* - 通知送达率统计
* - 渠道健康度监控
*
* DB 写入失败时降级为 console.info不阻塞通知发送流程。
*/
export function logNotificationSend(result: ChannelSendResult): void {
const status = result.success ? "OK" : "FAIL"
export async function logNotificationSend(
result: ChannelSendResult,
payload?: { userId: string; title: string }
): Promise<void> {
const status = result.success ? "success" : "failure"
const errorPart = result.error ? ` error="${result.error}"` : ""
// 始终输出 console 日志(便于开发调试)
console.info(
`[NotificationLog] ${status} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
)
// 持久化到 DB需要 payload 提供 userId 和 title
if (payload) {
try {
const logId = createId()
await db.insert(notificationLogs).values({
id: logId,
userId: payload.userId,
title: payload.title,
channel: result.channel,
status,
messageId: result.messageId ?? null,
error: result.error ?? null,
sentAt: result.sentAt,
})
} catch (dbError) {
// DB 写入失败不阻塞通知流程,仅记录错误
console.error("[NotificationLog] Failed to persist log:", dbError)
}
}
}
/**
* 批量记录发送日志。
*/
export function logNotificationSendBatch(results: ChannelSendResult[]): void {
for (const result of results) {
logNotificationSend(result)
}
export async function logNotificationSendBatch(
results: ChannelSendResult[],
payload?: { userId: string; title: string }
): Promise<void> {
await Promise.all(results.map((result) => logNotificationSend(result, payload)))
}

View File

@@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
getNotificationPreferences: vi.fn(),
getUserContactInfo: vi.fn(),
logNotificationSendBatch: vi.fn(),
createNotification: vi.fn(),
inAppSend: vi.fn(),
smsSend: vi.fn(),
wechatSend: vi.fn(),
emailSend: vi.fn(),
}))
vi.mock("./data-access", () => ({
getUserContactInfo: mocks.getUserContactInfo,
logNotificationSendBatch: mocks.logNotificationSendBatch,
createNotification: mocks.createNotification,
}))
vi.mock("./preferences", () => ({
getNotificationPreferences: mocks.getNotificationPreferences,
}))
vi.mock("./channels/sms-channel", () => ({
createSmsSender: () => ({
channel: "sms",
send: mocks.smsSend,
sendBatch: vi.fn(),
}),
}))
vi.mock("./channels/wechat-channel", () => ({
createWechatSender: () => ({
channel: "wechat",
send: mocks.wechatSend,
sendBatch: vi.fn(),
}),
}))
vi.mock("./channels/email-channel", () => ({
createEmailSender: () => ({
channel: "email",
send: mocks.emailSend,
sendBatch: vi.fn(),
}),
}))
vi.mock("./channels/in-app-channel", () => ({
createInAppSender: () => ({
channel: "in_app",
send: mocks.inAppSend,
sendBatch: vi.fn(),
}),
}))
import { sendNotification } from "./dispatcher"
import type { NotificationPayload } from "./types"
describe("sendNotification", () => {
beforeEach(() => {
// mockReset (from vitest config) clears implementations before beforeEach.
// Re-establish channel send implementations so the cached sender registry
// (module-level singleton in dispatcher.ts) keeps working across tests.
mocks.inAppSend.mockImplementation(async (payload: NotificationPayload) => {
const id = await mocks.createNotification({
userId: payload.userId,
type: "message",
title: payload.title,
content: payload.content,
link: payload.actionUrl ?? null,
})
return {
channel: "in_app" as const,
success: true,
messageId: id,
sentAt: new Date(),
}
})
mocks.smsSend.mockResolvedValue({
channel: "sms",
success: true,
sentAt: new Date(),
})
mocks.wechatSend.mockResolvedValue({
channel: "wechat",
success: true,
sentAt: new Date(),
})
mocks.emailSend.mockResolvedValue({
channel: "email",
success: true,
sentAt: new Date(),
})
})
it("should select in_app channel when pushEnabled is true and no contact info", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test notification",
content: "Test content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(1)
expect(results[0].channel).toBe("in_app")
expect(results[0].success).toBe(true)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
title: "Test notification",
})
)
})
it("should select sms channel when smsEnabled and phone provided", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: true,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", phone: "13800138000" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(2)
const channels = results.map((r) => r.channel)
expect(channels).toContain("in_app")
expect(channels).toContain("sms")
})
it("should select email channel when emailEnabled and email provided", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: true,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", email: "test@example.com" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(2)
const channels = results.map((r) => r.channel)
expect(channels).toContain("in_app")
expect(channels).toContain("email")
})
it("should fallback to in_app when all channels disabled", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: false,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
// pushEnabled false 时,兜底逻辑应至少发 in_app
expect(results).toHaveLength(1)
expect(results[0].channel).toBe("in_app")
})
it("should select wechat channel when pushEnabled and wechatOpenId provided", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", wechatOpenId: "wx-open-id" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(2)
const channels = results.map((r) => r.channel)
expect(channels).toContain("in_app")
expect(channels).toContain("wechat")
})
it("should call logNotificationSendBatch with results", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
await sendNotification(payload)
expect(mocks.logNotificationSendBatch).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ channel: "in_app", success: true }),
]),
{ userId: "user-1", title: "Test" }
)
})
})

View File

@@ -124,8 +124,8 @@ export async function sendNotification(
})
)
// 记录发送日志
logNotificationSendBatch(results)
// 记录发送日志(传入 payload 用于持久化)
await logNotificationSendBatch(results, { userId: payload.userId, title: payload.title })
return results
}

View File

@@ -0,0 +1,121 @@
"use client"
import { useEffect, useRef, useCallback, useState } from "react"
import type { Notification } from "../types"
interface UseDesktopNotificationsOptions {
/** 是否启用桌面推送(默认 false */
enabled?: boolean
/** 通知点击时的跳转回调 */
onClick?: (notification: Notification) => void
}
interface UseDesktopNotificationsResult {
/** 当前权限状态 */
permission: NotificationPermission | "unsupported"
/** 是否已授权 */
isGranted: boolean
/** 请求权限 */
requestPermission: () => Promise<void>
/** 发送桌面通知 */
showNotification: (notification: Notification) => void
}
/**
* 桌面通知 Hook
*
* 使用浏览器 Notification API 发送桌面推送通知,
* 当用户不在页面标签页时仍可收到通知提醒。
*
* 使用场景:
* - SSE 收到新通知时触发桌面推送
* - 用户切换到其他标签页时仍能收到提醒
*
* 权限流程:
* 1. 默认权限为 "default"(未询问)
* 2. 调用 requestPermission() 弹出浏览器权限询问框
* 3. 用户授权后可调用 showNotification 发送桌面通知
*
* 浏览器兼容性:
* - Chrome/Edge/Firefox 桌面版完整支持
* - Safari 桌面版 13+ 支持
* - 移动端浏览器不支持(自动降级为无桌面推送)
*/
export function useDesktopNotifications(
options?: UseDesktopNotificationsOptions
): UseDesktopNotificationsResult {
const enabled = options?.enabled ?? false
const [permission, setPermission] = useState<NotificationPermission | "unsupported">(
typeof window !== "undefined" && "Notification" in window
? Notification.permission
: "unsupported"
)
const onClickRef = useRef(options?.onClick)
useEffect(() => {
onClickRef.current = options?.onClick
}, [options?.onClick])
const isGranted = permission === "granted"
const requestPermission = useCallback(async (): Promise<void> => {
if (typeof window === "undefined" || !("Notification" in window)) return
try {
const result = await Notification.requestPermission()
setPermission(result)
} catch {
// 某些浏览器可能抛出异常,静默处理
}
}, [])
const showNotification = useCallback(
(notification: Notification): void => {
if (!enabled || !isGranted) return
if (typeof window === "undefined" || !("Notification" in window)) return
try {
const desktopNotif = new Notification(notification.title, {
body: notification.content ?? "",
tag: notification.id, // 避免重复通知
...(notification.link ? { data: notification.link } : {}),
})
desktopNotif.onclick = () => {
window.focus()
onClickRef.current?.(notification)
desktopNotif.close()
}
} catch {
// 通知创建失败静默处理
}
},
[enabled, isGranted]
)
// 当 enabled 变为 true 且权限为 default 时,自动请求权限
// 使用异步队列避免在 effect 中同步调用 setState
useEffect(() => {
if (!enabled || permission !== "default") return
if (typeof window === "undefined" || !("Notification" in window)) return
let cancelled = false
Notification.requestPermission().then((result) => {
if (!cancelled) setPermission(result)
}).catch(() => {
// 某些浏览器可能抛出异常,静默处理
})
return () => {
cancelled = true
}
}, [enabled, permission])
return {
permission,
isGranted,
requestPermission,
showNotification,
}
}

View File

@@ -0,0 +1,196 @@
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import type { Notification } from "../types"
import {
getNotificationsAction,
getUnreadNotificationCountAction,
} from "../actions"
interface NotificationStreamData {
type: "update" | "error"
unreadCount?: number
notifications?: Notification[]
message?: string
}
interface UseNotificationStreamOptions {
/** 是否启用 SSE默认 true */
enabled?: boolean
/** SSE 连接失败后的轮询降级间隔(毫秒),默认 30000 */
fallbackPollInterval?: number
/** 初始未读数 */
initialUnreadCount?: number
/** 初始通知列表 */
initialNotifications?: Notification[]
}
interface UseNotificationStreamResult {
/** 未读通知数 */
unreadCount: number
/** 最新通知列表 */
notifications: Notification[]
/** SSE 是否已连接 */
isConnected: boolean
/** 是否正在使用轮询降级模式 */
isUsingFallback: boolean
/** 手动刷新(轮询降级模式下使用) */
refresh: () => void
}
/**
* 通知实时推送 Hook
*
* 优先使用 SSEServer-Sent Events接收实时通知更新
* 当 SSE 不可用时自动降级为轮询模式(调用 Server Actions
*
* SSE 优势:
* - 实时推送(延迟 < 1 秒)
* - 低服务器负载(单连接 + 定时推送)
* - 自动重连(浏览器原生支持)
*
* 降级策略:
* - SSE 连接失败 / 浏览器不支持 EventSource → 切换为轮询模式
* - 轮询调用 getNotificationsAction + getUnreadNotificationCountAction
* - 轮询间隔为 fallbackPollInterval默认 30 秒)
*/
export function useNotificationStream(options?: UseNotificationStreamOptions): UseNotificationStreamResult {
const enabled = options?.enabled ?? true
const fallbackPollInterval = options?.fallbackPollInterval ?? 30_000
const [unreadCount, setUnreadCount] = useState(options?.initialUnreadCount ?? 0)
const [notifications, setNotifications] = useState<Notification[]>(options?.initialNotifications ?? [])
const [isConnected, setIsConnected] = useState(false)
const [isUsingFallback, setIsUsingFallback] = useState(false)
const eventSourceRef = useRef<EventSource | null>(null)
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const pollFnRef = useRef<(() => Promise<void>) | null>(null)
/** 通过 Server Actions 拉取一次通知数据(轮询降级模式使用) */
const fetchOnce = useCallback(async (): Promise<void> => {
try {
const [countRes, listRes] = await Promise.all([
getUnreadNotificationCountAction(),
getNotificationsAction({ pageSize: 10 }),
])
if (countRes.success && typeof countRes.data === "number") {
setUnreadCount(countRes.data)
}
if (listRes.success && listRes.data) {
setNotifications(listRes.data.items)
}
} catch {
// 轮询失败静默处理
}
}, [])
/** 手动刷新(轮询降级模式下立即拉取一次) */
const refresh = useCallback(() => {
if (pollFnRef.current) {
void pollFnRef.current()
}
}, [])
useEffect(() => {
if (!enabled) return
/** 启动轮询降级(清理 SSE + 设置定时器) */
const startPolling = (): void => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
pollFnRef.current = fetchOnce
void fetchOnce()
pollTimerRef.current = setInterval(() => {
void fetchOnce()
}, fallbackPollInterval)
}
/** 清理轮询定时器 */
const clearPolling = (): void => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}
// 浏览器不支持 EventSource — 直接使用轮询降级
if (typeof window === "undefined" || !("EventSource" in window)) {
startPolling()
// 延迟状态更新,避免 effect 内同步 setState
queueMicrotask(() => {
setIsUsingFallback(true)
setIsConnected(false)
})
return clearPolling
}
// 创建 SSE 连接
let eventSource: EventSource
try {
eventSource = new EventSource("/api/notifications/stream")
eventSourceRef.current = eventSource
} catch {
// EventSource 构造失败 — 降级为轮询
startPolling()
queueMicrotask(() => {
setIsUsingFallback(true)
setIsConnected(false)
})
return clearPolling
}
eventSource.onopen = () => {
setIsConnected(true)
setIsUsingFallback(false)
}
eventSource.onmessage = (event) => {
try {
if (event.data === "[DONE]") {
eventSource.close()
setIsConnected(false)
// SSE 关闭后降级为轮询
startPolling()
setIsUsingFallback(true)
return
}
const data = JSON.parse(event.data) as NotificationStreamData
if (data.type === "update") {
if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount)
if (data.notifications) setNotifications(data.notifications)
} else if (data.type === "error") {
eventSource.close()
setIsConnected(false)
startPolling()
setIsUsingFallback(true)
}
} catch {
// 忽略解析错误
}
}
eventSource.onerror = () => {
eventSource.close()
setIsConnected(false)
startPolling()
setIsUsingFallback(true)
}
return () => {
eventSource.close()
clearPolling()
}
}, [enabled, fallbackPollInterval, fetchOnce])
return {
unreadCount,
notifications,
isConnected,
isUsingFallback,
refresh,
}
}

View File

@@ -30,6 +30,8 @@ export {
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
archiveNotification,
unarchiveNotification,
getUserContactInfo,
logNotificationSend,
logNotificationSendBatch,
@@ -45,6 +47,7 @@ export {
getUnreadNotificationCountAction,
markNotificationAsReadAction,
markAllNotificationsAsReadAction,
archiveNotificationAction,
} from "./actions"
export { NotificationList, NotificationDropdown } from "./components"
export type {
@@ -56,7 +59,9 @@ export type {
WechatChannelConfig,
EmailChannelConfig,
NotificationType,
NotificationPriority,
Notification,
NotificationLog,
PaginatedResult,
GetNotificationsParams,
CreateNotificationInput,
@@ -70,3 +75,7 @@ export { createSmsSender } from "./channels/sms-channel"
export { createWechatSender, isWechatEnabled } from "./channels/wechat-channel"
export { createEmailSender, isEmailEnabled } from "./channels/email-channel"
export { createInAppSender } from "./channels/in-app-channel"
// Hooks
export { useNotificationStream } from "./hooks/use-notification-stream"
export { useDesktopNotifications } from "./hooks/use-desktop-notifications"

View File

@@ -20,6 +20,9 @@ export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
/** 站内通知类型message_notifications.type 列) */
export type NotificationType = "message" | "announcement" | "homework" | "grade"
/** 通知优先级message_notifications.priority 列) */
export type NotificationPriority = "low" | "normal" | "high" | "urgent"
/** 站内通知记录(对应 message_notifications 表的展示形态) */
export interface Notification {
id: string
@@ -29,6 +32,8 @@ export interface Notification {
content: string | null
link: string | null
isRead: boolean
priority: NotificationPriority
isArchived: boolean
createdAt: string
}
@@ -72,6 +77,10 @@ export interface GetNotificationsParams {
page?: number
pageSize?: number
unreadOnly?: boolean
/** V2-P2-13b: 仅返回未归档通知(默认 true */
unarchivedOnly?: boolean
/** V2-P2-13b: 按优先级筛选 */
priority?: NotificationPriority
}
/** 创建站内通知的输入 */
@@ -81,6 +90,7 @@ export interface CreateNotificationInput {
title: string
content?: string | null
link?: string | null
priority?: NotificationPriority
}
/** 通知偏好设置(对应 notification_preferences 表的展示形态) */
@@ -144,6 +154,18 @@ export interface EmailChannelConfig {
pass?: string
}
/** 通知发送日志记录(对应 notification_logs 表的展示形态) */
export interface NotificationLog {
id: string
userId: string
title: string
channel: NotificationChannel
status: "success" | "failure"
messageId: string | null
error: string | null
sentAt: string
}
/** 通知渠道总配置 */
export interface NotificationChannelConfig {
enabled: boolean

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"

View File

@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test"
/**
* 私信模块 E2E 测试。
* 未登录场景可独立运行;登录后场景需要 DATABASE_URL。
* 参考 announcements.spec.ts 的登录与跳转断言模式。
*/
test.describe("Messages module", () => {
test.describe("unauthenticated access", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/messages")
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
})
test("redirects message detail to login when not authenticated", async ({ page }) => {
await page.goto("/messages/some-id")
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
})
test("redirects compose to login when not authenticated", async ({ page }) => {
await page.goto("/messages/compose")
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
})
})
test.describe("authenticated access", () => {
test.beforeEach(async ({ page }) => {
test.skip(!process.env.DATABASE_URL, "requires DATABASE_URL for authenticated flow")
const email = process.env.E2E_STUDENT_EMAIL ?? "student@e2e.local"
const password = process.env.E2E_STUDENT_PASSWORD ?? "e2e-pass-123456"
await page.goto("/login")
await page.getByLabel("Email").fill(email)
await page.getByLabel("Password").fill(password)
await page.getByRole("button", { name: "Sign In with Email" }).click()
})
test("messages page loads and stays authenticated", async ({ page }) => {
await page.goto("/messages")
await expect(page.locator("body")).toBeVisible()
await expect(page).not.toHaveURL(/\/login(?:$|[/?#])/)
})
test("messages page has tab navigation", async ({ page }) => {
await page.goto("/messages")
const tabs = page.locator('[role="tab"]')
await expect(tabs.first()).toBeVisible({ timeout: 10000 })
const tabCount = await tabs.count()
expect(tabCount).toBeGreaterThanOrEqual(2)
})
test("messages page renders without error", async ({ page }) => {
await page.goto("/messages")
await expect(page.locator("body")).toBeVisible()
})
test("message compose page loads", async ({ page }) => {
await page.goto("/messages/compose")
await expect(page.locator("body")).toBeVisible()
})
})
})

View File

@@ -0,0 +1,4 @@
// Stub for Next.js "server-only" package.
// The real package throws when imported on the client side; vitest (jsdom)
// cannot resolve it, so we alias it to this empty module in vitest.unit.config.ts.
export {}

View File

@@ -5,6 +5,8 @@ export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
// Next.js "server-only" 包在 vitest 环境无法解析alias 到空 stub
"server-only": path.resolve(__dirname, "tests/setup/server-only-stub.ts"),
},
},
test: {