Files
NextEdu/src/modules/messaging/components/message-compose.tsx
SpecialX 2859ef74f2 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
2026-07-03 10:24:37 +08:00

305 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { ArrowLeft, Check, Loader2, Save, Send } 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 { deleteMessageDraftAction, saveMessageDraftAction, sendMessageAction } from "../actions"
import { MessageTemplatePicker } from "./message-template-picker"
import type { RecipientOption } from "../types"
type FieldErrors = Record<string, string[]>
type SaveStatus = "idle" | "saving" | "saved"
/** 自动保存防抖延迟(毫秒) */
const AUTOSAVE_DEBOUNCE_MS = 2000
export function MessageCompose({
recipients,
parentMessageId,
defaultReceiverId,
defaultSubject,
backHref = "/messages",
}: {
recipients: RecipientOption[]
parentMessageId?: string
defaultReceiverId?: string
defaultSubject?: string
backHref?: string
}) {
const t = useTranslations("messages")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [receiverId, setReceiverId] = useState(defaultReceiverId ?? "")
const [subject, setSubject] = useState(defaultSubject ?? "")
const [content, setContent] = useState("")
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
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(() => {
draftIdRef.current = draftId
}, [draftId])
const getFieldError = (field: string): string | null => {
const errs = fieldErrors[field]
return errs && errs.length > 0 ? errs[0] : null
}
// 自动保存草稿(防抖 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)
if (receiverId) formData.set("receiverId", receiverId)
if (subject) formData.set("subject", subject)
if (content) formData.set("content", content)
if (parentMessageId) formData.set("parentMessageId", parentMessageId)
setSaveStatus("saving")
void saveMessageDraftAction(null, formData)
.then((res) => {
if (cancelled) return
if (submittedRef.current) return // 提交后跳过
if (res.success && res.data) {
setDraftId(res.data)
setSaveStatus("saved")
} else {
setSaveStatus("idle")
}
})
.catch(() => {
if (!cancelled) setSaveStatus("idle")
})
}, AUTOSAVE_DEBOUNCE_MS)
return () => {
cancelled = true
clearTimeout(timeout)
}
}, [subject, content, receiverId, parentMessageId])
const handleSubmit = async (formData: FormData) => {
if (!receiverId) {
toast.error(t("form.selectRecipient"))
return
}
// P0-5: 标记已提交,阻止后续自动保存 effect 触发新草稿创建
submittedRef.current = true
formData.set("receiverId", receiverId)
if (parentMessageId) {
formData.set("parentMessageId", parentMessageId)
}
setIsWorking(true)
setFieldErrors({})
try {
const res = await sendMessageAction(null, formData)
if (res.success) {
// 发送成功后删除草稿(如有)
if (draftIdRef.current) {
void deleteMessageDraftAction(draftIdRef.current)
}
toast.success(res.message)
router.push("/messages")
router.refresh()
} else {
// 展示字段级错误(来自 Zod 校验)
if (res.errors) {
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)
}
}
const handleSaveDraft = async () => {
setSaveStatus("saving")
const formData = new FormData()
if (draftId) formData.set("draftId", draftId)
if (receiverId) formData.set("receiverId", receiverId)
if (subject) formData.set("subject", subject)
if (content) formData.set("content", content)
if (parentMessageId) formData.set("parentMessageId", parentMessageId)
try {
const res = await saveMessageDraftAction(null, formData)
if (res.success && res.data) {
setDraftId(res.data)
setSaveStatus("saved")
toast.success(t("messages.draftSaved"))
} else {
setSaveStatus("idle")
toast.error(res.message)
}
} catch {
setSaveStatus("idle")
toast.error(t("messages.sendFailed"))
}
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<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>{parentMessageId ? t("title.reply") : t("title.newMessage")}</CardTitle>
</div>
{/* 草稿保存状态指示器 */}
{saveStatus !== "idle" ? (
<span className="text-muted-foreground flex items-center gap-1 text-xs">
{saveStatus === "saving" ? (
<>
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
{t("actions.saveDraft")}...
</>
) : (
<>
<Check className="h-3 w-3 text-green-500" aria-hidden="true" />
{t("messages.draftSaved")}
</>
)}
</span>
) : null}
</div>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="receiverId">{t("form.to")}</Label>
<Select value={receiverId} onValueChange={setReceiverId} disabled={!!defaultReceiverId}>
<SelectTrigger aria-invalid={!!getFieldError("receiverId")}>
<SelectValue placeholder={t("form.toPlaceholder")} />
</SelectTrigger>
<SelectContent>
{recipients.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
{r.role ? ` (${r.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<input type="hidden" name="receiverId" value={receiverId} />
{getFieldError("receiverId") ? (
<p className="text-destructive text-xs">{getFieldError("receiverId")}</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>
{/* P2-6: 快捷回复模板插入器 */}
<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="outline"
onClick={handleSaveDraft}
disabled={isWorking || saveStatus === "saving"}
>
<Save className="mr-2 h-4 w-4" />
{t("actions.saveDraft")}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => router.push(backHref)}
disabled={isWorking}
>
{t("actions.cancel")}
</Button>
<Button type="submit" disabled={isWorking || !receiverId}>
{isWorking ? (
t("actions.sending")
) : (
<>
<Send className="mr-2 h-4 w-4" />
{t("actions.send")}
</>
)}
</Button>
</CardFooter>
</form>
</CardContent>
</Card>
)
}