279 lines
9.4 KiB
TypeScript
279 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import { ShieldAlert } from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { adminApi, errMessage, type RiskAlert } from "@/lib/admin-api";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
EmptyState,
|
|
ErrorState,
|
|
LoadingBlock,
|
|
Modal,
|
|
Pagination,
|
|
Select,
|
|
StatCard,
|
|
TableWrap,
|
|
Td,
|
|
Th,
|
|
formatPercent,
|
|
formatTime,
|
|
statusTone,
|
|
useToast,
|
|
} from "@/components/admin/ui";
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: "", label: "全部状态" },
|
|
{ value: "未处理", label: "未处理" },
|
|
{ value: "已确认", label: "已确认" },
|
|
{ value: "已排除", label: "已排除" },
|
|
];
|
|
|
|
type Action = "release" | "block" | "freeze";
|
|
|
|
const ACTION_META: Record<
|
|
Action,
|
|
{ label: string; tone: "primary" | "danger"; impact: string[]; confirmText: string }
|
|
> = {
|
|
release: {
|
|
label: "放行",
|
|
tone: "primary",
|
|
confirmText: "确认放行",
|
|
impact: [
|
|
"该笔交易订单将完成落账并生成交易流水",
|
|
"预警状态变更为「已排除」",
|
|
"若客户余额不足,订单将转为失败",
|
|
],
|
|
},
|
|
block: {
|
|
label: "拦截",
|
|
tone: "danger",
|
|
confirmText: "确认拦截",
|
|
impact: [
|
|
"该笔交易订单将作废",
|
|
"系统将向客户发送「交易被拦截」站内信",
|
|
"自动生成一张「可疑交易上报」工单",
|
|
],
|
|
},
|
|
freeze: {
|
|
label: "冻结",
|
|
tone: "danger",
|
|
confirmText: "确认冻结",
|
|
impact: [
|
|
"该笔交易订单将作废",
|
|
"客户账号状态将变更为「冻结」",
|
|
"系统将向客户发送「账户已被冻结」站内信",
|
|
"自动生成一张「冻结复核」工单",
|
|
],
|
|
},
|
|
};
|
|
|
|
export function RiskSection() {
|
|
const toast = useToast();
|
|
const [items, setItems] = useState<RiskAlert[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [status, setStatus] = useState("未处理");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [pending, setPending] = useState<{ alert: RiskAlert; action: Action } | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const result = await adminApi.alerts({ status: status || undefined, page, page_size: 10 });
|
|
setItems(result.items ?? []);
|
|
setTotal(result.total ?? 0);
|
|
setTotalPages(result.total_pages ?? 0);
|
|
} catch (e) {
|
|
setError(errMessage(e, "预警列表加载失败"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [status, page]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
async function confirmAction() {
|
|
if (!pending) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const result = await adminApi.alertAction(pending.alert.id, pending.action);
|
|
const message =
|
|
typeof result?.message === "string" ? result.message : `${ACTION_META[pending.action].label}成功`;
|
|
toast(message);
|
|
setPending(null);
|
|
await load();
|
|
} catch (e) {
|
|
toast(errMessage(e, "处置失败"), "error");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const highRisk = items.filter((item) => item.alert_level === "高").length;
|
|
|
|
return (
|
|
<>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<StatCard
|
|
icon={<ShieldAlert className="h-5 w-5" />}
|
|
label="当前预警"
|
|
value={total}
|
|
hint={status ? `筛选:${status}` : "全部状态"}
|
|
/>
|
|
<StatCard
|
|
label="本页高风险"
|
|
value={highRisk}
|
|
hint={highRisk > 0 ? "需优先处置" : "暂无高风险预警"}
|
|
hintTone={highRisk > 0 ? "danger" : "muted"}
|
|
/>
|
|
<StatCard label="待处置" value={status === "未处理" ? total : "--"} hint="放行 / 拦截 / 冻结" />
|
|
</div>
|
|
|
|
<Card
|
|
title="风险预警处置"
|
|
description="放行、拦截、冻结均为不可逆操作,执行前需二次确认"
|
|
bodyClassName="p-0"
|
|
className="mt-6"
|
|
actions={
|
|
<Select value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }}>
|
|
{STATUS_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<LoadingBlock />
|
|
) : error ? (
|
|
<ErrorState message={error} onRetry={() => void load()} />
|
|
) : items.length === 0 ? (
|
|
<EmptyState text="没有符合条件的风险预警" />
|
|
) : (
|
|
<>
|
|
<TableWrap>
|
|
<thead>
|
|
<tr>
|
|
<Th>预警类型</Th>
|
|
<Th>客户 / 订单</Th>
|
|
<Th>触发详情</Th>
|
|
<Th>级别</Th>
|
|
<Th>置信度</Th>
|
|
<Th>状态</Th>
|
|
<Th>创建时间</Th>
|
|
<Th className="text-right">处置</Th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-50">
|
|
{items.map((alert) => {
|
|
const done = alert.status !== "未处理";
|
|
return (
|
|
<tr key={alert.id} className="transition-colors hover:bg-slate-50">
|
|
<Td className="font-medium text-slate-900">{alert.alert_type}</Td>
|
|
<Td className="text-xs text-slate-500">
|
|
客户 #{alert.customer_id}
|
|
<br />
|
|
订单 #{alert.order_id}
|
|
</Td>
|
|
<Td className="max-w-[220px] truncate" title={alert.trigger_detail ?? ""}>
|
|
{alert.trigger_detail ?? "--"}
|
|
</Td>
|
|
<Td>
|
|
<Badge
|
|
tone={
|
|
alert.alert_level === "高" ? "danger" : alert.alert_level === "中" ? "warning" : "neutral"
|
|
}
|
|
>
|
|
{alert.alert_level}
|
|
</Badge>
|
|
</Td>
|
|
<Td>{alert.confidence !== null ? formatPercent(alert.confidence, 0) : "--"}</Td>
|
|
<Td>
|
|
<Badge tone={statusTone(alert.status)}>{alert.status}</Badge>
|
|
</Td>
|
|
<Td className="text-xs text-slate-500">{formatTime(alert.create_time)}</Td>
|
|
<Td>
|
|
{done ? (
|
|
<span className="block text-right text-xs text-slate-400">
|
|
已处置 · {alert.handle_result ?? "--"}
|
|
</span>
|
|
) : (
|
|
<div className="flex justify-end gap-2">
|
|
{(["release", "block", "freeze"] as Action[]).map((action) => (
|
|
<Button
|
|
key={action}
|
|
size="sm"
|
|
variant={ACTION_META[action].tone}
|
|
onClick={() => setPending({ alert, action })}
|
|
>
|
|
{ACTION_META[action].label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</TableWrap>
|
|
<Pagination page={page} totalPages={totalPages} total={total} onChange={setPage} />
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
<Modal
|
|
open={!!pending}
|
|
title={pending ? `${ACTION_META[pending.action].label}确认` : ""}
|
|
onClose={() => (submitting ? undefined : setPending(null))}
|
|
footer={
|
|
<>
|
|
<Button onClick={() => setPending(null)} disabled={submitting}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
variant={pending ? ACTION_META[pending.action].tone : "primary"}
|
|
loading={submitting}
|
|
onClick={() => void confirmAction()}
|
|
>
|
|
{pending ? ACTION_META[pending.action].confirmText : "确认"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{pending && (
|
|
<div className="space-y-3">
|
|
<p className="text-slate-900">
|
|
你即将对 <span className="font-semibold">预警 #{pending.alert.id}</span> 执行
|
|
<span className="font-semibold">「{ACTION_META[pending.action].label}」</span> 操作。
|
|
</p>
|
|
<div className="rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
|
|
<p>预警类型:{pending.alert.alert_type}</p>
|
|
<p>风险级别:{pending.alert.alert_level}</p>
|
|
<p>关联客户:#{pending.alert.customer_id}</p>
|
|
<p>关联订单:#{pending.alert.order_id}</p>
|
|
</div>
|
|
<div>
|
|
<p className="mb-1 text-xs font-medium text-slate-500">该操作将产生以下影响:</p>
|
|
<ul className="space-y-1 text-xs leading-5 text-amber-700">
|
|
{ACTION_META[pending.action].impact.map((line) => (
|
|
<li key={line}>• {line}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|