Files
NextEdu/src/modules/messaging/components/message-report-block.tsx
SpecialX 783b8f5484 feat(modules-comm): update messaging, notifications, onboarding, parent, proctoring, questions, rbac
- messaging: update message-compose, message-detail, message-draft-list,

  message-group-compose, message-list, message-report-block,

  message-template-picker, unread-message-badge, data-access

- notifications: update notification-list, data-access, use-notification-stream, preferences

- onboarding: update actions, data-access, use-onboarding-form

- parent: update child-schedule-card, parent-export-button

- proctoring: update data-access

- questions: update batch-operations, create-question-dialog, import-export-buttons,

  question-actions, data-access

- rbac: update actions, permission-catalog
2026-07-07 16:21:00 +08:00

230 lines
7.2 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 { useState } from "react"
import { useRouter } from "next/navigation"
import { notify } from "@/shared/lib/notify"
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) {
notify.success(t("messages.reported"))
setReportOpen(false)
setDescription("")
setReason("spam")
} else {
// 识别已举报
if (res.message?.includes("Already reported")) {
notify.error(t("messages.alreadyReported"))
} else if (res.message?.includes("own message")) {
notify.error(t("messages.reportSelf"))
} else {
notify.error(res.message || t("messages.reportFailed"))
}
}
} catch {
notify.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) {
notify.success(t("messages.blocked"))
setBlockOpen(false)
router.refresh()
} else {
if (res.message?.includes("already")) {
notify.error(t("messages.alreadyBlocked"))
} else if (res.message?.includes("yourself")) {
notify.error(t("messages.blockSelf"))
} else {
notify.error(res.message || t("messages.blockFailed"))
}
}
} catch {
notify.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>
</>
)
}