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

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

- Add message-group-compose for group messaging

- Add message-template-picker for message templates

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

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

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

View File

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