- 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
230 lines
7.2 KiB
TypeScript
230 lines
7.2 KiB
TypeScript
"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>
|
||
</>
|
||
)
|
||
}
|