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

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