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:
@@ -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 Actions(getNotificationsAction / 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 Action(2 分钟窗口内仅发送方可撤回)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 Actions(getNotificationsAction / 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 }
|
||||
|
||||
166
src/modules/messaging/components/message-attachments.tsx
Normal file
166
src/modules/messaging/components/message-attachments.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
136
src/modules/messaging/components/message-draft-list.tsx
Normal file
136
src/modules/messaging/components/message-draft-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
src/modules/messaging/components/message-group-compose.tsx
Normal file
201
src/modules/messaging/components/message-group-compose.tsx
Normal 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 范围)
|
||||
* - 调用 sendGroupMessageAction(fan-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>
|
||||
)
|
||||
}
|
||||
23
src/modules/messaging/components/message-list-section.tsx
Normal file
23
src/modules/messaging/components/message-list-section.tsx
Normal 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} />
|
||||
}
|
||||
36
src/modules/messaging/components/message-list-skeleton.tsx
Normal file
36
src/modules/messaging/components/message-list-skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
229
src/modules/messaging/components/message-report-block.tsx
Normal file
229
src/modules/messaging/components/message-report-block.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
297
src/modules/messaging/components/message-template-picker.tsx
Normal file
297
src/modules/messaging/components/message-template-picker.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 消息草稿 CRUD(message_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)))
|
||||
}
|
||||
|
||||
34
src/modules/messaging/lib/build-reply-href.ts
Normal file
34
src/modules/messaging/lib/build-reply-href.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Message } from "../types"
|
||||
|
||||
/**
|
||||
* P1-7: 构建回复消息的 compose 页面 URL。
|
||||
*
|
||||
* 抽离自 MessageDetail 组件,便于单元测试与复用。
|
||||
* - 使用 URLSearchParams 安全编码参数(避免 receiverId 含特殊字符的潜在问题)
|
||||
* - "Re:" 前缀逻辑集中在此处,避免多次回复产生 "Re: Re: Re:" 链
|
||||
* - 当 canSend 为 false 时返回 undefined(UI 据此隐藏回复入口)
|
||||
*
|
||||
* @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()}`
|
||||
}
|
||||
@@ -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.ts(V3-P1-3: 通知偏好 Schema 迁移)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
49
src/modules/messaging/services/message-list-service.ts
Normal file
49
src/modules/messaging/services/message-list-service.ts
Normal 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>>
|
||||
}
|
||||
@@ -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: 群组消息 ID(fan-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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user