功能:新增风控监控后台页面

This commit is contained in:
linkeyu
2026-07-25 10:21:15 +08:00
parent 7ca55d0dcb
commit 5c4af0d5fa
4 changed files with 1188 additions and 0 deletions
+2
View File
@@ -17,6 +17,7 @@ import {
NotificationOutlined,
ProfileOutlined,
SafetyCertificateOutlined,
SecurityScanOutlined,
SettingOutlined,
ShareAltOutlined,
TeamOutlined,
@@ -95,6 +96,7 @@ const NAV_GROUPS: NavGroup[] = [
icon: <FileSearchOutlined />,
label: '监控审计',
children: [
{ key: '/risk-monitor', icon: <SecurityScanOutlined />, label: '风控监控' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
{ key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' },
+323
View File
@@ -0,0 +1,323 @@
.page {
min-height: calc(100vh - 112px);
margin: -24px;
padding: 0 16px 28px;
background: #f5f5f3;
color: #171717;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72px;
margin: 0 -16px 18px;
padding: 0 24px;
border-bottom: 1px solid #e8e8e6;
background: #fff;
}
.titleCluster,
.brandLine,
.updated,
.rule,
.deviceCell,
.actionGroup,
.factRow {
display: flex;
align-items: center;
}
.back {
margin-right: 18px;
color: #555;
font-size: 13px;
}
.brandMark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-right: 12px;
border-radius: 10px;
background: #ffd400;
color: #111;
font-size: 17px;
font-weight: 800;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.subtitle {
margin-top: 3px;
color: #929292;
font-size: 12px;
}
.updated {
gap: 7px;
color: #898989;
font-size: 12px;
}
.updatedMeta {
display: flex;
align-items: center;
gap: 7px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9b9b9b;
}
.summaryGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.summaryCard {
min-height: 148px;
padding: 26px 24px 20px;
border: 1px solid #eeeeeb;
border-radius: 16px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.summaryTitle {
margin-bottom: 4px;
color: #111;
font-size: 14px;
font-weight: 700;
}
.summaryValue {
color: #ee3737;
font-size: 38px;
font-weight: 800;
line-height: 1.08;
font-variant-numeric: tabular-nums;
}
.summaryUnit {
margin-left: 5px;
color: #202020;
font-size: 16px;
font-weight: 600;
}
.summarySub {
margin: 8px 0 8px;
color: #969696;
font-size: 13px;
}
.rule {
width: fit-content;
padding: 4px 8px;
border: 0;
border-radius: 6px;
background: #f6f6f4;
color: #111;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
transition: background 0.18s ease, box-shadow 0.18s ease;
}
.rule:hover,
.rule:focus-visible {
background: #fff3bf;
box-shadow: 0 0 0 2px rgba(255, 212, 0, 0.3);
outline: none;
}
.rule strong {
margin-left: 4px;
color: #df2727;
}
.ruleEditIcon {
margin-left: 7px;
color: #777;
font-size: 11px;
}
.ruleDialogIntro {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
background: #faf8ef;
color: #666;
font-size: 12px;
line-height: 1.7;
}
.ruleSettings {
border-top: 1px solid #f0f0ed;
}
.ruleSettingRow {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
gap: 20px;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0ed;
}
.ruleSettingCopy {
display: flex;
flex-direction: column;
gap: 5px;
}
.ruleSettingCopy strong {
color: #222;
font-size: 14px;
}
.ruleSettingCopy span {
color: #888;
font-size: 12px;
line-height: 1.55;
}
.ruleValueEditor {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
color: #666;
}
.ruleValueEditor :global(.ant-input-number) {
width: 110px;
}
.section {
margin-top: 14px;
padding: 20px 20px 16px;
border: 1px solid #ecece8;
border-radius: 16px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.sectionTitle {
margin: 0 0 10px;
font-size: 15px;
font-weight: 700;
}
.deviceCell {
gap: 7px;
}
.mono {
max-width: 210px;
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.copyButton {
height: 25px !important;
padding: 0 7px !important;
color: #777 !important;
font-size: 12px !important;
}
.actionGroup {
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
.expandButton {
border-color: #ffd400 !important;
background: #ffd400 !important;
color: #111 !important;
font-weight: 600;
}
.detailWrap {
padding: 12px 16px 6px;
border-radius: 10px;
background: #fafaf8;
}
.factRow {
gap: 8px;
margin-bottom: 12px;
}
.fact {
padding: 6px 10px;
border: 1px solid #ecece7;
border-radius: 8px;
background: #fff;
color: #666;
font-size: 12px;
}
.fact strong {
margin: 0 3px;
color: #111;
font-size: 14px;
}
.price {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.na {
color: #aaa;
}
.footerNote {
margin-top: 16px;
padding: 16px 20px;
border: 1px dashed #dcdcd8;
border-radius: 14px;
background: rgba(255, 255, 255, 0.42);
color: #666;
font-size: 12px;
line-height: 1.8;
}
.footerNote strong {
color: #222;
}
@media (max-width: 900px) {
.summaryGrid {
grid-template-columns: 1fr;
}
.hero {
align-items: flex-start;
padding-top: 16px;
padding-bottom: 16px;
}
.updatedMeta {
display: none;
}
}
+778
View File
@@ -0,0 +1,778 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
App,
Button,
Empty,
InputNumber,
Modal,
Skeleton,
Table,
Tag,
Tooltip,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ClearOutlined,
CopyOutlined,
DownOutlined,
EditOutlined,
ReloadOutlined,
RightOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime, utcDayjs } from '@/lib/format';
import type {
RiskDetailItem,
RiskDetailPage,
RiskIncidentItem,
RiskIncidentPage,
RiskKind,
RiskMonitorSummary,
RiskResetResponse,
RiskRuleConfig,
RiskSummaryCard,
} from '@/lib/types';
import styles from './page.module.css';
const KIND_META: Record<
RiskKind,
{ title: string; summaryTitle: string; totalLabel: string; unit: string }
> = {
sms: {
title: '短信',
summaryTitle: '触发短信报警的设备',
totalLabel: '今日短信下发共',
unit: '台',
},
oneclick: {
title: '一键登录',
summaryTitle: '触发一键登录报警的设备',
totalLabel: '今日一键登录共',
unit: '台',
},
compare: {
title: '比价',
summaryTitle: '触发比价报警的账户',
totalLabel: '今日比价共',
unit: '个',
},
};
const integer = (value: number) => new Intl.NumberFormat('zh-CN').format(value);
const LIST_PAGE_SIZE = 20;
const RISK_KINDS: RiskKind[] = ['sms', 'oneclick', 'compare'];
const RULE_LIMITS: Record<RiskKind, { min: number; max: number; help: string }> = {
sms: {
min: 1,
max: 5,
help: '统计成功下发;最高5次,与现有每设备每小时发送上限一致。',
},
oneclick: {
min: 1,
max: 100000,
help: '成功和失败均计入,同一设备按北京时间自然日累计。',
},
compare: {
min: 1,
max: 100,
help: '同一账户按北京时间自然日累计;最高100次,与比价每日上限一致。',
},
};
const utcTime = (value: string | null, withDate = true) =>
formatUtcTime(value, withDate ? 'MM-DD HH:mm' : 'HH:mm');
const eventMoment = (value: string, kind: RiskKind) =>
kind === 'compare' ? dayjs(value) : utcDayjs(value).tz('Asia/Shanghai');
const eventTime = (value: string | null, kind: RiskKind, withDate = true) =>
value ? eventMoment(value, kind).format(withDate ? 'MM-DD HH:mm' : 'HH:mm') : '-';
function period(item: RiskIncidentItem): string {
const start = eventMoment(item.first_event_at, item.kind);
const end = eventMoment(item.last_event_at, item.kind);
if (start.isSame(end, 'day')) {
return `${start.format('YYYY-MM-DD HH:mm')}${end.format('HH:mm')}`;
}
return `${start.format('YYYY-MM-DD HH:mm')}${end.format('YYYY-MM-DD HH:mm')}`;
}
function DeviceId({
value,
onCopy,
}: {
value: string | null;
onCopy: (value: string) => void;
}) {
if (!value) return <span className={styles.na}>-</span>;
return (
<span className={styles.deviceCell}>
<Tooltip title={value}>
<span className={styles.mono}>{value}</span>
</Tooltip>
<Button
className={styles.copyButton}
size="small"
icon={<CopyOutlined />}
onClick={() => onCopy(value)}
>
</Button>
</span>
);
}
function DetailTable({
detail,
loading,
page,
onPage,
}: {
detail?: RiskDetailPage;
loading: boolean;
page: number;
onPage: (page: number) => void;
}) {
if (loading || !detail) return <Skeleton active paragraph={{ rows: 4 }} />;
let columns: ColumnsType<RiskDetailItem>;
if (detail.kind === 'sms') {
columns = [
{
title: '发送时间',
dataIndex: 'occurred_at',
width: 150,
render: (v) => eventTime(v, detail.kind),
},
{ title: '手机号', dataIndex: 'phone', width: 140, render: (v) => v || '-' },
{
title: '完成验证',
dataIndex: 'verified',
width: 100,
render: (v) => (v ? <Tag color="success"></Tag> : <Tag></Tag>),
},
{
title: '登录账号',
key: 'account',
render: (_, row) => row.username || (row.user_id ? `U${row.user_id}` : '-'),
},
{
title: '账号注册时间',
dataIndex: 'account_registered_at',
width: 150,
render: (v) => utcTime(v),
},
];
} else if (detail.kind === 'oneclick') {
columns = [
{
title: '登录时间',
dataIndex: 'occurred_at',
width: 150,
render: (v) => eventTime(v, detail.kind),
},
{ title: '手机号', dataIndex: 'phone', width: 140, render: (v) => v || '-' },
{
title: '登录账号',
key: 'account',
render: (_, row) => row.username || (row.user_id ? `U${row.user_id}` : '-'),
},
{
title: '账号注册时间',
dataIndex: 'account_registered_at',
width: 150,
render: (v) => utcTime(v),
},
{
title: '结果',
dataIndex: 'outcome',
width: 100,
render: (v) =>
v === 'success' ? <Tag color="success"></Tag> : <Tag color="error"></Tag>,
},
{ title: '失败原因', dataIndex: 'reason', render: (v) => v || '-' },
];
} else {
const priceColumn = (platform: string) => ({
title: platform,
key: platform,
width: 100,
render: (_: unknown, row: RiskDetailItem) => {
const value = row.prices?.[platform];
return value == null ? (
<span className={styles.na}></span>
) : (
<span className={styles.price}>¥{value.toFixed(2)}</span>
);
},
});
columns = [
{
title: '触发时间',
dataIndex: 'occurred_at',
width: 150,
render: (v) => eventTime(v, detail.kind),
},
{
title: '店铺 / 菜品',
key: 'shop',
render: (_, row) => (
<span>{[row.store_name, row.product_names].filter(Boolean).join(' · ') || '-'}</span>
),
},
priceColumn('美团'),
priceColumn('淘宝'),
priceColumn('京东'),
{
title: '节省',
dataIndex: 'saved_amount_yuan',
width: 90,
render: (v) => (v == null ? '-' : <span className={styles.price}>{v.toFixed(2)} </span>),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v) =>
v === 'success' ? <Tag color="success"></Tag> : <Tag color="error"></Tag>,
},
];
}
return (
<div className={styles.detailWrap}>
<div className={styles.factRow}>
<span className={styles.fact}>
{KIND_META[detail.kind].title}
<strong>{integer(detail.event_count)}</strong>
</span>
{detail.distinct_accounts != null && (
<span className={styles.fact}>
<strong>{integer(detail.distinct_accounts)}</strong>
</span>
)}
</div>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={detail.items}
scroll={{ x: detail.kind === 'compare' ? 1000 : 720 }}
pagination={{
current: page,
pageSize: 20,
total: detail.total,
showSizeChanger: false,
onChange: onPage,
}}
/>
</div>
);
}
export default function RiskMonitorPage() {
const { message, modal } = App.useApp();
const [summary, setSummary] = useState<RiskMonitorSummary | null>(null);
const [lists, setLists] = useState<Partial<Record<RiskKind, RiskIncidentPage>>>({});
const [listPage, setListPage] = useState<Record<RiskKind, number>>({
sms: 1,
oneclick: 1,
compare: 1,
});
const listPageRef = useRef(listPage);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [details, setDetails] = useState<Record<number, RiskDetailPage>>({});
const [detailLoading, setDetailLoading] = useState<Set<number>>(new Set());
const [detailPage, setDetailPage] = useState<Record<number, number>>({});
const [acting, setActing] = useState<number | null>(null);
const [ruleOpen, setRuleOpen] = useState(false);
const [ruleSaving, setRuleSaving] = useState(false);
const [resetting, setResetting] = useState(false);
const [ruleDraft, setRuleDraft] = useState<Record<RiskKind, number>>({
sms: 5,
oneclick: 20,
compare: 100,
});
const loadAll = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
try {
const pages = listPageRef.current;
const [summaryRes, smsRes, oneclickRes, compareRes] = await Promise.all([
api.get<RiskMonitorSummary>('/admin/api/risk-monitor/summary'),
api.get<RiskIncidentPage>('/admin/api/risk-monitor/incidents/sms', {
params: { cursor: (pages.sms - 1) * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE },
}),
api.get<RiskIncidentPage>('/admin/api/risk-monitor/incidents/oneclick', {
params: { cursor: (pages.oneclick - 1) * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE },
}),
api.get<RiskIncidentPage>('/admin/api/risk-monitor/incidents/compare', {
params: { cursor: (pages.compare - 1) * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE },
}),
]);
setSummary(summaryRes.data);
setLists({
sms: smsRes.data,
oneclick: oneclickRes.data,
compare: compareRes.data,
});
} catch (error) {
void message.error(errMsg(error, '风控数据加载失败'));
} finally {
setLoading(false);
}
}, [message]);
const changeListPage = useCallback(
async (kind: RiskKind, page: number) => {
const nextPages = { ...listPageRef.current, [kind]: page };
listPageRef.current = nextPages;
setListPage(nextPages);
try {
const { data } = await api.get<RiskIncidentPage>(
`/admin/api/risk-monitor/incidents/${kind}`,
{
params: { cursor: (page - 1) * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE },
},
);
setLists((current) => ({ ...current, [kind]: data }));
} catch (error) {
void message.error(errMsg(error, '风险列表加载失败'));
}
},
[message],
);
useEffect(() => {
void loadAll();
const timer = window.setInterval(() => void loadAll(true), 60 * 60 * 1000);
const onFocus = () => void loadAll(true);
window.addEventListener('focus', onFocus);
return () => {
window.clearInterval(timer);
window.removeEventListener('focus', onFocus);
};
}, [loadAll]);
const copy = useCallback(
async (value: string) => {
await navigator.clipboard.writeText(value);
void message.success('设备 ID 已复制');
},
[message],
);
const openRuleSettings = useCallback(() => {
if (summary) {
const current = Object.fromEntries(
summary.cards.map((card) => [card.kind, card.threshold]),
) as Record<RiskKind, number>;
setRuleDraft(current);
}
setRuleOpen(true);
}, [summary]);
const saveRules = useCallback(async () => {
setRuleSaving(true);
try {
const payload: RiskRuleConfig = {
sms_hourly_threshold: ruleDraft.sms,
oneclick_daily_threshold: ruleDraft.oneclick,
compare_daily_threshold: ruleDraft.compare,
};
await api.patch<RiskRuleConfig>('/admin/api/risk-monitor/rules', payload);
setRuleOpen(false);
void message.success('规则已保存,并已重新核算当前时段');
await loadAll(true);
} catch (error) {
void message.error(errMsg(error, '规则保存失败'));
} finally {
setRuleSaving(false);
}
}, [loadAll, message, ruleDraft]);
const resetAlerts = useCallback(() => {
modal.confirm({
title: '确认重置全部报警?',
content:
'短信、一键登录、比价三类待处理报警将归零,并从确认时刻重新累计。历史流水会保留,已封禁的设备和账户不会解封。',
okText: '确认重置',
okButtonProps: { danger: true },
cancelText: '取消',
async onOk() {
setResetting(true);
try {
const { data } = await api.post<RiskResetResponse>(
'/admin/api/risk-monitor/reset',
);
listPageRef.current = { sms: 1, oneclick: 1, compare: 1 };
setListPage({ sms: 1, oneclick: 1, compare: 1 });
setExpanded(new Set());
setDetails({});
setDetailPage({});
if (data.reset_incident_count > 0) {
void message.success(
`已重置 ${integer(data.reset_incident_count)} 条报警,并从 0 重新累计`,
);
} else {
void message.success('当前无待处理报警,新的累计基线已生效');
}
await loadAll(true);
} catch (error) {
void message.error(errMsg(error, '报警重置失败'));
throw error;
} finally {
setResetting(false);
}
},
});
}, [loadAll, message, modal]);
const loadDetail = useCallback(
async (kind: RiskKind, incidentId: number, page = 1) => {
setDetailLoading((current) => new Set(current).add(incidentId));
try {
const { data } = await api.get<RiskDetailPage>(
`/admin/api/risk-monitor/incidents/${kind}/${incidentId}/details`,
{ params: { cursor: (page - 1) * 20, limit: 20 } },
);
setDetails((current) => ({ ...current, [incidentId]: data }));
setDetailPage((current) => ({ ...current, [incidentId]: page }));
} catch (error) {
void message.error(errMsg(error, '风险明细加载失败'));
} finally {
setDetailLoading((current) => {
const next = new Set(current);
next.delete(incidentId);
return next;
});
}
},
[message],
);
const toggle = useCallback(
(row: RiskIncidentItem) => {
const willOpen = !expanded.has(row.incident_id);
setExpanded((current) => {
const next = new Set(current);
if (willOpen) next.add(row.incident_id);
else next.delete(row.incident_id);
return next;
});
if (willOpen && !details[row.incident_id]) {
void loadDetail(row.kind, row.incident_id);
}
},
[details, expanded, loadDetail],
);
const runAction = useCallback(
(row: RiskIncidentItem, action: 'ignore' | 'block') => {
const verb = action === 'block' ? '封禁' : '忽略';
modal.confirm({
title: `确认${verb}${row.subject_type === 'device' ? '设备' : '账户'}`,
content:
action === 'block'
? row.subject_type === 'device'
? '封禁后,该设备发送短信和一键登录会被直接拒绝,不影响它登录过的账户。'
: '封禁后,该账户不能发起比价、领取任务奖励或提现,现有金币和现金不会清零。'
: '忽略后,本风险事件会移出待处理列表,事实流水仍会保留。',
okText: verb,
okButtonProps: { danger: action === 'block' },
cancelText: '取消',
async onOk() {
setActing(row.incident_id);
try {
await api.post(`/admin/api/risk-monitor/incidents/${row.incident_id}/${action}`, {
reason: `风控监控页人工${verb}`,
});
void message.success(`${verb}成功`);
setExpanded((current) => {
const next = new Set(current);
next.delete(row.incident_id);
return next;
});
if (
(lists[row.kind]?.items.length ?? 0) === 1
&& listPageRef.current[row.kind] > 1
) {
const nextPages = {
...listPageRef.current,
[row.kind]: listPageRef.current[row.kind] - 1,
};
listPageRef.current = nextPages;
setListPage(nextPages);
}
await loadAll(true);
} catch (error) {
void message.error(errMsg(error));
throw error;
} finally {
setActing(null);
}
},
});
},
[lists, loadAll, message, modal],
);
const columns = useCallback(
(kind: RiskKind): ColumnsType<RiskIncidentItem> => {
const actionColumn = {
title: '操作',
key: 'actions',
align: 'right' as const,
fixed: 'right' as const,
width: 180,
render: (_: unknown, row: RiskIncidentItem) => {
const open = expanded.has(row.incident_id);
return (
<span className={styles.actionGroup}>
<Button
className={styles.expandButton}
size="small"
loading={detailLoading.has(row.incident_id)}
icon={open ? <DownOutlined /> : <RightOutlined />}
onClick={() => toggle(row)}
>
{open ? '收起' : '展开'}
</Button>
<Button
size="small"
danger
loading={acting === row.incident_id}
onClick={() => runAction(row, 'block')}
>
</Button>
<Button
size="small"
loading={acting === row.incident_id}
onClick={() => runAction(row, 'ignore')}
>
</Button>
</span>
);
},
};
if (kind === 'compare') {
return [
{ title: '账户手机号', dataIndex: 'phone', width: 130, render: (v) => v || '-' },
{
title: '注册时间',
dataIndex: 'registered_at',
width: 105,
render: (v) => utcTime(v),
},
{
title: '常用设备',
dataIndex: 'common_device_id',
width: 180,
render: (v) => <DeviceId value={v} onCopy={copy} />,
},
{ title: '触发时段', key: 'period', width: 180, render: (_, row) => period(row) },
{
title: '首次触发',
dataIndex: 'triggered_at',
width: 125,
render: (v) => eventTime(v, kind),
},
actionColumn,
];
}
return [
{ title: '设备型号', dataIndex: 'device_model', width: 120, render: (v) => v || '-' },
{
title: '设备 ID',
dataIndex: 'subject_id',
width: 190,
render: (v) => <DeviceId value={v} onCopy={copy} />,
},
{
title: '首次使用',
dataIndex: 'first_used_at',
width: 110,
render: (v) => eventTime(v, kind),
},
{ title: '触发时段', key: 'period', width: 180, render: (_, row) => period(row) },
{
title: '首次触发',
dataIndex: 'triggered_at',
width: 110,
render: (v) => eventTime(v, kind),
},
actionColumn,
];
},
[acting, copy, detailLoading, expanded, runAction, toggle],
);
const cards = useMemo(() => {
if (!summary) return [];
const byKind = Object.fromEntries(summary.cards.map((card) => [card.kind, card]));
return RISK_KINDS
.map((kind) => byKind[kind])
.filter(Boolean) as RiskSummaryCard[];
}, [summary]);
return (
<main className={styles.page}>
<header className={styles.hero}>
<div className={styles.titleCluster}>
<span className={styles.back}> </span>
<span className={styles.brandMark}></span>
<div>
<h1 className={styles.title}></h1>
<div className={styles.subtitle}> / / </div>
</div>
</div>
<div className={styles.updated}>
<span className={styles.updatedMeta}>
<span>{summary ? dayjs(summary.date).format('YYYY-MM-DD') : '---- -- --'}</span>
<span></span>
<span className={styles.dot} />
<span></span>
</span>
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
loading={loading}
aria-label="刷新风控数据"
onClick={() => void loadAll()}
/>
<Tooltip title="三类待处理报警归零,历史流水和封禁状态不变">
<Button
size="small"
icon={<ClearOutlined />}
loading={resetting}
onClick={resetAlerts}
>
</Button>
</Tooltip>
</div>
</header>
{loading && !summary ? (
<Skeleton active paragraph={{ rows: 10 }} />
) : (
<>
<section className={styles.summaryGrid}>
{cards.map((card) => {
const meta = KIND_META[card.kind];
return (
<article key={card.kind} className={styles.summaryCard}>
<div className={styles.summaryTitle}>{meta.summaryTitle}</div>
<div>
<span className={styles.summaryValue}>{card.alert_subject_count}</span>
<span className={styles.summaryUnit}>{meta.unit}</span>
</div>
<div className={styles.summarySub}>
{meta.totalLabel} {integer(card.today_total)}
</div>
<button
type="button"
className={styles.rule}
onClick={openRuleSettings}
aria-label={`编辑${meta.title}告警规则`}
>
<span>{card.rule_text.replace(/≥.*$/, '')}</span>
<strong> {card.threshold} </strong>
<EditOutlined className={styles.ruleEditIcon} />
</button>
</article>
);
})}
</section>
{RISK_KINDS.map((kind) => {
const data = lists[kind]?.items ?? [];
return (
<section key={kind} className={styles.section}>
<h2 className={styles.sectionTitle}>{KIND_META[kind].title}</h2>
<Table
rowKey="incident_id"
size="middle"
columns={columns(kind)}
dataSource={data}
tableLayout="fixed"
scroll={{ x: kind === 'compare' ? 900 : 890 }}
locale={{ emptyText: <Empty description="当前没有待处理风险" /> }}
pagination={{
current: listPage[kind],
pageSize: LIST_PAGE_SIZE,
total: lists[kind]?.total ?? 0,
hideOnSinglePage: true,
showSizeChanger: false,
onChange: (page) => void changeListPage(kind, page),
}}
expandable={{
expandedRowKeys: [...expanded],
showExpandColumn: false,
expandedRowRender: (row) => (
<DetailTable
detail={details[row.incident_id]}
loading={detailLoading.has(row.incident_id)}
page={detailPage[row.incident_id] || 1}
onPage={(page) => void loadDetail(row.kind, row.incident_id, page)}
/>
),
}}
/>
</section>
);
})}
<footer className={styles.footerNote}>
<strong></strong>
线
</footer>
</>
)}
<Modal
title="风控规则设置"
open={ruleOpen}
okText="保存并立即生效"
cancelText="取消"
confirmLoading={ruleSaving}
onOk={() => void saveRules()}
onCancel={() => setRuleOpen(false)}
destroyOnHidden
>
<p className={styles.ruleDialogIntro}>
</p>
<div className={styles.ruleSettings}>
{RISK_KINDS.map((kind) => {
const meta = KIND_META[kind];
const limits = RULE_LIMITS[kind];
return (
<div key={kind} className={styles.ruleSettingRow}>
<div className={styles.ruleSettingCopy}>
<strong>{meta.summaryTitle}</strong>
<span>{limits.help}</span>
</div>
<div className={styles.ruleValueEditor}>
<InputNumber
min={limits.min}
max={limits.max}
precision={0}
value={ruleDraft[kind]}
onChange={(value) => {
if (value == null) return;
setRuleDraft((current) => ({ ...current, [kind]: value }));
}}
/>
<span></span>
</div>
</div>
);
})}
</div>
</Modal>
</main>
);
}
+85
View File
@@ -218,6 +218,91 @@ export interface UserCoinRecord {
coin: number;
}
// ===== 风控监控 =====
export type RiskKind = 'sms' | 'oneclick' | 'compare';
export interface RiskSummaryCard {
kind: RiskKind;
alert_subject_count: number;
today_total: number;
threshold: number;
rule_text: string;
}
export interface RiskMonitorSummary {
date: string;
updated_at: string;
cards: RiskSummaryCard[];
}
export interface RiskRuleConfig {
sms_hourly_threshold: number;
oneclick_daily_threshold: number;
compare_daily_threshold: number;
}
export interface RiskResetResponse {
ok: boolean;
reset_at: string;
reset_incident_count: number;
reset_counts: Record<RiskKind, number>;
}
export interface RiskIncidentItem {
incident_id: number;
kind: RiskKind;
subject_type: 'device' | 'user';
subject_id: string;
device_model: string | null;
first_used_at: string | null;
phone: string | null;
user_id: number | null;
registered_at: string | null;
common_device_id: string | null;
window_start: string;
window_end: string;
first_event_at: string;
triggered_at: string;
last_event_at: string;
event_count: number;
status: 'open' | 'ignored' | 'blocked' | 'resolved';
restricted: boolean;
}
export interface RiskIncidentPage {
items: RiskIncidentItem[];
next_cursor: number | null;
total: number;
}
export interface RiskDetailItem {
id: number;
occurred_at: string;
outcome: string;
phone: string | null;
user_id: number | null;
username: string | null;
account_registered_at: string | null;
verified: boolean | null;
reason: string | null;
store_name: string | null;
product_names: string | null;
prices: Record<string, number | null> | null;
saved_amount_yuan: number | null;
status: string | null;
}
export interface RiskDetailPage {
kind: RiskKind;
incident_id: number;
event_count: number;
distinct_accounts: number | null;
items: RiskDetailItem[];
next_cursor: number | null;
total: number;
}
export interface WithdrawBulkItemResult {
out_bill_no: string;
ok: boolean;