"use client"; import { useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import type { SubscriptionRiskFinalAction, SubscriptionRiskReviewStatus, } from "@prisma/client"; import { FileText, LockKeyhole, RotateCcw, Send, ShieldCheck, UnlockKeyhole, } from "lucide-react"; import { toast } from "sonner"; import { finalizeSubscriptionRiskDecision, generateSubscriptionRiskReport, sendSubscriptionRiskReport, updateSubscriptionRiskReview, } from "@/actions/admin/subscription-risk"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { getErrorMessage } from "@/lib/errors"; interface RiskReviewMode { status: SubscriptionRiskReviewStatus; label: string; title: string; description: string; icon: "ack" | "open"; } const modes: Record<"OPEN" | "ACKNOWLEDGED", RiskReviewMode> = { OPEN: { status: "OPEN", label: "重新打开", title: "重新打开风控事件", description: "事件会回到待处理状态,便于稍后继续跟进。", icon: "open", }, ACKNOWLEDGED: { status: "ACKNOWLEDGED", label: "确认跟进", title: "确认正在处理", description: "适合先记录已看到、正在核查,暂不解除或关闭事件。", icon: "ack", }, }; type DialogState = | { type: "review"; mode: RiskReviewMode } | { type: "final"; action: SubscriptionRiskFinalAction } | { type: "report" } | null; function ModeIcon({ icon }: { icon: RiskReviewMode["icon"] }) { if (icon === "open") return ; return ; } function finalActionCopy(action: SubscriptionRiskFinalAction, restorableSubscriptionCount: number) { if (action === "RESTORE_ACCESS") { return { icon: , label: "解除限制", title: "确认解除风控限制?", description: restorableSubscriptionCount > 0 ? "会恢复可恢复的暂停订阅,并关闭用户端强制通知。" : "会关闭用户端强制通知,并把事件记录为已解除;当前没有可自动恢复的暂停订阅。", confirm: "确认解除", }; } return { icon: , label: "保持封禁/暂停", title: "确认保持封禁或暂停?", description: "订阅和用户限制会维持当前处置,适合确认订阅链接外泄、公共代理滥用或用户无法解释异常访问来源的情况。", confirm: "保持限制", }; } export function SubscriptionRiskReviewActions({ eventId, reviewStatus, canRestoreSubscription = false, restorableSubscriptionCount = 0, riskReport = null, reportSentAt = null, userRestrictionActive = false, finalAction = null, }: { eventId: string; reviewStatus: SubscriptionRiskReviewStatus; canRestoreSubscription?: boolean; restorableSubscriptionCount?: number; riskReport?: string | null; reportSentAt?: Date | string | null; userRestrictionActive?: boolean; finalAction?: SubscriptionRiskFinalAction | null; }) { const router = useRouter(); const [dialog, setDialog] = useState(null); const [note, setNote] = useState(""); const [notifyUser, setNotifyUser] = useState(Boolean(reportSentAt || userRestrictionActive)); const [reportPreview, setReportPreview] = useState(riskReport ?? ""); const [pending, startTransition] = useTransition(); const availableModes = useMemo(() => { return [modes.ACKNOWLEDGED, modes.OPEN].filter((item) => item.status !== reviewStatus); }, [reviewStatus]); const activeFinalCopy = dialog?.type === "final" ? finalActionCopy(dialog.action, restorableSubscriptionCount) : null; function openReviewDialog(mode: RiskReviewMode) { setDialog({ type: "review", mode }); setNote(""); } function openFinalDialog(action: SubscriptionRiskFinalAction) { setDialog({ type: "final", action }); setNote(""); setNotifyUser(action === "KEEP_RESTRICTED" ? true : Boolean(reportSentAt || userRestrictionActive)); } function handleGenerateReport(openAfterGenerate = true) { startTransition(async () => { try { const result = await generateSubscriptionRiskReport(eventId); setReportPreview(result.report); toast.success("风险报告已生成"); if (openAfterGenerate) setDialog({ type: "report" }); router.refresh(); } catch (error) { toast.error(getErrorMessage(error, "生成风险报告失败")); } }); } function handleSendReport() { startTransition(async () => { try { await sendSubscriptionRiskReport(eventId); toast.success("已发送用户端强制通知"); router.refresh(); } catch (error) { toast.error(getErrorMessage(error, "发送用户通知失败")); } }); } function submitReview() { if (dialog?.type !== "review") return; startTransition(async () => { try { await updateSubscriptionRiskReview(eventId, dialog.mode.status, note); toast.success("风控事件已更新"); setDialog(null); router.refresh(); } catch (error) { toast.error(getErrorMessage(error, "更新风控事件失败")); } }); } function submitFinalDecision() { if (dialog?.type !== "final") return; startTransition(async () => { try { await finalizeSubscriptionRiskDecision(eventId, dialog.action, note, { notifyUser: dialog.action === "KEEP_RESTRICTED" && notifyUser, }); toast.success(dialog.action === "RESTORE_ACCESS" ? "已解除限制" : "已保持限制并记录处置"); setDialog(null); router.refresh(); } catch (error) { toast.error(getErrorMessage(error, "保存最终处置失败")); } }); } return ( <> 风险报告 handleGenerateReport(true)}> {reportPreview ? "重生成" : "生成报告"} {reportPreview && ( setDialog({ type: "report" })}> 查看报告 )} {reportSentAt ? "重新发送用户" : "发送用户"} 最终处置 openFinalDialog("RESTORE_ACCESS")} className="justify-start" > 解除限制 openFinalDialog("KEEP_RESTRICTED")} className="justify-start" > 保持封禁/暂停 {availableModes.length > 0 && ( 队列状态 {availableModes.map((item) => ( openReviewDialog(item)} > {item.label} ))} )} !pending && !open && setDialog(null)}> {dialog?.type === "review" && ( <> {dialog.mode.title} {dialog.mode.description} 处理备注 setNote(event.target.value)} maxLength={1000} placeholder="例如:已联系用户确认是出差;或确认订阅链接外泄,继续限制。" /> setDialog(null)} disabled={pending}> 先不处理 {pending ? "保存中..." : dialog.mode.label} > )} {dialog?.type === "final" && activeFinalCopy && ( <> {activeFinalCopy.icon} {activeFinalCopy.title} {activeFinalCopy.description} {dialog.action === "RESTORE_ACCESS" && restorableSubscriptionCount > 0 && ( 将尝试恢复 {restorableSubscriptionCount} 个仍在有效期内的暂停代理订阅。 )} {dialog.action === "KEEP_RESTRICTED" && ( setNotifyUser(event.target.checked)} /> 同时发送用户端强制通知 用户会看到全屏不可关闭说明,只能进入工单页面联系客服。 )} 最终处理备注 setNote(event.target.value)} maxLength={1000} placeholder="记录最终判断依据,例如:用户提交工单证明为本人出差;或确认链接被多人共享,保持限制。" /> setDialog(null)} disabled={pending}> 返回 {pending ? "保存中..." : activeFinalCopy.confirm} > )} {dialog?.type === "report" && ( <> 风险报告总结 可作为人工复核依据,也可以发送给用户端形成强制通知。 {reportPreview || "尚未生成风险报告。"} setDialog(null)} disabled={pending}> 关闭 handleGenerateReport(false)} disabled={pending}> {pending ? "生成中..." : "重新生成"} 发送用户 > )} > ); }
风险报告
最终处置
队列状态
{reportPreview || "尚未生成风险报告。"}