video-shuoshan/web/src/pages/AnomalyLogPage.tsx
seaislee1209 be656900c0
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
feat: v0.9.7 登录风控第二期 — IP归属地解析 + 异常检测(R1-R5) + 飞书告警 + 自动封禁
- IP138 在线 API + ip2region 离线库双通道归属地解析,60 秒熔断降级
- 5 条异常检测规则:地区不对/不可能旅行/频繁登录/团队遍地开花/海外IP太杂
- 飞书 interactive 卡片告警(红色严重/橙色警告),含辅助指标
- R2 自动封禁用户、R4 自动封禁团队,封禁即踢下线
- 系统设置页全局配置 + 团队详情页独立阈值覆盖
- 安全日志页面 + 管理员修改密码入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:02:56 +08:00

225 lines
8.3 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { LoginAnomaly, Team } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import styles from './AuditLogsPage.module.css';
const RULE_OPTIONS = [
{ label: '全部规则', value: '' },
{ label: 'R1 登录地区不对', value: 'region_mismatch' },
{ label: 'R2 不可能的旅行', value: 'impossible_travel' },
{ label: 'R3 登录太频繁', value: 'login_frequency' },
{ label: 'R4 团队遍地开花', value: 'multi_city' },
{ label: 'R5 海外IP太杂', value: 'overseas_ip_diversity' },
];
const LEVEL_OPTIONS = [
{ label: '全部级别', value: '' },
{ label: '警告', value: 'warning' },
{ label: '严重', value: 'critical' },
];
const RULE_LABELS: Record<string, string> = {
region_mismatch: 'R1 登录地区不对',
impossible_travel: 'R2 不可能的旅行',
login_frequency: 'R3 登录太频繁',
multi_city: 'R4 团队遍地开花',
overseas_ip_diversity: 'R5 海外IP太杂',
};
export function AnomalyLogPage() {
const [anomalies, setAnomalies] = useState<LoginAnomaly[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [ruleFilter, setRuleFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [loading, setLoading] = useState(true);
const [teams, setTeams] = useState<Team[]>([]);
const pageSize = 20;
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchAnomalies = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getLoginAnomalies({
page, page_size: pageSize,
rule: ruleFilter || undefined,
level: levelFilter || undefined,
team_id: teamFilter ? Number(teamFilter) : undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setAnomalies(data.results);
setTotal(data.total);
} catch {
showToast('加载安全日志失败');
} finally {
setLoading(false);
}
}, [page, ruleFilter, levelFilter, teamFilter, startDate, endDate]);
useEffect(() => { fetchAnomalies(); }, [fetchAnomalies]);
const handleSearch = () => {
setPage(1);
fetchAnomalies();
};
const totalPages = Math.ceil(total / pageSize);
const teamOptions = [
{ label: '全部团队', value: '' },
...teams.map((t) => ({ label: t.name, value: String(t.id) })),
];
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<Select
value={ruleFilter}
onChange={(v) => { setRuleFilter(v); setPage(1); }}
placeholder="全部规则"
options={RULE_OPTIONS}
/>
<Select
value={levelFilter}
onChange={(v) => { setLevelFilter(v); setPage(1); }}
placeholder="全部级别"
options={LEVEL_OPTIONS}
/>
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={teamOptions}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
<button className={styles.refreshBtn} onClick={fetchAnomalies}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>IP / </th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : anomalies.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
anomalies.map((a) => (
<tr key={a.id}>
<td className={styles.timeCell}>{new Date(a.created_at).toLocaleString('zh-CN')}</td>
<td>{a.team_name}</td>
<td>{a.username}</td>
<td>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap',
background: a.level === 'critical' ? 'rgba(255, 77, 79, 0.15)' : 'rgba(250, 173, 20, 0.15)',
color: a.level === 'critical' ? '#ff4d4f' : '#faad14',
}}>
{a.level === 'critical' ? '严重' : '警告'}
</span>
</td>
<td><span className={styles.actionBadge}>{RULE_LABELS[a.rule] || a.rule}</span></td>
<td className={styles.ipCell}>
{a.ip_address}
{(a.geo_country || a.geo_city) && (
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
{[a.geo_country, a.geo_province, a.geo_city].filter(Boolean).join(' ')}
</div>
)}
</td>
<td style={{ fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{renderDetail(a)}
</td>
<td>
{a.auto_disabled ? (
<span style={{ fontSize: 12, color: '#ff4d4f' }}>
{a.disabled_target === 'team' ? '团队' : '用户'}
</span>
) : a.alerted ? (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>-</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}
function renderDetail(a: LoginAnomaly): string {
const d = a.detail;
switch (a.rule) {
case 'region_mismatch':
return `预期: ${(d.expected as string[] || []).join(',')} | 实际: ${d.city || ''}`;
case 'impossible_travel':
return `${d.previous_city || ''}${d.current_city || ''}`;
case 'login_frequency':
return `${d.count || 0} 次 / ${d.window_seconds || 0}s`;
case 'multi_city':
return `预期外城市: ${(d.unexpected_cities as string[] || []).join(',')}`;
case 'overseas_ip_diversity':
return `海外国家: ${(d.countries as string[] || []).join(',')}`;
default:
return JSON.stringify(d);
}
}