修复:补齐风控解除封禁操作
This commit is contained in:
@@ -87,6 +87,34 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.statusToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 2px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #ecece8;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.statusToolbar > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statusToolbar strong {
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.statusToolbar span {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
min-height: 148px;
|
||||
padding: 26px 24px 20px;
|
||||
@@ -320,4 +348,9 @@
|
||||
.updatedMeta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.statusToolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Empty,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Table,
|
||||
Tag,
|
||||
@@ -64,6 +65,7 @@ const KIND_META: Record<
|
||||
const integer = (value: number) => new Intl.NumberFormat('zh-CN').format(value);
|
||||
const LIST_PAGE_SIZE = 20;
|
||||
const RISK_KINDS: RiskKind[] = ['sms', 'oneclick', 'compare'];
|
||||
type IncidentStatus = 'open' | 'blocked';
|
||||
const RULE_LIMITS: Record<RiskKind, { min: number; max: number; help: string }> = {
|
||||
sms: {
|
||||
min: 1,
|
||||
@@ -277,6 +279,7 @@ export default function RiskMonitorPage() {
|
||||
oneclick: 1,
|
||||
compare: 1,
|
||||
});
|
||||
const [incidentStatus, setIncidentStatus] = useState<IncidentStatus>('open');
|
||||
const listPageRef = useRef(listPage);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
@@ -300,13 +303,25 @@ export default function RiskMonitorPage() {
|
||||
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 },
|
||||
params: {
|
||||
status: incidentStatus,
|
||||
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 },
|
||||
params: {
|
||||
status: incidentStatus,
|
||||
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 },
|
||||
params: {
|
||||
status: incidentStatus,
|
||||
cursor: (pages.compare - 1) * LIST_PAGE_SIZE,
|
||||
limit: LIST_PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
setSummary(summaryRes.data);
|
||||
@@ -320,7 +335,7 @@ export default function RiskMonitorPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
}, [incidentStatus, message]);
|
||||
|
||||
const changeListPage = useCallback(
|
||||
async (kind: RiskKind, page: number) => {
|
||||
@@ -331,7 +346,11 @@ export default function RiskMonitorPage() {
|
||||
const { data } = await api.get<RiskIncidentPage>(
|
||||
`/admin/api/risk-monitor/incidents/${kind}`,
|
||||
{
|
||||
params: { cursor: (page - 1) * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE },
|
||||
params: {
|
||||
status: incidentStatus,
|
||||
cursor: (page - 1) * LIST_PAGE_SIZE,
|
||||
limit: LIST_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
);
|
||||
setLists((current) => ({ ...current, [kind]: data }));
|
||||
@@ -339,9 +358,21 @@ export default function RiskMonitorPage() {
|
||||
void message.error(errMsg(error, '风险列表加载失败'));
|
||||
}
|
||||
},
|
||||
[message],
|
||||
[incidentStatus, message],
|
||||
);
|
||||
|
||||
const changeIncidentStatus = useCallback((status: IncidentStatus) => {
|
||||
const firstPage = { sms: 1, oneclick: 1, compare: 1 };
|
||||
listPageRef.current = firstPage;
|
||||
setListPage(firstPage);
|
||||
setLists({});
|
||||
setExpanded(new Set());
|
||||
setDetails({});
|
||||
setDetailLoading(new Set());
|
||||
setDetailPage({});
|
||||
setIncidentStatus(status);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
const timer = window.setInterval(() => void loadAll(true), 60 * 60 * 1000);
|
||||
@@ -516,6 +547,56 @@ export default function RiskMonitorPage() {
|
||||
[lists, loadAll, message, modal],
|
||||
);
|
||||
|
||||
const revokeRestriction = useCallback(
|
||||
(row: RiskIncidentItem) => {
|
||||
if (!row.restriction_id) {
|
||||
void message.error('未找到有效的封禁记录,请刷新后重试');
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: `确认解除该${row.subject_type === 'device' ? '设备' : '账户'}的封禁?`,
|
||||
content:
|
||||
row.subject_type === 'device'
|
||||
? '解除后,该设备可以重新发送短信验证码和使用一键登录。'
|
||||
: '解除后,该账户可以重新发起比价、领取任务奖励和提现。',
|
||||
okText: '解除封禁',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
setActing(row.incident_id);
|
||||
try {
|
||||
await api.post(
|
||||
`/admin/api/risk-monitor/restrictions/${row.restriction_id}/revoke`,
|
||||
);
|
||||
void message.success('解除封禁成功');
|
||||
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 = {
|
||||
@@ -537,21 +618,35 @@ export default function RiskMonitorPage() {
|
||||
>
|
||||
{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>
|
||||
{row.status === 'open' && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{row.status === 'blocked' && (
|
||||
<Button
|
||||
size="small"
|
||||
loading={acting === row.incident_id}
|
||||
disabled={!row.restriction_id}
|
||||
onClick={() => revokeRestriction(row)}
|
||||
>
|
||||
解除封禁
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -605,7 +700,7 @@ export default function RiskMonitorPage() {
|
||||
actionColumn,
|
||||
];
|
||||
},
|
||||
[acting, copy, detailLoading, expanded, runAction, toggle],
|
||||
[acting, copy, detailLoading, expanded, revokeRestriction, runAction, toggle],
|
||||
);
|
||||
|
||||
const cards = useMemo(() => {
|
||||
@@ -688,6 +783,25 @@ export default function RiskMonitorPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className={styles.statusToolbar}>
|
||||
<div>
|
||||
<strong>风险处置</strong>
|
||||
<span>
|
||||
{incidentStatus === 'open'
|
||||
? '查看待处理报警,可执行封禁或忽略'
|
||||
: '查看当前已封禁主体,可恢复其业务权限'}
|
||||
</span>
|
||||
</div>
|
||||
<Segmented
|
||||
value={incidentStatus}
|
||||
options={[
|
||||
{ label: '待处理', value: 'open' },
|
||||
{ label: '已封禁', value: 'blocked' },
|
||||
]}
|
||||
onChange={(value) => changeIncidentStatus(value as IncidentStatus)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{RISK_KINDS.map((kind) => {
|
||||
const data = lists[kind]?.items ?? [];
|
||||
return (
|
||||
@@ -700,7 +814,17 @@ export default function RiskMonitorPage() {
|
||||
dataSource={data}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: kind === 'compare' ? 900 : 890 }}
|
||||
locale={{ emptyText: <Empty description="当前没有待处理风险" /> }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={
|
||||
incidentStatus === 'open'
|
||||
? '当前没有待处理风险'
|
||||
: '当前没有已封禁主体'
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
pagination={{
|
||||
current: listPage[kind],
|
||||
pageSize: LIST_PAGE_SIZE,
|
||||
@@ -728,7 +852,7 @@ export default function RiskMonitorPage() {
|
||||
|
||||
<footer className={styles.footerNote}>
|
||||
<strong>处理口径:</strong>
|
||||
封禁设备只拦截该设备发送短信和一键登录,不影响它登录过的账户;封禁账户只拦截该账户的比价、任务奖励和提现,不连坐设备。重置报警只清空待处理计数并建立新的累计基线,不删除历史流水、不解除封禁。所有处置均写入管理员审计日志。
|
||||
封禁设备只拦截该设备发送短信和一键登录,不影响它登录过的账户;封禁账户只拦截该账户的比价、任务奖励和提现,不连坐设备。误封可在“已封禁”视图解除。重置报警只清空待处理计数并建立新的累计基线,不删除历史流水、不解除封禁。所有处置均写入管理员审计日志。
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -268,6 +268,7 @@ export interface RiskIncidentItem {
|
||||
event_count: number;
|
||||
status: 'open' | 'ignored' | 'blocked' | 'resolved';
|
||||
restricted: boolean;
|
||||
restriction_id: number | null;
|
||||
}
|
||||
|
||||
export interface RiskIncidentPage {
|
||||
|
||||
Reference in New Issue
Block a user