feat(messaging): add drafts, group compose, templates, reports, blocks, and services

- Add message-draft-list and message-attachments for draft and attachment management

- Add message-group-compose for group messaging

- Add message-template-picker for message templates

- Add message-report-block for reporting and blocking users

- Add message-list-section and message-list-skeleton for list rendering

- Add lib and services directories for messaging utilities and service layer
This commit is contained in:
SpecialX
2026-07-03 10:24:37 +08:00
parent c935597803
commit 2859ef74f2
20 changed files with 3247 additions and 347 deletions

View File

@@ -5,17 +5,24 @@ import { getTranslations } from "next-intl/server"
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { trackEvent } from "@/shared/lib/track-event"
import { Permissions } from "@/shared/types/permissions"
import type { AuthContext } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { sendNotification } from "@/modules/notifications/dispatcher"
import {
SendMessageSchema,
MessageIdSchema,
UpdateNotificationPreferencesSchema,
MessageDraftSchema,
BulkMessageIdsSchema,
MessageTemplateSchema,
SendGroupMessageSchema,
ReportMessageSchema,
BlockUserSchema,
} from "./schema"
import {
getMessages,
getMessageById,
getMessageThread,
createMessage,
markMessageAsRead,
deleteMessage,
@@ -26,23 +33,39 @@ import {
createMessageDraft,
updateMessageDraft,
deleteMessageDraft,
isReceiverAllowed,
bulkMarkMessagesAsRead,
bulkDeleteMessages,
bulkToggleMessagesStar,
recallMessage,
getMessageTemplates,
createMessageTemplate,
updateMessageTemplate,
deleteMessageTemplate,
sendGroupMessage,
reportMessage,
hasUserReportedMessage,
blockUser,
unblockUser,
isEitherUserBlocked,
getUserBlocks,
getMessageAttachments,
} from "./data-access"
import {
getNotificationPreferences,
upsertNotificationPreferences,
} from "@/modules/notifications/preferences"
import type { Message, MessageType, RecipientOption } from "./types"
import type {
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "@/modules/notifications/types"
import type { Message, MessageType, MessageDraft, MessageTemplate, RecipientOption, UserBlock, MessageAttachment } from "./types"
export async function sendMessageAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
let ctx: AuthContext | null = null
let parsedInput: {
receiverId: string
subject: string | null
content: string
parentMessageId: string | null
} | null = null
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = SendMessageSchema.safeParse({
receiverId: formData.get("receiverId"),
@@ -56,10 +79,33 @@ export async function sendMessageAction(
}
const input = parsed.data
parsedInput = input
if (input.receiverId === ctx.userId) {
return { success: false, message: "Cannot send a message to yourself" }
}
// P0-1: Server Action 二次校验收件人是否在 sender 的 DataScope 允许范围内
// 防止绕过前端 Select 直接构造 FormData 提交任意 receiverId 越权发送
const allowed = await isReceiverAllowed(ctx.userId, input.receiverId, ctx.dataScope)
if (!allowed) {
return { success: false, message: "Recipient not allowed" }
}
// P2-5: 屏蔽校验 —— 发送方或接收方任一屏蔽对方,则禁止发送
const blocked = await isEitherUserBlocked(ctx.userId, input.receiverId)
if (blocked) {
return { success: false, message: "Cannot send message due to block" }
}
// P0-6: 校验 parentMessageId 属于当前用户参与的会话
// 防止将回复消息注入到他人会话线程
if (input.parentMessageId) {
const parent = await getMessageById(input.parentMessageId, ctx.userId)
if (!parent) {
return { success: false, message: "Parent message not accessible" }
}
}
const id = await createMessage({
senderId: ctx.userId,
receiverId: input.receiverId,
@@ -96,6 +142,18 @@ export async function sendMessageAction(
return { success: true, message: "Message sent", data: id }
} catch (e) {
// P1-9: 失败埋点(权限拒绝除外,避免噪音)
if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) {
void trackEvent({
event: "message.send_failed",
userId: ctx.userId,
targetType: "message",
properties: {
receiverId: parsedInput.receiverId,
reason: e instanceof Error ? e.message : "unknown",
},
})
}
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" }
@@ -159,7 +217,20 @@ export async function deleteMessageAction(messageId: string): Promise<ActionStat
}
export async function getMessagesAction(
params: { type: MessageType; page?: number; pageSize?: number; keyword?: string }
params: {
type: MessageType
page?: number
pageSize?: number
keyword?: string
starredOnly?: boolean
/** P2-11: 按发件人筛选 */
senderId?: string
/** P2-11: 按日期范围筛选 */
dateFrom?: string
dateTo?: string
/** P2-11: 按已读状态筛选 */
isRead?: boolean
}
): Promise<ActionState<{ items: Message[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
@@ -172,7 +243,17 @@ export async function getMessagesAction(
}
}
export async function getMessageDetailAction(messageId: string): Promise<ActionState<Message>> {
/**
* P1-3: 获取消息线程(根消息 + 回复链)。
*
* 替代直接调用 data-access.getMessageThread统一通过 Server Action 入口
* 做权限校验。返回的 Message[] 已按时间倒序(与 data-access 一致),
* UI 层可按需 reverse 为正序展示。
*
* 注:原 getMessageDetailAction 已删除P0-4 死代码,详情数据由
* getMessageDetailPageData 编排函数 + RSC 直接获取,组件通过 props 接收)。
*/
export async function getMessageThreadAction(messageId: string): Promise<ActionState<Message[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
@@ -181,15 +262,9 @@ export async function getMessageDetailAction(messageId: string): Promise<ActionS
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
const validMessageId = parsed.data.messageId
const message = await getMessageById(validMessageId, ctx.userId)
if (!message) return { success: false, message: "Message not found" }
// Auto-mark as read when viewed by receiver
if (!message.isRead && message.receiverId === ctx.userId) {
await markMessageAsRead(validMessageId, ctx.userId)
revalidatePath("/messages")
}
return { success: true, data: message }
// data-access.getMessageThread 内部会校验当前用户对根消息的访问权
const thread = await getMessageThread(parsed.data.messageId, ctx.userId)
return { success: true, data: thread }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
@@ -257,7 +332,7 @@ export async function toggleMessageStarAction(messageId: string): Promise<Action
// V2-P2-13c: 消息草稿 Server Actions
// ---------------------------------------------------------------------------
export async function getMessageDraftsAction(): Promise<ActionState<import("./types").MessageDraft[]>> {
export async function getMessageDraftsAction(): Promise<ActionState<MessageDraft[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const drafts = await getMessageDrafts(ctx.userId)
@@ -276,30 +351,62 @@ export async function saveMessageDraftAction(
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
const parsed = MessageDraftSchema.safeParse({
draftId: formData.get("draftId") ?? undefined,
receiverId: formData.get("receiverId") ?? undefined,
subject: formData.get("subject") ?? undefined,
content: formData.get("content") ?? undefined,
parentMessageId: formData.get("parentMessageId") ?? undefined,
})
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 }
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input = parsed.data
// P2-10: 从 FormData 读取 deviceId 和 expectedVersion可选
const deviceId = (formData.get("deviceId") as string | null) ?? null
const expectedVersionRaw = formData.get("expectedVersion")
const expectedVersion = expectedVersionRaw ? Number(expectedVersionRaw) : undefined
const draftData = {
receiverId: input.receiverId || null,
subject: input.subject || null,
content: input.content || null,
parentMessageId: input.parentMessageId || null,
deviceId,
expectedVersion,
}
if (input.draftId) {
// P2-10: updateMessageDraft 现在返回 "ok" | "not_found" | "conflict"
const result = await updateMessageDraft(input.draftId, ctx.userId, draftData)
if (result === "conflict") {
// P2-10: 版本冲突,客户端应拉取最新版本
return { success: false, message: "Draft version conflict" }
}
if (result === "not_found") {
// P2-10: 草稿不存在(可能已在其他设备删除),重新创建
const id = await createMessageDraft({
userId: ctx.userId,
receiverId: draftData.receiverId,
subject: draftData.subject,
content: draftData.content,
parentMessageId: draftData.parentMessageId,
deviceId: draftData.deviceId,
})
return { success: true, message: "Draft recreated", data: id }
}
return { success: true, message: "Draft saved", data: input.draftId }
}
// 创建新草稿
const id = await createMessageDraft({
userId: ctx.userId,
receiverId: receiverId || null,
subject: subject || null,
content: content || null,
parentMessageId: parentMessageId || null,
receiverId: draftData.receiverId,
subject: draftData.subject,
content: draftData.content,
parentMessageId: draftData.parentMessageId,
deviceId: draftData.deviceId,
})
return { success: true, message: "Draft created", data: id }
@@ -326,16 +433,31 @@ export async function deleteMessageDraftAction(draftId: string): Promise<ActionS
}
// ---------------------------------------------------------------------------
// 通知相关 Server ActionsgetNotificationsAction / markNotificationAsReadAction /
// markAllNotificationsAsReadAction / getUnreadNotificationCountAction已迁移至
// @/modules/notifications/actions。请直接从 notifications 模块导入。
// P2-1: 批量操作 Server Actions
// ---------------------------------------------------------------------------
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
export async function bulkMarkMessagesAsReadAction(
ids: string[]
): Promise<ActionState<{ updated: number }>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const prefs = await getNotificationPreferences(ctx.userId)
return { success: true, data: prefs }
const parsed = BulkMessageIdsSchema.safeParse({ ids })
if (!parsed.success) {
return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors }
}
const updated = await bulkMarkMessagesAsRead(parsed.data.ids, ctx.userId)
revalidatePath("/messages")
void trackEvent({
event: "message.marked_read",
userId: ctx.userId,
targetType: "message",
properties: { count: updated, bulk: true },
})
return { success: true, message: "Messages marked as read", data: { updated } }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
@@ -343,51 +465,510 @@ export async function getNotificationPreferencesAction(): Promise<ActionState<No
}
}
export async function updateNotificationPreferencesAction(
prevState: ActionState<NotificationPreferences> | null,
formData: FormData
): Promise<ActionState<NotificationPreferences>> {
export async function bulkDeleteMessagesAction(
ids: string[]
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_DELETE)
const parsed = BulkMessageIdsSchema.safeParse({ ids })
if (!parsed.success) {
return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors }
}
await bulkDeleteMessages(parsed.data.ids, ctx.userId)
revalidatePath("/messages")
void trackEvent({
event: "message.deleted",
userId: ctx.userId,
targetType: "message",
properties: { count: parsed.data.ids.length, bulk: true },
})
return { success: true, message: "Messages 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" }
}
}
export async function bulkToggleMessagesStarAction(
ids: string[]
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
// 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交)
const parseBool = (key: string): boolean => formData.get(key) === "on"
// 从 FormData 中解析时间字符串("HH:mm"),空字符串转为 null
const parseTime = (key: string): string | null => {
const v = formData.get(key)
if (typeof v !== "string") return null
const trimmed = v.trim()
return trimmed.length > 0 ? trimmed : null
const parsed = BulkMessageIdsSchema.safeParse({ ids })
if (!parsed.success) {
return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors }
}
const parsed = UpdateNotificationPreferencesSchema.safeParse({
emailEnabled: parseBool("emailEnabled"),
smsEnabled: parseBool("smsEnabled"),
pushEnabled: parseBool("pushEnabled"),
homeworkNotifications: parseBool("homeworkNotifications"),
gradeNotifications: parseBool("gradeNotifications"),
announcementNotifications: parseBool("announcementNotifications"),
messageNotifications: parseBool("messageNotifications"),
attendanceNotifications: parseBool("attendanceNotifications"),
quietHoursEnabled: parseBool("quietHoursEnabled"),
quietHoursStart: parseTime("quietHoursStart"),
quietHoursEnd: parseTime("quietHoursEnd"),
await bulkToggleMessagesStar(parsed.data.ids, ctx.userId)
revalidatePath("/messages")
void trackEvent({
event: "message.star_toggled",
userId: ctx.userId,
targetType: "message",
properties: { count: parsed.data.ids.length, bulk: true },
})
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" }
}
}
// ---------------------------------------------------------------------------
// P2-4: 消息撤回 Server Action2 分钟窗口内仅发送方可撤回)
// ---------------------------------------------------------------------------
export async function recallMessageAction(
messageId: string
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = MessageIdSchema.safeParse({ messageId })
if (!parsed.success) {
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
}
const result = await recallMessage(parsed.data.messageId, ctx.userId)
if (result === "not_found") {
return { success: false, message: "Message not found or not owned" }
}
if (result === "already_recalled") {
return { success: false, message: "Message already recalled" }
}
if (result === "expired") {
return { success: false, message: "Recall window expired" }
}
revalidatePath("/messages")
revalidatePath(`/messages/${parsed.data.messageId}`)
void trackEvent({
event: "message.recalled",
userId: ctx.userId,
targetId: parsed.data.messageId,
targetType: "message",
})
return { success: true, message: "Message recalled" }
} 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" }
}
}
// ---------------------------------------------------------------------------
// P2-2: 群组消息 Server Action教师→全班家长fan-out on write
// ---------------------------------------------------------------------------
export async function sendGroupMessageAction(
prevState: ActionState<{ recipientCount: number }> | null,
formData: FormData
): Promise<ActionState<{ recipientCount: number }>> {
let ctx: AuthContext | null = null
let parsedInput: { classId: string; subject: string | null; content: string } | null = null
try {
ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = SendGroupMessageSchema.safeParse({
classId: formData.get("classId"),
subject: formData.get("subject") || undefined,
content: formData.get("content"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input: UpdateNotificationPreferencesInput = parsed.data
const input = parsed.data
parsedInput = input
const updated = await upsertNotificationPreferences(ctx.userId, input)
if (!updated) {
return { success: false, message: "Failed to update notification preferences" }
// P2-2: 校验当前教师是否有权向该班级群发(必须在其 class_taught 范围内)
const allowed = ctx.dataScope.type === "class_taught"
&& ctx.dataScope.classIds.includes(input.classId)
if (!allowed) {
return { success: false, message: "Not allowed to send group message to this class" }
}
revalidatePath("/settings")
const result = await sendGroupMessage({
senderId: ctx.userId,
classId: input.classId,
subject: input.subject,
content: input.content,
})
return { success: true, message: "Notification preferences updated", data: updated }
if (result.recipientIds.length === 0) {
return { success: false, message: "No recipients found in this class" }
}
// 通知每个家长
const t = await getTranslations("messages")
const notifyTitle = input.subject
? t("notification.messageTitle", { subject: input.subject })
: t("notification.messageTitleNoSubject")
const notifyContent = input.content.slice(0, 200)
await Promise.all(
result.recipientIds.map((parentId) =>
sendNotification({
userId: parentId,
type: "info",
title: notifyTitle,
content: notifyContent,
actionUrl: "/messages",
metadata: { messageType: "group_message", groupMessageId: result.groupMessageId },
})
)
)
revalidatePath("/messages")
void trackEvent({
event: "message.group_sent",
userId: ctx.userId,
targetType: "message",
properties: {
classId: input.classId,
recipientCount: result.recipientIds.length,
groupMessageId: result.groupMessageId,
},
})
return {
success: true,
message: "Group message sent",
data: { recipientCount: result.recipientIds.length },
}
} catch (e) {
// P1-9: 失败埋点(权限拒绝除外)
if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) {
void trackEvent({
event: "message.group_send_failed",
userId: ctx.userId,
targetType: "message",
properties: {
classId: parsedInput.classId,
reason: e instanceof Error ? e.message : "unknown",
},
})
}
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" }
}
}
// ---------------------------------------------------------------------------
// P2-6: 快捷回复模板 Server Actions
// ---------------------------------------------------------------------------
export async function getMessageTemplatesAction(): Promise<ActionState<MessageTemplate[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const templates = await getMessageTemplates(ctx.userId)
return { success: true, data: templates }
} 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 saveMessageTemplateAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = MessageTemplateSchema.safeParse({
templateId: formData.get("templateId") ?? undefined,
title: formData.get("title"),
content: formData.get("content"),
sortOrder: formData.get("sortOrder") !== null
? Number(formData.get("sortOrder"))
: undefined,
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input = parsed.data
if (input.templateId) {
await updateMessageTemplate(input.templateId, ctx.userId, {
title: input.title,
content: input.content,
sortOrder: input.sortOrder,
})
revalidatePath("/messages/compose")
return { success: true, message: "Template updated", data: input.templateId }
}
const id = await createMessageTemplate({
userId: ctx.userId,
title: input.title,
content: input.content,
sortOrder: input.sortOrder,
})
revalidatePath("/messages/compose")
void trackEvent({
event: "message.template_created",
userId: ctx.userId,
targetId: id,
targetType: "message",
})
return { success: true, message: "Template 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 deleteMessageTemplateAction(templateId: string): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = MessageIdSchema.safeParse({ messageId: templateId })
if (!parsed.success) {
return { success: false, message: "Invalid template id", errors: parsed.error.flatten().fieldErrors }
}
await deleteMessageTemplate(parsed.data.messageId, ctx.userId)
revalidatePath("/messages/compose")
return { success: true, message: "Template 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 /
// getNotificationPreferencesAction / updateNotificationPreferencesAction
// 已迁移至 @/modules/notifications/actions。请直接从 notifications 模块导入。
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// P2-5: 消息举报 + 用户屏蔽 Server Actions
// ---------------------------------------------------------------------------
/**
* P2-5: 举报消息。
*
* - 校验消息存在且当前用户为接收方(只能举报收到的消息)
* - 防重复:同一用户对同一消息只能举报一次
*/
export async function reportMessageAction(
prevState: ActionState<null> | null,
formData: FormData
): Promise<ActionState<null>> {
let ctx: AuthContext | null = null
let parsedInput: { messageId: string; reason: string; description?: string } | null = null
try {
ctx = await requirePermission(Permissions.MESSAGE_READ)
const parsed = ReportMessageSchema.safeParse({
messageId: formData.get("messageId"),
reason: formData.get("reason"),
description: formData.get("description") || undefined,
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input = parsed.data
parsedInput = input
// 校验消息存在且当前用户为接收方(防越权举报)
const msg = await getMessageById(input.messageId, ctx.userId)
if (!msg) {
return { success: false, message: "Message not found" }
}
// 举报人必须是接收方(不能举报自己发的消息)
if (msg.senderId === ctx.userId) {
return { success: false, message: "Cannot report your own message" }
}
const result = await reportMessage({
reporterId: ctx.userId,
messageId: input.messageId,
reportedUserId: msg.senderId,
reason: input.reason as "spam" | "harassment" | "inappropriate" | "other",
description: input.description ?? null,
})
if (result === "already_reported") {
return { success: false, message: "Already reported this message" }
}
void trackEvent({
event: "message.reported",
userId: ctx.userId,
targetId: input.messageId,
targetType: "message",
properties: { reason: input.reason },
})
return { success: true, message: "Message reported", data: null }
} catch (e) {
if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) {
void trackEvent({
event: "message.report_failed",
userId: ctx.userId,
targetId: parsedInput.messageId,
targetType: "message",
properties: { reason: parsedInput.reason },
})
}
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" }
}
}
/**
* P2-5: 检查当前用户是否已举报某条消息(用于 UI 禁用状态)。
*/
export async function checkReportedAction(messageId: string): Promise<ActionState<boolean>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const reported = await hasUserReportedMessage(ctx.userId, messageId)
return { success: true, data: reported }
} 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" }
}
}
/**
* P2-5: 屏蔽用户。
*/
export async function blockUserAction(
prevState: ActionState<null> | null,
formData: FormData
): Promise<ActionState<null>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const parsed = BlockUserSchema.safeParse({
blockedId: formData.get("blockedId"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input = parsed.data
const result = await blockUser(ctx.userId, input.blockedId)
if (result === "self_block") {
return { success: false, message: "Cannot block yourself" }
}
if (result === "already_blocked") {
return { success: false, message: "User already blocked" }
}
revalidatePath("/messages")
void trackEvent({
event: "message.user_blocked",
userId: ctx.userId,
targetId: input.blockedId,
targetType: "user",
})
return { success: true, message: "User blocked", data: null }
} 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" }
}
}
/**
* P2-5: 取消屏蔽用户。
*/
export async function unblockUserAction(blockedId: string): Promise<ActionState<null>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
await unblockUser(ctx.userId, blockedId)
revalidatePath("/messages")
void trackEvent({
event: "message.user_unblocked",
userId: ctx.userId,
targetId: blockedId,
targetType: "user",
})
return { success: true, message: "User unblocked", data: null }
} 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" }
}
}
/**
* P2-5: 获取当前用户的屏蔽列表。
*/
export async function getUserBlocksAction(): Promise<ActionState<UserBlock[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const blocks = await getUserBlocks(ctx.userId)
return { success: true, data: blocks }
} 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" }
}
}
// ---------------------------------------------------------------------------
// P2-3: 消息附件 Server Actions
// ---------------------------------------------------------------------------
/**
* P2-3: 获取指定消息的附件列表。
*
* 校验当前用户对消息有访问权(必须是发送方或接收方),防止越权访问附件。
* 附件存储复用 files 模块的 fileAttachments 表targetType="message")。
*/
export async function getMessageAttachmentsAction(
messageId: string
): Promise<ActionState<MessageAttachment[]>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
// 校验访问权:必须是消息的发送方或接收方
const msg = await getMessageById(messageId, ctx.userId)
if (!msg) {
return { success: false, message: "Message not found" }
}
const attachments = await getMessageAttachments(messageId)
return { success: true, data: attachments }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }

View File

@@ -0,0 +1,166 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Paperclip, X, FileText, Loader2, Download } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { formatFileSize } from "@/shared/lib/file-storage"
import { useFileUpload, type UploadTask } from "@/modules/files/hooks/use-file-upload"
import type { FileUploadResult } from "@/modules/files/types"
import type { MessageAttachment } from "../types"
const MESSAGE_TARGET_TYPE = "message"
/**
* P2-3: 消息附件上传区compose 用)。
*
* 采用临时上传模式targetType="message" + targetId="draft-{userId}-{timestamp}"。
* 发送消息后由 Server Action 通过 updateFileTargets 批量更新 targetId 为真实 messageId。
*
* 为简化实现,本组件仅返回上传后的附件 ID 列表,由 compose form 提交时一并传入。
*/
export function MessageAttachmentUploader({
onAttachmentsChange,
}: {
onAttachmentsChange: (attachments: FileUploadResult[]) => void
}) {
const t = useTranslations("messages")
const [attachments, setAttachments] = useState<FileUploadResult[]>([])
// 临时 targetId使用 useState 懒初始化避免渲染期间调用 Date.now()react-hooks/purity
const [draftTargetId] = useState(() => `draft-${Date.now()}`)
const { tasks, inputRef, handleFiles, removeTask, acceptAttr } = useFileUpload({
targetType: MESSAGE_TARGET_TYPE,
// 临时 targetId发送时由 action 更新为真实 messageId
targetId: draftTargetId,
multiple: true,
onUploaded: (result) => {
const next = [...attachments, result]
setAttachments(next)
onAttachmentsChange(next)
},
})
const handleRemove = (task: UploadTask): void => {
removeTask(task)
if (task.result) {
const next = attachments.filter((a) => a.id !== task.result!.id)
setAttachments(next)
onAttachmentsChange(next)
}
}
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
multiple
accept={acceptAttr}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
>
<Paperclip className="mr-2 h-4 w-4" aria-hidden="true" />
{t("attachments.add")}
</Button>
{/* 上传任务列表 */}
{tasks.length > 0 ? (
<ul className="space-y-1">
{tasks.map((task) => (
<li
key={`${task.file.name}-${task.file.lastModified}`}
className="bg-muted/40 flex items-center justify-between gap-2 rounded px-2 py-1 text-sm"
>
<div className="flex min-w-0 items-center gap-2">
{task.status === "uploading" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden="true" />
) : (
<FileText className="text-muted-foreground h-3 w-3 shrink-0" aria-hidden="true" />
)}
<span className="truncate">{task.file.name}</span>
<span className="text-muted-foreground shrink-0 text-xs">
{formatFileSize(task.file.size)}
</span>
{task.status === "error" && task.message ? (
<span className="text-destructive shrink-0 text-xs">{task.message}</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleRemove(task)}
aria-label={t("attachments.remove")}
>
<X className="h-3 w-3" aria-hidden="true" />
</Button>
</li>
))}
</ul>
) : null}
</div>
)
}
/**
* P2-3: 消息附件列表展示detail 用)。
*
* 只读展示已上传附件,提供下载链接。
*/
export function MessageAttachmentList({
attachments,
}: {
attachments: MessageAttachment[]
}) {
const t = useTranslations("messages")
if (attachments.length === 0) return null
return (
<div className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium">
<Paperclip className="h-3 w-3" aria-hidden="true" />
{t("attachments.title", { count: attachments.length })}
</div>
<ul className="space-y-1">
{attachments.map((a) => (
<li
key={a.id}
className="bg-muted/40 flex items-center justify-between gap-2 rounded px-2 py-1 text-sm"
>
<div className="flex min-w-0 items-center gap-2">
<FileText className="text-muted-foreground h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">{a.originalName}</span>
<span className="text-muted-foreground shrink-0 text-xs">
{formatFileSize(a.size)}
</span>
</div>
{a.url ? (
<Button asChild variant="ghost" size="icon" className="h-6 w-6">
<a
href={a.url}
download={a.originalName}
target="_blank"
rel="noopener noreferrer"
aria-label={t("attachments.download")}
>
<Download className="h-3 w-3" aria-hidden="true" />
</a>
</Button>
) : null}
</li>
))}
</ul>
</div>
)
}

View File

@@ -21,6 +21,7 @@ import {
} from "@/shared/components/ui/select"
import { deleteMessageDraftAction, saveMessageDraftAction, sendMessageAction } from "../actions"
import { MessageTemplatePicker } from "./message-template-picker"
import type { RecipientOption } from "../types"
type FieldErrors = Record<string, string[]>
@@ -52,6 +53,8 @@ export function MessageCompose({
const [draftId, setDraftId] = useState<string | null>(null)
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle")
const draftIdRef = useRef<string | null>(null)
// P0-5: 提交标志位,防止发送成功后自动保存 effect 重新创建草稿
const submittedRef = useRef(false)
// Keep draftIdRef in sync so auto-save callback reads the latest value
useEffect(() => {
@@ -64,13 +67,16 @@ export function MessageCompose({
}
// 自动保存草稿(防抖 2 秒)
// P0-5: 检查 submittedRef提交后不再触发自动保存
useEffect(() => {
if (submittedRef.current) return
// 没有内容时不保存
if (!subject.trim() && !content.trim()) return
let cancelled = false
const timeout = setTimeout(() => {
if (cancelled) return
if (submittedRef.current) return // 提交后跳过
const formData = new FormData()
const currentDraftId = draftIdRef.current
if (currentDraftId) formData.set("draftId", currentDraftId)
@@ -83,6 +89,7 @@ export function MessageCompose({
void saveMessageDraftAction(null, formData)
.then((res) => {
if (cancelled) return
if (submittedRef.current) return // 提交后跳过
if (res.success && res.data) {
setDraftId(res.data)
setSaveStatus("saved")
@@ -106,6 +113,8 @@ export function MessageCompose({
toast.error(t("form.selectRecipient"))
return
}
// P0-5: 标记已提交,阻止后续自动保存 effect 触发新草稿创建
submittedRef.current = true
formData.set("receiverId", receiverId)
if (parentMessageId) {
formData.set("parentMessageId", parentMessageId)
@@ -129,9 +138,12 @@ export function MessageCompose({
setFieldErrors(res.errors)
}
toast.error(res.message || t("messages.sendFailed"))
// 提交失败时重置 submittedRef 允许后续自动保存
submittedRef.current = false
}
} catch {
toast.error(t("messages.sendFailed"))
submittedRef.current = false
} finally {
setIsWorking(false)
}
@@ -232,7 +244,15 @@ export function MessageCompose({
</div>
<div className="grid gap-2">
<Label htmlFor="content">{t("form.content")}</Label>
<div className="flex items-center justify-between">
<Label htmlFor="content">{t("form.content")}</Label>
{/* P2-6: 快捷回复模板插入器 */}
<MessageTemplatePicker
onInsert={(templateContent) => {
setContent((prev) => (prev ? `${prev}\n\n${templateContent}` : templateContent))
}}
/>
</div>
<Textarea
id="content"
name="content"

View File

@@ -1,21 +1,31 @@
"use client"
import { useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { ArrowLeft, Mail, Reply, Star, Trash2 } from "lucide-react"
import { ArrowLeft, Loader2, Mail, Reply, RotateCcw, Star, Trash2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import { cn, formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { deleteMessageAction, toggleMessageStarAction } from "../actions"
import { deleteMessageAction, getMessageThreadAction, recallMessageAction, toggleMessageStarAction } from "../actions"
import { buildReplyHref } from "../lib/build-reply-href"
import { MessageReportBlock } from "./message-report-block"
import type { Message } from "../types"
export function MessageDetail({
@@ -31,16 +41,54 @@ export function MessageDetail({
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [recallOpen, setRecallOpen] = useState(false)
const [isRecalling, setIsRecalling] = useState(false)
const [isStarred, setIsStarred] = useState(message.isStarred)
const [isTogglingStar, setIsTogglingStar] = useState(false)
// P1-3: 线程状态
const [thread, setThread] = useState<Message[]>([])
const [loadingThread, setLoadingThread] = useState(true)
// P2-4: 撤回状态(同步 UI撤回成功后立即显示占位
const [isRecalled, setIsRecalled] = useState(message.recalledAt !== null)
const { hasPermission } = usePermission()
const canSend = hasPermission(Permissions.MESSAGE_SEND)
const canDelete = hasPermission(Permissions.MESSAGE_DELETE)
const isReceived = message.receiverId === currentUserId
// P2-4: 仅发送方在 2 分钟窗口内、且消息未被撤回时可撤回
const canRecall =
!isReceived &&
canSend &&
!isRecalled &&
Date.now() - new Date(message.createdAt).getTime() <= 2 * 60 * 1000
const counterpart = isReceived ? message.senderName : message.receiverName
const counterpartLabel = isReceived ? t("meta.from") : t("meta.to")
// P1-3: 获取消息线程(根消息 + 回复链)
useEffect(() => {
let active = true
setLoadingThread(true)
void getMessageThreadAction(message.id)
.then((res) => {
if (!active) return
if (res.success && res.data) {
setThread(res.data)
} else {
setThread([])
}
})
.catch(() => {
if (!active) return
setThread([])
})
.finally(() => {
if (active) setLoadingThread(false)
})
return () => {
active = false
}
}, [message.id])
const handleDelete = async () => {
setIsWorking(true)
try {
@@ -83,11 +131,41 @@ export function MessageDetail({
}
}
const replyHref = canSend
? `/messages/compose?parentId=${message.id}&receiverId=${isReceived ? message.senderId : message.receiverId}&subject=${encodeURIComponent(
message.subject?.startsWith("Re:") ? message.subject : `Re: ${message.subject ?? ""}`
)}`
: undefined
// P2-4: 消息撤回仅发送方2 分钟窗口内)
const handleRecall = async () => {
setIsRecalling(true)
try {
const res = await recallMessageAction(message.id)
if (res.success) {
// 同步 UI立即显示"已撤回"占位
setIsRecalled(true)
toast.success(t("messages.recalled"))
router.refresh()
} else {
// 失败:根据返回消息判断是否超时
if (res.message === "Recall window expired") {
toast.error(t("messages.recallExpired"))
} else {
toast.error(res.message || t("messages.recallFailed"))
}
}
} catch {
toast.error(t("messages.recallFailed"))
} finally {
setIsRecalling(false)
setRecallOpen(false)
}
}
// P1-7: 使用纯函数构建回复链接(替代组件内字符串拼接)
const replyHref = buildReplyHref(message, currentUserId, canSend)
// 线程展示:根消息已渲染在主区域,这里仅展示回复(时间正序)
// data-access 返回倒序最新在前UI 反转为正序(最早回复在前)
const replies = thread
.filter((m) => m.id !== message.id)
.slice()
.reverse()
return (
<div className="space-y-6">
@@ -105,13 +183,25 @@ export function MessageDetail({
onClick={handleToggleStar}
disabled={isTogglingStar}
variant={isStarred ? "default" : "outline"}
aria-pressed={isStarred}
>
<Star
className={cn("mr-2 h-4 w-4", isStarred && "fill-current")}
/>
{isStarred ? t("actions.unstar") : t("actions.star")}
</Button>
{canSend ? (
{/* P2-4: 撤回按钮(仅发送方可见且未撤回、未超时) */}
{canRecall ? (
<Button
onClick={() => setRecallOpen(true)}
disabled={isRecalling}
variant="outline"
>
<RotateCcw className="mr-2 h-4 w-4" />
{t("actions.recall")}
</Button>
) : null}
{canSend && !isRecalled ? (
<Button asChild variant="outline">
<Link href={replyHref ?? "#"}>
<Reply className="mr-2 h-4 w-4" />
@@ -125,6 +215,14 @@ export function MessageDetail({
{t("actions.delete")}
</Button>
) : null}
{/* P2-5: 举报 + 屏蔽(仅收到的消息,非自己发送的) */}
{isReceived && !isRecalled ? (
<MessageReportBlock
messageId={message.id}
senderId={message.senderId}
currentUserId={currentUserId}
/>
) : null}
</div>
</div>
@@ -132,7 +230,10 @@ export function MessageDetail({
<CardHeader className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Mail className="text-muted-foreground h-4 w-4" aria-hidden="true" />
{isReceived && !message.isRead ? (
{/* P2-4: 已撤回状态优先显示 */}
{isRecalled ? (
<Badge variant="secondary">{t("status.recalled")}</Badge>
) : isReceived && !message.isRead ? (
<Badge variant="default">{t("status.new")}</Badge>
) : isReceived ? (
<Badge variant="secondary">{t("status.read")}</Badge>
@@ -162,7 +263,64 @@ export function MessageDetail({
</div>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
{/* P2-4: 已撤回消息显示占位文案,隐藏原始内容 */}
{isRecalled ? (
<p className="text-muted-foreground italic text-sm">{t("status.recalled")}</p>
) : (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
)}
</CardContent>
</Card>
{/* P1-3: 消息线程(回复链) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{t("thread.title")}
{loadingThread ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<span className="text-muted-foreground text-xs">
{replies.length > 0
? t("thread.replyCount", { count: replies.length })
: t("thread.noReplies")}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{loadingThread ? null : replies.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("thread.noReplies")}</p>
) : (
replies.map((reply) => {
const replyIsReceived = reply.receiverId === currentUserId
const replyCounterpart = replyIsReceived ? reply.senderName : reply.receiverName
const replyIsSelf = reply.senderId === currentUserId
const replyRecalled = reply.recalledAt !== null
return (
<div
key={reply.id}
className={cn(
"rounded-md border p-3",
replyIsSelf ? "border-primary/40 bg-primary/5" : "bg-muted/30"
)}
>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="font-medium">
{replyIsSelf ? t("thread.you") : (replyCounterpart ?? t("meta.unknown"))}
</span>
<span>{formatDate(reply.createdAt)}</span>
</div>
{/* P2-4: 线程中已撤回回复显示占位文案 */}
{replyRecalled ? (
<p className="text-muted-foreground italic text-sm">{t("status.recalled")}</p>
) : (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{reply.content}</p>
)}
</div>
)
})
)}
</CardContent>
</Card>
@@ -174,6 +332,29 @@ export function MessageDetail({
onConfirm={handleDelete}
isWorking={isWorking}
/>
{/* P2-4: 撤回确认对话框 */}
<Dialog open={recallOpen} onOpenChange={setRecallOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("actions.recall")}</DialogTitle>
<DialogDescription>{t("messages.recallConfirm")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRecallOpen(false)} disabled={isRecalling}>
{t("actions.cancel")}
</Button>
<Button onClick={handleRecall} disabled={isRecalling}>
{isRecalling ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<RotateCcw className="mr-2 h-4 w-4" aria-hidden="true" />
)}
{t("actions.recall")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,136 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { FileText, Loader2, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import { cn, formatDate } from "@/shared/lib/utils"
import { deleteMessageDraftAction } from "../actions"
import type { MessageDraft } from "../types"
/**
* P1-1: 草稿列表组件。
*
* 渲染用户的未发送草稿,支持"继续编辑"(带 draftId 参数回到 compose 页)
* 和"删除草稿"操作。compose page 通过 RSC 获取初始草稿列表传入;
* 删除操作通过 Server Action 调用,使用乐观更新 + 失败回滚。
*/
export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) {
const t = useTranslations("messages")
const [items, setItems] = useState<MessageDraft[]>(drafts)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [deleteOpen, setDeleteOpen] = useState<string | null>(null)
const handleDelete = async (draftId: string) => {
setDeletingId(draftId)
// 乐观更新:立即从列表移除
const snapshot = items
setItems((prev) => prev.filter((d) => d.id !== draftId))
try {
const res = await deleteMessageDraftAction(draftId)
if (!res.success) {
// 回滚
setItems(snapshot)
toast.error(res.message)
} else {
toast.success(t("messages.draftDeleted"))
}
} catch {
setItems(snapshot)
toast.error(t("messages.sendFailed"))
} finally {
setDeletingId(null)
setDeleteOpen(null)
}
}
if (items.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="py-6">
<EmptyState
icon={FileText}
title={t("empty.noDrafts")}
description={t("empty.inboxEmptyDesc")}
className="h-auto border-none shadow-none"
/>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" aria-hidden="true" />
{t("drafts.title")}
<span className="text-muted-foreground text-xs">({items.length})</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{items.map((draft) => {
const draftHref = draft.receiverId
? `/messages/compose?draftId=${encodeURIComponent(draft.id)}&receiverId=${encodeURIComponent(draft.receiverId)}${
draft.subject ? `&subject=${encodeURIComponent(draft.subject)}` : ""
}`
: `/messages/compose?draftId=${encodeURIComponent(draft.id)}`
return (
<div
key={draft.id}
className={cn(
"flex items-start justify-between gap-3 rounded-md border p-3 transition-colors hover:bg-accent/50"
)}
>
<div className="min-w-0 flex-1 space-y-1">
<p className="truncate text-sm font-medium">
{draft.subject || t("meta.noSubject")}
</p>
<p className="text-muted-foreground line-clamp-1 text-xs">
{draft.content || t("drafts.noContent")}
</p>
<p className="text-muted-foreground text-xs">
{t("drafts.updatedAt", { date: formatDate(draft.updatedAt) })}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="ghost" size="sm" aria-label={t("drafts.resume")}>
<Link href={draftHref}>{t("drafts.resume")}</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => setDeleteOpen(draft.id)}
disabled={deletingId === draft.id}
aria-label={t("drafts.delete")}
>
{deletingId === draft.id ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Trash2 className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
<ConfirmDeleteDialog
open={deleteOpen === draft.id}
onOpenChange={(v) => setDeleteOpen(v ? draft.id : null)}
title={t("drafts.delete")}
description={t("empty.deleteDesc", { subject: draft.subject || t("meta.noSubject") })}
onConfirm={() => handleDelete(draft.id)}
isWorking={deletingId === draft.id}
/>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,201 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { ArrowLeft, Send, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { sendGroupMessageAction } from "../actions"
import { MessageTemplatePicker } from "./message-template-picker"
type FieldErrors = Record<string, string[]>
/** P2-2: 班级选项id + name由页面层从教师 DataScope 解析后传入 */
export interface ClassOption {
id: string
name: string
}
/**
* P2-2: 群组消息撰写组件(教师→全班家长)。
*
* 与单聊 compose 的区别:
* - 收件人替换为"班级"选择器(仅限当前教师 DataScope.classIds 范围)
* - 调用 sendGroupMessageActionfan-out on write按 groupMessageId 聚合)
* - 不支持草稿(群发场景无草稿需求)
* - 不支持 parentMessageId群发不作为某条消息的回复
*/
export function MessageGroupCompose({
classOptions,
backHref = "/messages",
}: {
classOptions: ClassOption[]
backHref?: string
}) {
const t = useTranslations("messages")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [classId, setClassId] = useState("")
const [subject, setSubject] = useState("")
const [content, setContent] = useState("")
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const getFieldError = (field: string): string | null => {
const errs = fieldErrors[field]
return errs && errs.length > 0 ? errs[0] : null
}
const handleSubmit = async (formData: FormData) => {
if (!classId) {
toast.error(t("form.selectClass"))
return
}
formData.set("classId", classId)
setIsWorking(true)
setFieldErrors({})
try {
const res = await sendGroupMessageAction(null, formData)
if (res.success) {
const count = res.data?.recipientCount ?? 0
toast.success(t("messages.groupSent", { count }))
router.push("/messages")
router.refresh()
} else {
// 识别业务层返回的特定错误(无收件人 / 越权)
const msg = res.message ?? ""
if (msg.includes("No recipients")) {
toast.error(t("messages.groupNoRecipients"))
} else if (msg.includes("Not allowed")) {
toast.error(t("messages.groupNotAuthorized"))
} else {
toast.error(t("messages.groupSendFailed"))
}
if (res.errors) {
setFieldErrors(res.errors)
}
}
} catch {
toast.error(t("messages.groupSendFailed"))
} finally {
setIsWorking(false)
}
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon" aria-label={t("actions.back")}>
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<CardTitle>{t("title.groupCompose")}</CardTitle>
</div>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="classId">{t("form.class")}</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger aria-invalid={!!getFieldError("classId")}>
<SelectValue placeholder={t("form.classPlaceholder")} />
</SelectTrigger>
<SelectContent>
{classOptions.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="classId" value={classId} />
{getFieldError("classId") ? (
<p className="text-destructive text-xs">{getFieldError("classId")}</p>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor="subject">{t("form.subject")}</Label>
<Input
id="subject"
name="subject"
placeholder={t("form.subjectPlaceholder")}
value={subject}
onChange={(e) => setSubject(e.target.value)}
maxLength={255}
aria-invalid={!!getFieldError("subject")}
/>
{getFieldError("subject") ? (
<p className="text-destructive text-xs">{getFieldError("subject")}</p>
) : null}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="content">{t("form.content")}</Label>
<MessageTemplatePicker
onInsert={(templateContent) => {
setContent((prev) => (prev ? `${prev}\n\n${templateContent}` : templateContent))
}}
/>
</div>
<Textarea
id="content"
name="content"
placeholder={t("form.contentPlaceholder")}
className="min-h-[200px]"
value={content}
onChange={(e) => setContent(e.target.value)}
required
aria-invalid={!!getFieldError("content")}
/>
{getFieldError("content") ? (
<p className="text-destructive text-xs">{getFieldError("content")}</p>
) : null}
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button
type="button"
variant="ghost"
onClick={() => router.push(backHref)}
disabled={isWorking}
>
{t("actions.cancel")}
</Button>
<Button type="submit" disabled={isWorking || !classId}>
{isWorking ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t("actions.sending")}
</span>
) : (
<span className="flex items-center gap-2">
<Send className="h-4 w-4" aria-hidden="true" />
{t("actions.send")}
</span>
)}
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,23 @@
import type { JSX } from "react"
import { getMessages } from "@/modules/messaging/data-access"
import { MessageList } from "@/modules/messaging/components/message-list"
/**
* P2-9: 消息列表区块Server Component
*
* 独立获取数据,配合 Suspense + ErrorBoundary 实现流式渲染:
* - 页面立即渲染,此区块异步加载
* - 加载中显示 MessageListSkeleton
* - 加载失败由 SectionErrorBoundary 捕获,不影响其他区块
*/
export async function MessageListSection({
userId,
canGroupSend = false,
}: {
userId: string
canGroupSend?: boolean
}): Promise<JSX.Element> {
const result = await getMessages({ userId, type: "all", page: 1, pageSize: 50 })
return <MessageList messages={result.items} currentUserId={userId} canGroupSend={canGroupSend} />
}

View File

@@ -0,0 +1,36 @@
import type { JSX } from "react"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* P2-9: 消息列表骨架屏。
*
* 作为 Suspense fallback在 MessageListSection 流式加载期间展示。
*/
export function MessageListSkeleton(): JSX.Element {
return (
<div className="space-y-4" role="status" aria-label="Loading messages">
<div className="flex items-center justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-28" />
</div>
<Skeleton className="h-10 w-full" />
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i} aria-hidden="true">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-3 w-20" />
</CardHeader>
<CardContent className="pt-0">
<Skeleton className="h-4 w-full" />
<Skeleton className="mt-1 h-4 w-3/4" />
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useMemo, useState } from "react"
import Link from "next/link"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star } from "lucide-react"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
@@ -16,11 +16,14 @@ import { cn, formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { getMessagesAction, toggleMessageStarAction } from "../actions"
import { useMessageSearch } from "../hooks/use-message-search"
import { useMessageListService } from "../services/message-list-service-context"
import type { Message, MessageType } from "../types"
type Tab = "inbox" | "sent"
// P1-2: 新增 "starred" Tab 类型
type Tab = "inbox" | "sent" | "starred"
const isTab = (v: string): v is Tab => v === "inbox" || v === "sent" || v === "starred"
/** 客户端分页大小 */
const PAGE_SIZE = 20
@@ -29,31 +32,62 @@ export function MessageList({
messages,
currentUserId,
initialType = "inbox",
canGroupSend = false,
}: {
messages: Message[]
currentUserId: string
initialType?: MessageType
canGroupSend?: boolean
}) {
const t = useTranslations("messages")
const [tab, setTab] = useState<Tab>(initialType === "sent" ? "sent" : "inbox")
const [currentPage, setCurrentPage] = useState(1)
const [starredOverride, setStarredOverride] = useState<Record<string, boolean>>({})
const [togglingStarId, setTogglingStarId] = useState<string | null>(null)
const [starredMessages, setStarredMessages] = useState<Message[] | null>(null)
const [loadingStarred, setLoadingStarred] = useState(false)
// P2-4: 撤回状态覆盖(按消息 ID 维度,撤回成功后立即隐藏原内容)
const [recalledOverride, setRecalledOverride] = useState<Record<string, boolean>>({})
const [recallingId, setRecallingId] = useState<string | null>(null)
const { hasPermission } = usePermission()
const canSend = hasPermission(Permissions.MESSAGE_SEND)
// P1-8: 通过依赖注入获取消息列表服务,而非直接 import actions
const messageService = useMessageListService()
const { keyword, setKeyword, results, searching, isUsingInitial } = useMessageSearch({
searchAction: getMessagesAction,
tab,
searchAction: messageService.search,
tab: tab === "starred" ? "inbox" : tab,
})
// P1-2: 切换到 starred Tab 时单独加载星标消息type=all + starredOnly=true
// 由于 useMessageSearch 仅支持 inbox/sent 搜索starred Tab 使用独立加载逻辑
const loadStarred = useCallback(async () => {
setLoadingStarred(true)
try {
const res = await messageService.search({ type: "all", starredOnly: true })
setStarredMessages(res.success && res.data ? res.data.items : [])
} catch {
setStarredMessages([])
} finally {
setLoadingStarred(false)
}
}, [messageService])
// 客户端过滤仅在初始数据type=all时需要搜索结果已由服务端按 tab 过滤
// P1-2: starred Tab 使用 starredMessages 状态
const filtered = useMemo(() => {
if (tab === "starred") {
// starred Tab若有搜索关键字则用搜索结果否则用 starredMessages
if (keyword.trim().length > 0 && results) {
return results.filter((m) => m.receiverId === currentUserId)
}
return starredMessages ?? []
}
const displayMessages = isUsingInitial ? messages : (results ?? [])
if (!isUsingInitial) return displayMessages
if (tab === "inbox") return displayMessages.filter((m) => m.receiverId === currentUserId)
return displayMessages.filter((m) => m.senderId === currentUserId)
}, [messages, results, tab, currentUserId, isUsingInitial])
}, [messages, results, tab, currentUserId, isUsingInitial, starredMessages, keyword])
// 客户端分页:超过 PAGE_SIZE 条时显示分页 UI
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
@@ -63,8 +97,13 @@ export function MessageList({
// 切换 tab 或搜索时重置分页
const handleTabChange = (v: string): void => {
setTab(v as Tab)
if (!isTab(v)) return
setTab(v)
setCurrentPage(1)
// P1-2: 切换到 starred 时触发加载
if (v === "starred") {
void loadStarred()
}
}
const handleKeywordChange = (v: string): void => {
@@ -85,9 +124,13 @@ export function MessageList({
// 乐观更新
setStarredOverride((prev) => ({ ...prev, [messageId]: !currentStarred }))
try {
const res = await toggleMessageStarAction(messageId)
const res = await messageService.toggleStar(messageId)
if (res.success) {
toast.success(t("messages.starToggled"))
// P1-2: 当前在 starred Tab 时重新加载星标列表
if (tab === "starred") {
void loadStarred()
}
} else {
// 回滚
setStarredOverride((prev) => ({ ...prev, [messageId]: currentStarred }))
@@ -101,9 +144,55 @@ export function MessageList({
setTogglingStarId(null)
}
},
[t]
[t, tab, loadStarred, messageService]
)
// P2-4: 撤回消息仅发送方2 分钟窗口内)
const handleRecall = useCallback(
async (e: React.MouseEvent, message: Message): Promise<void> => {
e.preventDefault()
e.stopPropagation()
setRecallingId(message.id)
try {
const res = await messageService.recall(message.id)
if (res.success) {
// 同步 UI立即显示"已撤回"占位
setRecalledOverride((prev) => ({ ...prev, [message.id]: true }))
toast.success(t("messages.recalled"))
} else {
if (res.message === "Recall window expired") {
toast.error(t("messages.recallExpired"))
} else {
toast.error(res.message || t("messages.recallFailed"))
}
}
} catch {
toast.error(t("messages.recallFailed"))
} finally {
setRecallingId(null)
}
},
[t, messageService]
)
const getIsRecalled = (m: Message): boolean => {
const override = recalledOverride[m.id]
return override === undefined ? m.recalledAt !== null : override
}
// P1-4: 空状态文案按 Tab 区分
const getEmptyState = () => {
if (tab === "starred") {
return {
title: t("empty.noStarred"),
description: t("empty.inboxEmptyDesc"),
}
}
return tab === "inbox"
? { title: t("empty.inboxEmpty"), description: t("empty.inboxEmptyDesc") }
: { title: t("empty.sentEmpty"), description: t("empty.sentEmptyDesc") }
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -117,15 +206,30 @@ export function MessageList({
<Send className="h-4 w-4" />
{t("tabs.sent")}
</TabsTrigger>
{/* P1-2: 新增星标 Tab */}
<TabsTrigger value="starred" className="gap-2">
<Star className="h-4 w-4" />
{t("actions.star")}
</TabsTrigger>
</TabsList>
</Tabs>
{canSend ? (
<Button asChild>
<Link href="/messages/compose">
<Plus className="mr-2 h-4 w-4" />
{t("actions.compose")}
</Link>
</Button>
<div className="flex gap-2">
<Button asChild>
<Link href="/messages/compose">
<Plus className="mr-2 h-4 w-4" />
{t("actions.compose")}
</Link>
</Button>
{canGroupSend ? (
<Button asChild variant="outline">
<Link href="/messages/group-compose">
<Users className="mr-2 h-4 w-4" />
{t("title.groupCompose")}
</Link>
</Button>
) : null}
</div>
) : null}
</div>
@@ -135,24 +239,21 @@ export function MessageList({
<Input
type="search"
aria-label={t("search.placeholder")}
aria-busy={searching || loadingStarred}
placeholder={t("search.placeholder")}
value={keyword}
onChange={(e) => handleKeywordChange(e.target.value)}
className="pl-9"
/>
{searching ? (
{searching || loadingStarred ? (
<Loader2 className="text-muted-foreground absolute right-3 top-1/2 size-4 -translate-y-1/2 animate-spin" aria-hidden="true" />
) : null}
</div>
{paged.length === 0 ? (
<EmptyState
title={tab === "inbox" ? t("empty.inboxEmpty") : t("empty.sentEmpty")}
description={
tab === "inbox"
? t("empty.inboxEmptyDesc")
: t("empty.sentEmptyDesc")
}
title={getEmptyState().title}
description={getEmptyState().description}
icon={Mail}
className="h-auto border-none shadow-none"
/>
@@ -164,21 +265,36 @@ export function MessageList({
const counterpart = isReceived ? m.senderName : m.receiverName
const unread = isReceived && !m.isRead
const isStarred = getIsStarred(m)
// P2-4: 撤回状态
const isRecalled = getIsRecalled(m)
const canRecallItem =
!isReceived &&
canSend &&
!isRecalled &&
Date.now() - new Date(m.createdAt).getTime() <= 2 * 60 * 1000
return (
<Link key={m.id} href={`/messages/${m.id}`} className="block" aria-label={m.subject ?? t("meta.noSubject")}>
<Card className={cn("transition-colors hover:bg-accent/50", unread && "border-primary/40")}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
{unread ? (
{isRecalled ? (
<RotateCcw className="text-muted-foreground h-4 w-4" aria-hidden="true" />
) : unread ? (
<Mail className="h-4 w-4 text-primary" aria-hidden="true" />
) : (
<MailOpen className="text-muted-foreground h-4 w-4" aria-hidden="true" />
)}
<span className={cn("text-sm font-medium", unread && "text-primary")}>
<span className={cn("text-sm font-medium", unread && !isRecalled && "text-primary")}>
{m.subject ?? t("meta.noSubject")}
</span>
{unread ? <Badge variant="default" className="text-xs">{t("status.new")}</Badge> : null}
{/* P2-4: 已撤回优先显示徽章 */}
{isRecalled ? (
<Badge variant="secondary" className="text-xs">{t("status.recalled")}</Badge>
) : null}
{unread && !isRecalled ? (
<Badge variant="default" className="text-xs">{t("status.new")}</Badge>
) : null}
{isStarred ? (
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" aria-hidden="true" />
) : null}
@@ -188,11 +304,24 @@ export function MessageList({
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{/* P2-4: 撤回按钮(仅发送方在 2 分钟窗口内可见) */}
{canRecallItem ? (
<button
type="button"
onClick={(e) => void handleRecall(e, m)}
disabled={recallingId === m.id}
aria-label={t("actions.recall")}
className="text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={(e) => void handleToggleStar(e, m.id, isStarred)}
disabled={togglingStarId === m.id}
aria-label={isStarred ? t("actions.unstar") : t("actions.star")}
aria-pressed={isStarred}
className="text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<Star
@@ -208,9 +337,14 @@ export function MessageList({
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
{m.content}
</p>
{/* P2-4: 已撤回消息显示占位文案,隐藏原始内容 */}
{isRecalled ? (
<p className="text-muted-foreground italic text-sm">{t("status.recalled")}</p>
) : (
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
{m.content}
</p>
)}
</CardContent>
</Card>
</Link>
@@ -219,25 +353,25 @@ export function MessageList({
</div>
{showPagination ? (
<div className="flex items-center justify-center gap-4 pt-2" role="navigation" aria-label={t("tabs.inbox")}>
<div className="flex items-center justify-center gap-4 pt-2" role="navigation" aria-label={t("pagination.nav")}>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
aria-label="Previous page"
aria-label={t("pagination.previous")}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-muted-foreground text-sm" aria-live="polite">
{safePage} / {totalPages}
{t("pagination.page", { current: safePage, total: totalPages })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
aria-label="Next page"
aria-label={t("pagination.next")}
>
<ChevronRight className="h-4 w-4" />
</Button>

View File

@@ -0,0 +1,229 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Flag, Ban, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { blockUserAction, reportMessageAction } from "../actions"
import type { MessageReportReason } from "../types"
const REPORT_REASONS: MessageReportReason[] = ["spam", "harassment", "inappropriate", "other"]
/**
* P2-5: 消息举报 + 用户屏蔽按钮组。
*
* 仅在"收到的消息"(当前用户为 receiver时显示
* - 举报:打开 Dialog 选择理由 + 补充描述 → reportMessageAction
* - 屏蔽:打开确认 Dialog → blockUserAction屏蔽发送方
*
* 不能举报/屏蔽自己的消息。
*/
export function MessageReportBlock({
messageId,
senderId,
currentUserId,
}: {
messageId: string
senderId: string
currentUserId: string
}) {
const t = useTranslations("messages")
const router = useRouter()
const [reportOpen, setReportOpen] = useState(false)
const [blockOpen, setBlockOpen] = useState(false)
const [reason, setReason] = useState<MessageReportReason>("spam")
const [description, setDescription] = useState("")
const [isReporting, setIsReporting] = useState(false)
const [isBlocking, setIsBlocking] = useState(false)
// 不能举报/屏蔽自己
if (senderId === currentUserId) return null
const handleReport = async (formData: FormData) => {
setIsReporting(true)
try {
const res = await reportMessageAction(null, formData)
if (res.success) {
toast.success(t("messages.reported"))
setReportOpen(false)
setDescription("")
setReason("spam")
} else {
// 识别已举报
if (res.message?.includes("Already reported")) {
toast.error(t("messages.alreadyReported"))
} else if (res.message?.includes("own message")) {
toast.error(t("messages.reportSelf"))
} else {
toast.error(res.message || t("messages.reportFailed"))
}
}
} catch {
toast.error(t("messages.reportFailed"))
} finally {
setIsReporting(false)
}
}
const handleBlock = async () => {
setIsBlocking(true)
try {
const formData = new FormData()
formData.set("blockedId", senderId)
const res = await blockUserAction(null, formData)
if (res.success) {
toast.success(t("messages.blocked"))
setBlockOpen(false)
router.refresh()
} else {
if (res.message?.includes("already")) {
toast.error(t("messages.alreadyBlocked"))
} else if (res.message?.includes("yourself")) {
toast.error(t("messages.blockSelf"))
} else {
toast.error(res.message || t("messages.blockFailed"))
}
}
} catch {
toast.error(t("messages.blockFailed"))
} finally {
setIsBlocking(false)
}
}
return (
<>
<Button
onClick={() => setReportOpen(true)}
variant="ghost"
size="sm"
disabled={isReporting}
>
<Flag className="mr-2 h-4 w-4" aria-hidden="true" />
{t("actions.report")}
</Button>
<Button
onClick={() => setBlockOpen(true)}
variant="ghost"
size="sm"
disabled={isBlocking}
>
<Ban className="mr-2 h-4 w-4" aria-hidden="true" />
{t("actions.block")}
</Button>
{/* 举报 Dialog */}
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("report.title")}</DialogTitle>
<DialogDescription>{t("report.description")}</DialogDescription>
</DialogHeader>
<form action={handleReport} className="space-y-4">
<input type="hidden" name="messageId" value={messageId} />
<input type="hidden" name="reason" value={reason} />
<div className="grid gap-2">
<Label htmlFor="report-reason">{t("report.reason")}</Label>
<Select value={reason} onValueChange={(v) => setReason(v as MessageReportReason)}>
<SelectTrigger id="report-reason">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_REASONS.map((r) => (
<SelectItem key={r} value={r}>
{t(`report.reason${r.charAt(0).toUpperCase()}${r.slice(1)}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="report-description">{t("report.descriptionField")}</Label>
<Textarea
id="report-description"
name="description"
placeholder={t("report.descriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={1000}
className="min-h-[100px]"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setReportOpen(false)}
disabled={isReporting}
>
{t("report.cancel")}
</Button>
<Button type="submit" disabled={isReporting}>
{isReporting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Flag className="mr-2 h-4 w-4" aria-hidden="true" />
)}
{t("report.confirm")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* 屏蔽确认 Dialog */}
<Dialog open={blockOpen} onOpenChange={setBlockOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("block.title")}</DialogTitle>
<DialogDescription>{t("block.confirm")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setBlockOpen(false)}
disabled={isBlocking}
>
{t("report.cancel")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleBlock}
disabled={isBlocking}
>
{isBlocking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Ban className="mr-2 h-4 w-4" aria-hidden="true" />
)}
{t("actions.block")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,297 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { ChevronDown, FileText, Plus, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { cn, formatDate } from "@/shared/lib/utils"
import {
deleteMessageTemplateAction,
getMessageTemplatesAction,
saveMessageTemplateAction,
} from "../actions"
import type { MessageTemplate } from "../types"
/**
* P2-6: 快捷回复模板选择器
*
* 在 compose 页面展示"插入模板"下拉,点击模板追加到正文。
* 内置模板管理弹窗(创建/编辑/删除)。
*/
export function MessageTemplatePicker({
onInsert,
}: {
onInsert: (content: string) => void
}) {
const t = useTranslations("messages")
const [templates, setTemplates] = useState<MessageTemplate[]>([])
const [loading, setLoading] = useState(true)
const [manageOpen, setManageOpen] = useState(false)
const [editing, setEditing] = useState<MessageTemplate | null>(null)
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [sortOrder, setSortOrder] = useState("0")
const [isSaving, setIsSaving] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const loadTemplates = useCallback(async () => {
setLoading(true)
try {
const res = await getMessageTemplatesAction()
if (res.success && res.data) {
setTemplates(res.data)
} else {
setTemplates([])
}
} catch {
setTemplates([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadTemplates()
}, [loadTemplates])
const handleInsert = (template: MessageTemplate): void => {
onInsert(template.content)
toast.success(t("templates.insert"))
}
const openCreate = (): void => {
setEditing(null)
setTitle("")
setContent("")
setSortOrder("0")
setManageOpen(true)
}
const openEdit = (template: MessageTemplate): void => {
setEditing(template)
setTitle(template.title)
setContent(template.content)
setSortOrder(String(template.sortOrder))
setManageOpen(true)
}
const handleSave = async (formData: FormData): Promise<void> => {
setIsSaving(true)
try {
const res = await saveMessageTemplateAction(null, formData)
if (res.success) {
toast.success(t("templates.saved"))
setManageOpen(false)
await loadTemplates()
} else {
toast.error(res.message)
}
} catch {
toast.error(t("templates.saved"))
} finally {
setIsSaving(false)
}
}
const handleDelete = async (templateId: string): Promise<void> => {
setDeletingId(templateId)
try {
const res = await deleteMessageTemplateAction(templateId)
if (res.success) {
toast.success(t("templates.deleted"))
await loadTemplates()
} else {
toast.error(res.message)
}
} catch {
toast.error(t("templates.deleted"))
} finally {
setDeletingId(null)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" disabled={loading}>
<FileText className="mr-2 h-4 w-4" />
{t("templates.insert")}
<ChevronDown className="ml-1 h-3 w-3" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuLabel>{t("templates.title")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{templates.length === 0 ? (
<div className="text-muted-foreground px-2 py-3 text-sm">
{t("templates.empty")}
</div>
) : (
templates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => handleInsert(template)}
className="flex flex-col items-start gap-1"
>
<span className="text-sm font-medium">{template.title}</span>
<span className="text-muted-foreground line-clamp-1 text-xs">
{template.content}
</span>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
{t("templates.create")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setManageOpen(true)}>
<FileText className="mr-2 h-4 w-4" />
{t("templates.manage")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 模板管理弹窗 */}
<Dialog open={manageOpen} onOpenChange={setManageOpen}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editing ? t("templates.edit") : t("templates.create")}
</DialogTitle>
<DialogDescription>{t("templates.emptyDesc")}</DialogDescription>
</DialogHeader>
{/* 已有模板列表(编辑模式下显示在表单下方) */}
{templates.length > 0 ? (
<div className="space-y-2">
{templates.map((template) => (
<div
key={template.id}
className={cn(
"flex items-center justify-between gap-2 rounded-md border p-2",
editing?.id === template.id && "border-primary/40 bg-primary/5"
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{template.title}</span>
<span className="text-muted-foreground text-xs">
{formatDate(template.updatedAt)}
</span>
</div>
<p className="text-muted-foreground line-clamp-1 text-xs">
{template.content}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => openEdit(template)}
>
{t("templates.edit")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void handleDelete(template.id)}
disabled={deletingId === template.id}
aria-label={t("templates.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">{t("templates.empty")}</p>
)}
{/* 新建/编辑表单 */}
<form action={handleSave} className="space-y-4 pt-2">
{editing ? (
<input type="hidden" name="templateId" value={editing.id} />
) : null}
<div className="grid gap-2">
<Label htmlFor="template-title">{t("templates.titleField")}</Label>
<Input
id="template-title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("templates.titlePlaceholder")}
maxLength={100}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="template-content">{t("templates.contentField")}</Label>
<Textarea
id="template-content"
name="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t("templates.contentPlaceholder")}
className="min-h-[100px]"
maxLength={2000}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="template-sort">{t("templates.sortOrder")}</Label>
<Input
id="template-sort"
name="sortOrder"
type="number"
min={0}
max={9999}
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setManageOpen(false)}
disabled={isSaving}
>
{t("actions.cancel")}
</Button>
<Button type="submit" disabled={isSaving}>
{t("actions.saveDraft")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -10,21 +10,25 @@ import { getUnreadMessageCountAction } from "../actions"
* 未读消息计数徽章
*
* 在侧边栏 Messages 导航项旁显示未读私信数。
* 每 POLL_INTERVAL_MS 毫秒轮询一次以保持计数更新。
*
* 注意:当前 SSE 端点(/api/notifications/stream仅推送通知数据
* 不包含私信未读数,因此本组件仍使用轮询模式。轮询间隔与通知组件
* 保持一致30 秒),未来可扩展 SSE 端点同时推送消息未读数以实现实时更新
* P1-5: 优先订阅 SSE 端点(/api/notifications/stream获取实时更新
* payload.unreadMessageCount 字段即为未读私信数。
* SSE 不可用时自动降级为 30 秒轮询
*/
const POLL_INTERVAL_MS = 30_000
const SSE_URL = "/api/notifications/stream"
export function UnreadMessageBadge() {
const [count, setCount] = useState(0)
useEffect(() => {
let active = true
let es: EventSource | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let usingSSE = false
const fetchCount = async () => {
const fetchCount = async (): Promise<void> => {
if (!active) return
const res = await getUnreadMessageCountAction()
if (!active) return
if (res.success && typeof res.data === "number") {
@@ -32,15 +36,70 @@ export function UnreadMessageBadge() {
}
}
void fetchCount()
const timer = setInterval(() => {
const startPolling = (): void => {
if (pollTimer) return
void fetchCount()
}, POLL_INTERVAL_MS)
pollTimer = setInterval(() => {
void fetchCount()
}, POLL_INTERVAL_MS)
}
const stopPolling = (): void => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
// 尝试建立 SSE 连接
try {
es = new EventSource(SSE_URL)
usingSSE = true
es.onmessage = (event): void => {
if (!active) return
try {
const payload = JSON.parse(event.data) as {
type: "update" | "error" | string
unreadMessageCount?: number
}
if (payload.type === "update" && typeof payload.unreadMessageCount === "number") {
setCount(payload.unreadMessageCount)
}
} catch {
// 忽略解析错误
}
}
// SSE 连接失败时降级为轮询
es.onerror = (): void => {
if (!active) return
usingSSE = false
if (es) {
es.close()
es = null
}
startPolling()
}
} catch {
// EventSource 不可用,降级为轮询
usingSSE = false
startPolling()
}
// 兜底:如果 SSE 在 5 秒内未收到任何数据,启动轮询作为保险
const fallbackTimer = setTimeout(() => {
if (active && usingSSE && count === 0) {
// SSE 可能未推送初始数据,拉取一次
void fetchCount()
}
}, 5_000)
return () => {
active = false
clearInterval(timer)
if (es) es.close()
stopPolling()
clearTimeout(fallbackTimer)
}
}, [])
@@ -50,6 +109,7 @@ export function UnreadMessageBadge() {
<Badge
variant="destructive"
className="ml-auto flex h-5 min-w-5 items-center justify-center px-1.5 text-[10px]"
aria-label={`${count} unread messages`}
>
{count > 99 ? "99+" : count}
</Badge>

View File

@@ -8,20 +8,28 @@ import "server-only"
* - createMessage / markMessageAsRead / deleteMessage: 私信 CRUD
* - getUnreadMessageCount: 未读私信计数
* - getRecipients: 获取收件人列表(按 DataScope 过滤)
* - isReceiverAllowed: 校验收件人是否在 sender 的 DataScope 内P0-1 安全二次校验)
*
* 通知相关函数createNotification / getNotifications /
* markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount
* 已迁移到 notifications/data-access.ts请直接从该模块导入。
*
* 变更历史:
* - P0-2: getMessageThread 增加 userId 参数,做权限校验
* - P0-3: getMessagesPageData 编排迁出(移至 messages/page.tsx 页面层)
*/
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, isNull, like, or, type SQL } from "drizzle-orm"
import { and, count, desc, eq, gte, inArray, isNull, like, lte, or, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
messages,
messageDrafts,
messageTemplates,
messageReports,
userBlocks,
users,
} from "@/shared/db/schema"
import {
@@ -30,18 +38,31 @@ import {
getTeacherIdsByClassIds,
getStudentActiveClassId,
} from "@/modules/classes/data-access"
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getFileAttachmentsByTarget } from "@/modules/files/data-access"
import type { DataScope } from "@/shared/types/permissions"
import type { PaginatedResult } from "@/modules/notifications/types"
import type {
Message,
GetMessagesParams,
CreateMessageInput,
RecipientOption,
RecipientRole,
MessageDraft,
CreateMessageDraftInput,
UpdateMessageDraftInput,
MessageTemplate,
CreateMessageTemplateInput,
UpdateMessageTemplateInput,
RecipientResolverContext,
MessagingRoleConfig,
MessageReport,
CreateMessageReportInput,
MessageReportReason,
UserBlock,
MessageAttachment,
} from "./types"
import type { PaginatedResult } from "@/modules/notifications/types"
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
@@ -55,8 +76,10 @@ interface MessageRow {
content: string
isRead: boolean
isStarred: boolean
recalledAt: Date | null
readAt: Date | null
parentMessageId: string | null
groupMessageId: string | null
createdAt: Date
}
@@ -80,8 +103,10 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
content: r.content,
isRead: r.isRead,
isStarred: r.isStarred,
recalledAt: toIso(r.recalledAt),
readAt: toIso(r.readAt),
parentMessageId: r.parentMessageId,
groupMessageId: r.groupMessageId,
createdAt: toIsoRequired(r.createdAt),
})
@@ -119,6 +144,32 @@ export const getMessages = cache(
conds.push(eq(messages.isStarred, true))
}
// P2-11: 按发件人 ID 筛选
if (params.senderId) {
conds.push(eq(messages.senderId, params.senderId))
}
// P2-11: 按日期范围筛选
if (params.dateFrom) {
const fromDate = new Date(params.dateFrom)
if (!isNaN(fromDate.getTime())) {
conds.push(gte(messages.createdAt, fromDate))
}
}
if (params.dateTo) {
const toDate = new Date(params.dateTo)
if (!isNaN(toDate.getTime())) {
conds.push(lte(messages.createdAt, toDate))
}
}
// P2-11: 按已读状态筛选
if (params.isRead === true) {
conds.push(eq(messages.isRead, true))
} else if (params.isRead === false) {
conds.push(eq(messages.isRead, false))
}
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset),
@@ -153,8 +204,18 @@ export const getMessageById = cache(
}
)
export const getMessageThread = cache(async (messageId: string): Promise<Message[]> => {
const [root] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1)
export const getMessageThread = cache(async (messageId: string, userId: string): Promise<Message[]> => {
// P0-2: 校验当前用户对根消息有访问权(必须是发送方或接收方)
const [root] = await db
.select()
.from(messages)
.where(
and(
eq(messages.id, messageId),
or(eq(messages.senderId, userId), eq(messages.receiverId, userId))
)
)
.limit(1)
if (!root) return []
const replies = await db
@@ -181,6 +242,64 @@ export async function createMessage(data: CreateMessageInput): Promise<string> {
return id
}
// ---------------------------------------------------------------------------
// P2-2: 群组消息教师→全班家长fan-out on write
// ---------------------------------------------------------------------------
export interface SendGroupMessageInput {
senderId: string
classId: string
subject: string | null
content: string
}
export interface SendGroupMessageResult {
groupMessageId: string
recipientIds: string[]
}
/**
* P2-2: 群发消息给指定班级的所有家长。
*
* 采用 fan-out on write 策略:为每个家长创建独立的 messages 行,
* 共享同一 groupMessageId 便于后续聚合查询(如"已发送群组消息"列表)。
*
* 通过 classes data-access 获取学生 ID再通过 parent data-access
* 获取家长 ID避免直接 JOIN classEnrollments / parentStudentRelations 表。
*/
export async function sendGroupMessage(
data: SendGroupMessageInput
): Promise<SendGroupMessageResult> {
// 1. 获取班级所有学生 ID
const studentIds = await getStudentIdsByClassIds([data.classId])
// 2. 获取这些学生的所有家长 ID去重
const parentIds = await getParentIdsByStudentIds(studentIds)
// 过滤掉发送者自己(避免教师给自己发消息)
const recipientIds = Array.from(new Set(parentIds)).filter((id) => id !== data.senderId)
if (recipientIds.length === 0) {
return { groupMessageId: "", recipientIds: [] }
}
// 3. 生成 groupMessageId 并批量插入
const groupMessageId = createId()
const now = new Date()
const rows = recipientIds.map((receiverId) => ({
id: createId(),
senderId: data.senderId,
receiverId,
subject: data.subject ?? null,
content: data.content,
parentMessageId: null,
groupMessageId,
createdAt: now,
}))
await db.insert(messages).values(rows)
return { groupMessageId, recipientIds }
}
export async function markMessageAsRead(id: string, userId: string): Promise<void> {
await db
.update(messages)
@@ -220,6 +339,120 @@ export async function toggleMessageStar(id: string, userId: string): Promise<voi
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
}
// ---------------------------------------------------------------------------
// P2-1: 批量操作
// ---------------------------------------------------------------------------
/** P2-1: 批量标记已读(仅当前用户为接收方的消息) */
export async function bulkMarkMessagesAsRead(ids: string[], userId: string): Promise<number> {
if (ids.length === 0) return 0
const result = await db
.update(messages)
.set({ isRead: true, readAt: new Date() })
.where(and(
inArray(messages.id, ids),
eq(messages.receiverId, userId),
eq(messages.isRead, false)
))
// MySqlRawQueryResult 是 [ResultSetHeader, FieldPacket[]] 元组affectedRows 在 [0]
return result[0]?.affectedRows ?? 0
}
/** P2-1: 批量删除(软删除,发送方/接收方各自标记) */
export async function bulkDeleteMessages(ids: string[], userId: string): Promise<void> {
if (ids.length === 0) return
const now = new Date()
await db.transaction(async (tx) => {
await tx
.update(messages)
.set({ senderDeletedAt: now })
.where(and(inArray(messages.id, ids), eq(messages.senderId, userId)))
await tx
.update(messages)
.set({ receiverDeletedAt: now })
.where(and(inArray(messages.id, ids), eq(messages.receiverId, userId)))
})
}
/** P2-1: 批量切换星标(仅当前用户为接收方的消息) */
export async function bulkToggleMessagesStar(ids: string[], userId: string): Promise<void> {
if (ids.length === 0) return
// 查询当前状态
const rows = await db
.select({ id: messages.id, isStarred: messages.isStarred })
.from(messages)
.where(and(inArray(messages.id, ids), eq(messages.receiverId, userId)))
if (rows.length === 0) return
// 按目标状态分组
const toStar = rows.filter((r) => !r.isStarred).map((r) => r.id)
const toUnstar = rows.filter((r) => r.isStarred).map((r) => r.id)
if (toStar.length > 0) {
await db
.update(messages)
.set({ isStarred: true })
.where(and(inArray(messages.id, toStar), eq(messages.receiverId, userId)))
}
if (toUnstar.length > 0) {
await db
.update(messages)
.set({ isStarred: false })
.where(and(inArray(messages.id, toUnstar), eq(messages.receiverId, userId)))
}
}
// ---------------------------------------------------------------------------
// P2-4: 消息撤回
// ---------------------------------------------------------------------------
/** P2-4: 撤回时间窗口(毫秒),发送后 2 分钟内可撤回 */
export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000
/**
* P2-4: 撤回消息。
*
* 规则:
* - 仅发送方可以撤回自己的消息
* - 必须在 MESSAGE_RECALL_WINDOW_MS 时间窗口内
* - 已撤回的消息recalledAt 非空)不能重复撤回
*
* 返回值:
* - "ok": 撤回成功
* - "not_found": 消息不存在或不属于当前用户
* - "expired": 超过撤回时间窗口
* - "already_recalled": 消息已被撤回
*/
export async function recallMessage(
id: string,
userId: string
): Promise<"ok" | "not_found" | "expired" | "already_recalled"> {
const [row] = await db
.select({
id: messages.id,
senderId: messages.senderId,
recalledAt: messages.recalledAt,
createdAt: messages.createdAt,
})
.from(messages)
.where(and(eq(messages.id, id), eq(messages.senderId, userId)))
.limit(1)
if (!row) return "not_found"
if (row.recalledAt) return "already_recalled"
const elapsed = Date.now() - row.createdAt.getTime()
if (elapsed > MESSAGE_RECALL_WINDOW_MS) return "expired"
await db
.update(messages)
.set({ recalledAt: new Date() })
.where(eq(messages.id, id))
return "ok"
}
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
@@ -228,78 +461,123 @@ export const getUnreadMessageCount = cache(async (userId: string): Promise<numbe
return Number(row?.value ?? 0)
})
// ---------------------------------------------------------------------------
// P2-7: 配置驱动的角色收件人解析器
//
// 每个 DataScope.type 对应一个 RecipientResolver将解析逻辑与主流程解耦。
// 新增角色或调整 DataScope 时仅需在 RECIPIENT_RESOLVERS 中增/改配置项,
// 无需修改 getRecipients 主流程(开闭原则)。
// ---------------------------------------------------------------------------
const resolveAdminRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
const { userId } = ctx
const all = await db.select({ id: users.id, name: users.name, email: users.email }).from(users)
// P1-6: admin 角色分支补 role
return all
.filter((r) => r.id !== userId)
.map((r) => ({ ...r, name: r.name ?? r.email, role: "admin" as RecipientRole }))
}
const resolveClassTaughtRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
const { userId, scope } = ctx
if (scope.type !== "class_taught" || scope.classIds.length === 0) return []
// 通过 classes data-access 获取学生 ID避免直接 JOIN classEnrollments 表
const studentIds = await getStudentIdsByClassIds(scope.classIds)
const userMap = await getUserNamesByIds(studentIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole }))
}
const resolveGradeManagedRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
const { userId, scope } = ctx
if (scope.type !== "grade_managed" || scope.gradeIds.length === 0) return []
// 通过 classes data-access 获取年级下所有班级,再获取学生 ID
// 避免直接 JOIN classes / classEnrollments 表
const classLists = await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))
const classIds = classLists.flat().map((c) => c.id)
const studentIds = await getStudentIdsByClassIds(classIds)
const userMap = await getUserNamesByIds(studentIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole }))
}
const resolveClassMembersRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
const { userId, scope } = ctx
if (scope.type !== "class_members" || scope.classIds.length === 0) return []
// 学生可以给自己班级的任课教师/班主任发消息
const teacherIds = await getTeacherIdsByClassIds(scope.classIds)
const userMap = await getUserNamesByIds(teacherIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole }))
}
const resolveChildrenRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
const { userId, scope } = ctx
if (scope.type !== "children" || scope.childrenIds.length === 0) return []
// 家长可以给孩子的班主任/任课教师发消息
const classIds = await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))
const validClassIds = classIds.filter((id): id is string => id !== null)
const teacherIds = await getTeacherIdsByClassIds(validClassIds)
const userMap = await getUserNamesByIds(teacherIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole }))
}
/** P2-7: 收件人解析器注册表,按 DataScope.type 索引 */
const RECIPIENT_RESOLVERS: Record<DataScope["type"], MessagingRoleConfig> = {
all: { scopeType: "all", resolve: resolveAdminRecipients },
owned: { scopeType: "owned", resolve: async () => [] },
class_taught: { scopeType: "class_taught", resolve: resolveClassTaughtRecipients },
grade_managed: { scopeType: "grade_managed", resolve: resolveGradeManagedRecipients },
class_members: { scopeType: "class_members", resolve: resolveClassMembersRecipients },
children: { scopeType: "children", resolve: resolveChildrenRecipients },
}
/**
* P2-7: 获取收件人列表(配置驱动)。
*
* 根据 DataScope.type 从 RECIPIENT_RESOLVERS 注册表查找对应解析器并调用。
* 新增角色或 DataScope 类型仅需:
* 1. 实现新的 RecipientResolver
* 2. 在 RECIPIENT_RESOLVERS 中新增配置项
* 无需修改本函数。
*/
export const getRecipients = cache(
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
if (scope.type === "all") {
const all = await db.select({ id: users.id, name: users.name, email: users.email }).from(users)
return all.filter((r) => r.id !== userId).map((r) => ({ ...r, name: r.name ?? r.email }))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
// 通过 classes data-access 获取学生 ID避免直接 JOIN classEnrollments 表
const studentIds = await getStudentIdsByClassIds(scope.classIds)
const userMap = await getUserNamesByIds(studentIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" }))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
// 通过 classes data-access 获取年级下所有班级,再获取学生 ID
// 避免直接 JOIN classes / classEnrollments 表
const classLists = await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))
const classIds = classLists.flat().map((c) => c.id)
const studentIds = await getStudentIdsByClassIds(classIds)
const userMap = await getUserNamesByIds(studentIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" }))
}
if (scope.type === "class_members" && scope.classIds.length > 0) {
// 学生可以给自己班级的任课教师/班主任发消息
const teacherIds = await getTeacherIdsByClassIds(scope.classIds)
const userMap = await getUserNamesByIds(teacherIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" }))
}
if (scope.type === "children" && scope.childrenIds.length > 0) {
// 家长可以给孩子的班主任/任课教师发消息
const classIds = await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))
const validClassIds = classIds.filter((id): id is string => id !== null)
const teacherIds = await getTeacherIdsByClassIds(validClassIds)
const userMap = await getUserNamesByIds(teacherIds)
return Array.from(userMap.values())
.filter((u) => u.id !== userId)
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" }))
}
return []
const config = RECIPIENT_RESOLVERS[scope.type]
if (!config) return []
return config.resolve({ userId, scope })
}
)
/**
* 消息首页编排函数:一次性获取消息列表和通知列表
* 将原本散落在 page.tsx 中的多模块编排逻辑下沉到 data-access 层,
* 页面层只需调用单一函数,提升可复用性与可测试性。
* P0-1: 校验收件人是否在 sender 的 DataScope 允许范围内
*
* 在 sendMessageAction 中作为 Server Action 二次校验,防止绕过前端 Select
* 直接构造 FormData 提交任意 receiverId 的越权发送。
*
* 实现复用 getRecipients 的结果,避免重复解析 DataScope。
*/
export async function getMessagesPageData(userId: string): Promise<{
messages: { items: Message[]; total: number; page: number; pageSize: number; totalPages: number }
notifications: { items: import("@/modules/notifications/types").Notification[]; total: number; page: number; pageSize: number; totalPages: number }
}> {
const { getNotifications } = await import("@/modules/notifications/data-access")
const [messagesResult, notificationsResult] = await Promise.all([
getMessages({ userId, type: "all", page: 1, pageSize: 50 }),
getNotifications(userId, { page: 1, pageSize: 20 }),
])
return {
messages: messagesResult,
notifications: notificationsResult,
}
export async function isReceiverAllowed(
userId: string,
receiverId: string,
scope: DataScope
): Promise<boolean> {
const recipients = await getRecipients(userId, scope)
return recipients.some((r) => r.id === receiverId)
}
/**
* 消息详情页编排函数:获取消息详情,并自动标记为已读(若当前用户为接收方且未读)。
* 使用 next/server 的 after() 实现非阻塞标记,避免阻塞页面渲染。
*
* 注消息首页编排getMessagesPageData已迁出至 messages/page.tsx 页面层,
* 由页面通过 Promise.all 调用 messaging.data-access 和 notifications.data-access
* 保持模块独立性P0-3 修复,避免 data-access 层跨模块动态 import
*/
export async function getMessageDetailPageData(
id: string,
@@ -316,6 +594,252 @@ export async function getMessageDetailPageData(
return message
}
// ---------------------------------------------------------------------------
// P2-5: 消息举报 + 用户屏蔽
// ---------------------------------------------------------------------------
/**
* P2-5: 举报消息。
*
* 防重复:同一用户对同一消息只能举报一次。已举报则返回 "already_reported"。
*/
export async function reportMessage(
data: CreateMessageReportInput
): Promise<"ok" | "already_reported"> {
// 检查是否已举报过该消息
const [existing] = await db
.select({ id: messageReports.id })
.from(messageReports)
.where(
and(
eq(messageReports.reporterId, data.reporterId),
eq(messageReports.messageId, data.messageId)
)
)
.limit(1)
if (existing) return "already_reported"
const id = createId()
await db.insert(messageReports).values({
id,
reporterId: data.reporterId,
messageId: data.messageId,
reportedUserId: data.reportedUserId,
reason: data.reason,
description: data.description ?? null,
})
return "ok"
}
/**
* P2-5: 检查用户是否已举报某条消息(用于 UI 按钮禁用状态)。
*/
export async function hasUserReportedMessage(
reporterId: string,
messageId: string
): Promise<boolean> {
const [row] = await db
.select({ id: messageReports.id })
.from(messageReports)
.where(
and(
eq(messageReports.reporterId, reporterId),
eq(messageReports.messageId, messageId)
)
)
.limit(1)
return !!row
}
/**
* P2-5: 屏蔽用户。
*
* 利用 unique 索引防止重复屏蔽:插入冲突时视为"已屏蔽"。
*/
export async function blockUser(
blockerId: string,
blockedId: string
): Promise<"ok" | "already_blocked" | "self_block"> {
if (blockerId === blockedId) return "self_block"
// 检查是否已屏蔽
const [existing] = await db
.select({ id: userBlocks.id })
.from(userBlocks)
.where(
and(
eq(userBlocks.blockerId, blockerId),
eq(userBlocks.blockedId, blockedId)
)
)
.limit(1)
if (existing) return "already_blocked"
const id = createId()
await db.insert(userBlocks).values({ id, blockerId, blockedId })
return "ok"
}
/**
* P2-5: 取消屏蔽用户。
*/
export async function unblockUser(
blockerId: string,
blockedId: string
): Promise<void> {
await db
.delete(userBlocks)
.where(
and(
eq(userBlocks.blockerId, blockerId),
eq(userBlocks.blockedId, blockedId)
)
)
}
/**
* P2-5: 检查 blocker 是否屏蔽了 blocked用于发送消息前校验
*/
export async function isUserBlocked(
blockerId: string,
blockedId: string
): Promise<boolean> {
const [row] = await db
.select({ id: userBlocks.id })
.from(userBlocks)
.where(
and(
eq(userBlocks.blockerId, blockerId),
eq(userBlocks.blockedId, blockedId)
)
)
.limit(1)
return !!row
}
/**
* P2-5: 判断两个用户之间是否存在屏蔽关系(任一方向)。
* 用于 sendMessage 校验:发送方或接收方任一屏蔽对方,则禁止发送。
*/
export async function isEitherUserBlocked(
userIdA: string,
userIdB: string
): Promise<boolean> {
const [row] = await db
.select({ id: userBlocks.id })
.from(userBlocks)
.where(
or(
and(eq(userBlocks.blockerId, userIdA), eq(userBlocks.blockedId, userIdB)),
and(eq(userBlocks.blockerId, userIdB), eq(userBlocks.blockedId, userIdA))
)
)
.limit(1)
return !!row
}
/**
* P2-5: 获取用户屏蔽的所有用户 ID用于收件人列表过滤
*/
export async function getBlockedUserIds(blockerId: string): Promise<string[]> {
const rows = await db
.select({ blockedId: userBlocks.blockedId })
.from(userBlocks)
.where(eq(userBlocks.blockerId, blockerId))
return rows.map((r) => r.blockedId)
}
/** P2-5: 映射举报行到接口类型 */
const mapMessageReport = (r: {
id: string
reporterId: string
messageId: string
reportedUserId: string
reason: string
description: string | null
status: string
createdAt: Date
updatedAt: Date
}): MessageReport => ({
id: r.id,
reporterId: r.reporterId,
messageId: r.messageId,
reportedUserId: r.reportedUserId,
reason: r.reason as MessageReportReason,
description: r.description,
status: r.status as MessageReport["status"],
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
/**
* P2-5: 获取举报列表admin 管理用,按状态筛选)。
*/
export async function getMessageReports(
status?: MessageReport["status"]
): Promise<MessageReport[]> {
const where = status ? eq(messageReports.status, status) : undefined
const query = db
.select()
.from(messageReports)
.orderBy(desc(messageReports.createdAt))
const rows = where ? await query.where(where) : await query
return rows.map(mapMessageReport)
}
/** P2-5: 映射屏蔽行到接口类型 */
const mapUserBlock = (r: {
id: string
blockerId: string
blockedId: string
createdAt: Date
}): UserBlock => ({
id: r.id,
blockerId: r.blockerId,
blockedId: r.blockedId,
createdAt: r.createdAt.toISOString(),
})
/**
* P2-5: 获取用户屏蔽列表。
*/
export async function getUserBlocks(blockerId: string): Promise<UserBlock[]> {
const rows = await db
.select()
.from(userBlocks)
.where(eq(userBlocks.blockerId, blockerId))
.orderBy(desc(userBlocks.createdAt))
return rows.map(mapUserBlock)
}
// ---------------------------------------------------------------------------
// P2-3: 消息附件(复用 fileAttachments 表targetType="message"
// ---------------------------------------------------------------------------
/** P2-3: 附件 target 类型常量,用于 fileAttachments 表关联 */
export const MESSAGE_ATTACHMENT_TARGET_TYPE = "message"
/**
* P2-3: 获取指定消息的所有附件。
*
* 复用 files 模块的 getFileAttachmentsByTarget避免直接查询 fileAttachments 表。
*/
export async function getMessageAttachments(messageId: string): Promise<MessageAttachment[]> {
const files = await getFileAttachmentsByTarget(MESSAGE_ATTACHMENT_TARGET_TYPE, messageId)
return files.map((f) => ({
id: f.id,
filename: f.filename,
originalName: f.originalName,
mimeType: f.mimeType,
size: f.size,
url: f.url,
// files 模块 FileAttachment.createdAt 已为 ISO 字符串
createdAt: f.createdAt,
}))
}
// ---------------------------------------------------------------------------
// V2-P2-13c: 消息草稿 CRUDmessage_drafts 表)
// ---------------------------------------------------------------------------
@@ -328,6 +852,8 @@ const mapDraft = (
subject: string | null
content: string | null
parentMessageId: string | null
deviceId: string | null
version: number
createdAt: Date
updatedAt: Date
},
@@ -340,6 +866,8 @@ const mapDraft = (
subject: r.subject,
content: r.content,
parentMessageId: r.parentMessageId,
deviceId: r.deviceId,
version: r.version,
createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt),
})
@@ -368,11 +896,45 @@ export async function createMessageDraft(data: CreateMessageDraftInput): Promise
subject: data.subject ?? null,
content: data.content ?? null,
parentMessageId: data.parentMessageId ?? null,
// P2-10: 记录来源设备
deviceId: data.deviceId ?? null,
// P2-10: 初始版本号 1
version: 1,
})
return id
}
export async function updateMessageDraft(id: string, userId: string, data: UpdateMessageDraftInput): Promise<void> {
/**
* P2-10: 更新草稿(带乐观锁)。
*
* - 客户端传入 expectedVersion与数据库当前 version 比对
* - 匹配则更新并 version+1
* - 不匹配(冲突)返回 "conflict",客户端应拉取最新版本并提示用户
*
* 返回值:
* - "ok": 更新成功
* - "not_found": 草稿不存在或不属于当前用户
* - "conflict": 版本冲突(其他设备已更新)
*/
export async function updateMessageDraft(
id: string,
userId: string,
data: UpdateMessageDraftInput
): Promise<"ok" | "not_found" | "conflict"> {
// P2-10: 乐观锁 - 先查询当前版本
const [existing] = await db
.select({ id: messageDrafts.id, version: messageDrafts.version })
.from(messageDrafts)
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
.limit(1)
if (!existing) return "not_found"
// P2-10: 版本冲突检测
if (data.expectedVersion !== undefined && data.expectedVersion !== existing.version) {
return "conflict"
}
await db
.update(messageDrafts)
.set({
@@ -380,8 +942,13 @@ export async function updateMessageDraft(id: string, userId: string, data: Updat
...(data.subject !== undefined && { subject: data.subject }),
...(data.content !== undefined && { content: data.content }),
...(data.parentMessageId !== undefined && { parentMessageId: data.parentMessageId }),
...(data.deviceId !== undefined && { deviceId: data.deviceId }),
// P2-10: 版本号自增
version: existing.version + 1,
})
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
return "ok"
}
export async function deleteMessageDraft(id: string, userId: string): Promise<void> {
@@ -402,3 +969,74 @@ export async function getMessageDraftById(id: string, userId: string): Promise<M
const nameMap = row.receiverId ? await resolveUserNames([row.receiverId]) : new Map<string, string>()
return mapDraft(row, nameMap)
}
// ---------------------------------------------------------------------------
// P2-6: 快捷回复模板
// ---------------------------------------------------------------------------
interface MessageTemplateRow {
id: string
userId: string
title: string
content: string
sortOrder: number
updatedAt: Date
createdAt: Date
}
const mapTemplate = (r: MessageTemplateRow): MessageTemplate => ({
id: r.id,
userId: r.userId,
title: r.title,
content: r.content,
sortOrder: r.sortOrder,
updatedAt: toIsoRequired(r.updatedAt),
createdAt: toIsoRequired(r.createdAt),
})
export const getMessageTemplates = cache(
async (userId: string): Promise<MessageTemplate[]> => {
const rows = await db
.select()
.from(messageTemplates)
.where(eq(messageTemplates.userId, userId))
.orderBy(messageTemplates.sortOrder, desc(messageTemplates.createdAt))
return rows.map(mapTemplate)
}
)
export async function createMessageTemplate(data: CreateMessageTemplateInput): Promise<string> {
const id = createId()
await db.insert(messageTemplates).values({
id,
userId: data.userId,
title: data.title,
content: data.content,
sortOrder: data.sortOrder ?? 0,
})
return id
}
export async function updateMessageTemplate(
id: string,
userId: string,
data: UpdateMessageTemplateInput
): Promise<void> {
const updateData: Partial<{ title: string; content: string; sortOrder: number }> = {}
if (data.title !== undefined) updateData.title = data.title
if (data.content !== undefined) updateData.content = data.content
if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder
if (Object.keys(updateData).length === 0) return
await db
.update(messageTemplates)
.set(updateData)
.where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId)))
}
export async function deleteMessageTemplate(id: string, userId: string): Promise<void> {
await db
.delete(messageTemplates)
.where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId)))
}

View File

@@ -0,0 +1,34 @@
import type { Message } from "../types"
/**
* P1-7: 构建回复消息的 compose 页面 URL。
*
* 抽离自 MessageDetail 组件,便于单元测试与复用。
* - 使用 URLSearchParams 安全编码参数(避免 receiverId 含特殊字符的潜在问题)
* - "Re:" 前缀逻辑集中在此处,避免多次回复产生 "Re: Re: Re:" 链
* - 当 canSend 为 false 时返回 undefinedUI 据此隐藏回复入口)
*
* @param message 被回复的消息
* @param currentUserId 当前用户 ID用于判断回复方向回复对方
* @param canSend 当前用户是否有 MESSAGE_SEND 权限
*/
export function buildReplyHref(
message: Message,
currentUserId: string,
canSend: boolean
): string | undefined {
if (!canSend) return undefined
const replyToId = message.receiverId === currentUserId ? message.senderId : message.receiverId
const subject = message.subject?.startsWith("Re:")
? message.subject
: `Re: ${message.subject ?? ""}`
const params = new URLSearchParams({
parentId: message.id,
receiverId: replyToId,
subject,
})
return `/messages/compose?${params.toString()}`
}

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"
import {
SendMessageSchema,
MessageIdSchema,
UpdateNotificationPreferencesSchema,
} from "./schema"
describe("SendMessageSchema", () => {
@@ -239,135 +238,5 @@ describe("MessageIdSchema", () => {
})
})
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)
})
})
// UpdateNotificationPreferencesSchema 测试已迁移至
// src/modules/notifications/schema.test.tsV3-P1-3: 通知偏好 Schema 迁移)

View File

@@ -24,21 +24,65 @@ export const MessageIdSchema = z.object({
export type MessageIdInput = z.infer<typeof MessageIdSchema>
/** 校验通知偏好更新表单8 个布尔字段 + 免打扰时段,来自 checkbox/FormData */
export const UpdateNotificationPreferencesSchema = z.object({
emailEnabled: z.boolean(),
smsEnabled: z.boolean(),
pushEnabled: z.boolean(),
homeworkNotifications: z.boolean(),
gradeNotifications: z.boolean(),
announcementNotifications: z.boolean(),
messageNotifications: z.boolean(),
attendanceNotifications: z.boolean(),
quietHoursEnabled: z.boolean(),
quietHoursStart: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
quietHoursEnd: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
/**
* 校验消息草稿表单(来自 FormData
* 所有字段可选draftId 存在时为更新,不存在时为创建。
* 使用 Zod 安全转换 FormData.get() 的 string | File | null 返回值。
*/
export const MessageDraftSchema = z.object({
draftId: z.string().trim().min(1).optional(),
receiverId: z.string().trim().min(1).optional(),
subject: z.string().trim().max(255).optional(),
content: z.string().max(5000).optional(),
parentMessageId: z.string().trim().min(1).optional(),
})
export type UpdateNotificationPreferencesFormInput = z.infer<
typeof UpdateNotificationPreferencesSchema
>
export type MessageDraftFormInput = z.infer<typeof MessageDraftSchema>
/** P2-1: 批量操作 ID 列表校验 */
export const BulkMessageIdsSchema = z.object({
ids: z.array(z.string().trim().min(1)).min(1).max(100),
})
export type BulkMessageIdsInput = z.infer<typeof BulkMessageIdsSchema>
/** P2-6: 快捷回复模板校验(创建/更新) */
export const MessageTemplateSchema = z.object({
templateId: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).max(100),
content: z.string().trim().min(1).max(2000),
sortOrder: z.number().int().min(0).max(9999).optional(),
})
export type MessageTemplateFormInput = z.infer<typeof MessageTemplateSchema>
/** P2-2: 群组消息校验(教师→全班家长) */
export const SendGroupMessageSchema = z
.object({
classId: z.string().trim().min(1),
subject: z.string().trim().max(255).optional().nullable(),
content: z.string().trim().min(1),
})
.transform((v) => ({
classId: v.classId,
subject: v.subject && v.subject.length > 0 ? v.subject : null,
content: v.content,
}))
export type SendGroupMessageInput = z.infer<typeof SendGroupMessageSchema>
/** P2-5: 举报消息校验 */
export const ReportMessageSchema = z.object({
messageId: z.string().trim().min(1),
reason: z.enum(["spam", "harassment", "inappropriate", "other"]),
description: z.string().trim().max(1000).optional(),
})
export type ReportMessageFormInput = z.infer<typeof ReportMessageSchema>
/** P2-5: 屏蔽用户校验 */
export const BlockUserSchema = z.object({
blockedId: z.string().trim().min(1),
})
export type BlockUserFormInput = z.infer<typeof BlockUserSchema>

View File

@@ -0,0 +1,28 @@
"use client"
import {
getMessagesAction,
recallMessageAction,
toggleMessageStarAction,
} from "../actions"
import type { MessageListService } from "./message-list-service"
/**
* P1-8: 消息列表默认服务实现。
*
* 绑定现有 Server Actions作为 MessageListServiceProvider 的默认注入值。
* 测试时可替换为 mock 实现以隔离组件测试。
*/
export const defaultMessageListService: MessageListService = {
async search(params) {
return getMessagesAction(params)
},
async toggleStar(messageId) {
return toggleMessageStarAction(messageId)
},
async recall(messageId) {
return recallMessageAction(messageId)
},
}

View File

@@ -0,0 +1,44 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import { defaultMessageListService } from "./default-message-list-service"
import type { MessageListService } from "./message-list-service"
/**
* P1-8: 消息列表服务 Context。
*
* 组件通过 useMessageListService() 获取服务实现,
* 而非直接 import actions实现依赖反转。
*
* 默认注入真实实现(调用 Server Actions
* 测试时可注入 mock 实现以隔离组件测试。
*/
const MessageListServiceContext = createContext<MessageListService | null>(null)
interface MessageListServiceProviderProps {
/** 自定义服务实现;不传则使用默认实现(调用真实 Server Actions */
service?: MessageListService
children: ReactNode
}
export function MessageListServiceProvider({
service = defaultMessageListService,
children,
}: MessageListServiceProviderProps): ReactNode {
return (
<MessageListServiceContext.Provider value={service}>
{children}
</MessageListServiceContext.Provider>
)
}
/**
* 获取消息列表服务。
* 必须在 MessageListServiceProvider 内部使用;
* 未包裹时回退到默认实现(便于渐进迁移)。
*/
export function useMessageListService(): MessageListService {
const service = useContext(MessageListServiceContext)
return service ?? defaultMessageListService
}

View File

@@ -0,0 +1,49 @@
/**
* P1-8: 消息列表服务接口。
*
* 通过 TypeScript 接口抽象所有客户端可调用的消息列表操作,
* 使组件依赖接口而非具体 Server Action 实现,便于测试与替换。
*
* 默认实现绑定现有 Server Actions测试时可注入 mock 实现。
*/
import type { ActionState } from "@/shared/types/action-state"
import type { Message, MessageType } from "../types"
/** 消息搜索返回的分页结构 */
export interface MessageSearchResult {
items: Message[]
total: number
page: number
pageSize: number
totalPages: number
}
/** 消息搜索参数 */
export interface MessageSearchParams {
type: MessageType
keyword?: string
page?: number
pageSize?: number
starredOnly?: boolean
senderId?: string
dateFrom?: string
dateTo?: string
isRead?: boolean
}
/**
* 消息列表客户端服务接口。
* 所有客户端组件通过 useMessageListService() 获取实现,不直接 import actions。
*/
export interface MessageListService {
/** 搜索消息(支持关键词、星标、发件人、日期范围、已读状态等筛选) */
search(params: MessageSearchParams): Promise<ActionState<MessageSearchResult>>
/** 切换消息星标 */
toggleStar(messageId: string): Promise<ActionState<string>>
/** 撤回消息仅发送方2 分钟窗口内) */
recall(messageId: string): Promise<ActionState<string>>
}

View File

@@ -6,8 +6,45 @@
* PaginatedResult已迁移到 notifications/types.ts请直接从该模块导入。
*/
import type { DataScope } from "@/shared/types/permissions"
export type MessageType = "inbox" | "sent" | "all"
/** 收件人角色枚举P1-6 收紧类型,替代宽松的 string */
export type RecipientRole = "student" | "teacher" | "admin" | "parent"
/**
* P2-7: 收件人解析器上下文。
*
* 将 userId 和 dataScope 打包传入解析器,便于统一签名。
*/
export interface RecipientResolverContext {
userId: string
scope: DataScope
}
/**
* P2-7: 收件人解析器签名。
*
* 每个 DataScope.type 对应一个解析器,返回该范围内可见的收件人列表。
*/
export type RecipientResolver = (
ctx: RecipientResolverContext
) => Promise<RecipientOption[]>;
/**
* P2-7: 角色收件人配置。
*
* 将 DataScope.type 与解析器映射解耦,新增角色仅需新增配置项,
* 无需修改 getRecipients 主流程(开闭原则)。
*/
export interface MessagingRoleConfig {
/** 对应的 DataScope 类型 */
scopeType: DataScope["type"]
/** 收件人解析器 */
resolve: RecipientResolver
}
export interface Message {
id: string
senderId: string
@@ -18,8 +55,12 @@ export interface Message {
content: string
isRead: boolean
isStarred: boolean
/** P2-4: 撤回时间ISO 字符串),非空即表示消息已被撤回 */
recalledAt: string | null
readAt: string | null
parentMessageId: string | null
/** P2-2: 群组消息 IDfan-out on write教师群发时每条消息共享同一 ID */
groupMessageId: string | null
createdAt: string
}
@@ -37,6 +78,14 @@ export interface GetMessagesParams {
keyword?: string
/** V2-P2-13c: 仅返回星标消息 */
starredOnly?: boolean
/** P2-11: 按发件人 ID 筛选 */
senderId?: string
/** P2-11: 按日期范围筛选起始ISO 字符串) */
dateFrom?: string
/** P2-11: 按日期范围筛选结束ISO 字符串) */
dateTo?: string
/** P2-11: 按已读状态筛选true=仅已读false=仅未读) */
isRead?: boolean
}
export interface CreateMessageInput {
@@ -51,7 +100,7 @@ export interface RecipientOption {
id: string
name: string
email: string
role?: string
role?: RecipientRole
}
/** V2-P2-13c: 消息草稿 */
@@ -63,6 +112,10 @@ export interface MessageDraft {
subject: string | null
content: string | null
parentMessageId: string | null
/** P2-10: 来源设备 ID */
deviceId: string | null
/** P2-10: 版本号(乐观锁) */
version: number
createdAt: string
updatedAt: string
}
@@ -73,6 +126,8 @@ export interface CreateMessageDraftInput {
subject?: string | null
content?: string | null
parentMessageId?: string | null
/** P2-10: 来源设备 ID */
deviceId?: string | null
}
export interface UpdateMessageDraftInput {
@@ -80,4 +135,79 @@ export interface UpdateMessageDraftInput {
subject?: string | null
content?: string | null
parentMessageId?: string | null
/** P2-10: 来源设备 ID用于跨设备冲突检测 */
deviceId?: string | null
/** P2-10: 客户端持有的版本号(用于乐观锁校验) */
expectedVersion?: number
}
/** P2-6: 快捷回复模板 */
export interface MessageTemplate {
id: string
userId: string
title: string
content: string
sortOrder: number
createdAt: string
updatedAt: string
}
export interface CreateMessageTemplateInput {
userId: string
title: string
content: string
sortOrder?: number
}
export interface UpdateMessageTemplateInput {
title?: string
content?: string
sortOrder?: number
}
/** P2-5: 举报理由分类 */
export type MessageReportReason = "spam" | "harassment" | "inappropriate" | "other"
/** P2-5: 举报状态 */
export type MessageReportStatus = "pending" | "reviewed" | "dismissed" | "actioned"
/** P2-5: 消息举报记录 */
export interface MessageReport {
id: string
reporterId: string
messageId: string
reportedUserId: string
reason: MessageReportReason
description: string | null
status: MessageReportStatus
createdAt: string
updatedAt: string
}
/** P2-5: 创建举报输入 */
export interface CreateMessageReportInput {
reporterId: string
messageId: string
reportedUserId: string
reason: MessageReportReason
description?: string | null
}
/** P2-5: 用户屏蔽记录 */
export interface UserBlock {
id: string
blockerId: string
blockedId: string
createdAt: string
}
/** P2-3: 消息附件(复用 fileAttachments 表targetType="message" */
export interface MessageAttachment {
id: string
filename: string
originalName: string
mimeType: string
size: number
url: string | null
createdAt: string
}